Skip to main content

An Airtable code generator, focused on developer experience.

Project description

myAirtable

An Airtable code generator, focused on developer experience.

[!WARNING] myAirtable is under active development.

Languages supported:

  • Python (via pyAirtable)
  • TypeScript (via airtable.js)
  • JavaScript (via airtable.js)
  • Rust (via a custom-built client)
  • Swift (via a custom-built client)
  • Kotlin (via a custom-built client on Ktor + kotlinx.serialization)
  • Java (via a custom-built client on the JDK's java.net.http.HttpClient + Jackson)
  • Go (via a custom-built client on the standard library — net/http + encoding/json, zero third-party dependencies)
  • C# (via a custom-built .NET 8 client on HttpClient + System.Text.Json, async/await, zero third-party dependencies)
  • C++ (via a custom-built C++20 client on libcurl + a vendored nlohmann/json; header-only distribution)

Kotlin

The generated Kotlin code uses @Serializable models, so the kotlinx.serialization compiler plugin is REQUIRED — without kotlin("plugin.serialization") the generated code will not compile. A known-good Gradle setup (build.gradle.kts):

plugins {
    kotlin("jvm") version "2.2.20"
    kotlin("plugin.serialization") version "2.2.20" // REQUIRED for the generated @Serializable models
}

sourceSets {
    main {
        kotlin.srcDir("path/to/generated/output") // wherever you generate into
    }
}

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
    implementation("io.ktor:ktor-client-core:3.2.3")
    implementation("io.ktor:ktor-client-cio:3.2.3")
}

Java

The generated Java code targets Java 21+ (sealed interfaces, records, pattern-matching switch) and is a plain blocking API (cheap on virtual threads). It only needs Jackson databind on the classpath:

java {
    toolchain { languageVersion = JavaLanguageVersion.of(21) }
}

sourceSets {
    main {
        java.srcDir("path/to/generated/output") // wherever you generate into
    }
}

dependencies {
    implementation("com.fasterxml.jackson.core:jackson-databind:2.18.2")
}

[!IMPORTANT] Do NOT register jackson-datatype-jsr310 (or call findAndRegisterModules()) on the runtime's mapper — the bundled AirtableJacksonModule owns Instant/Duration encoding (Airtable durations are numeric seconds, not ISO PT… strings).

Models are mutable POJOs created through a generated Builder (the Java analog of the other targets' named arguments): PrimaryModel.builder().primaryKey("x").build(). Bulk deletes are named deleteAll(ids) / deleteModels(models) because Java's type erasure forbids overloading delete(List<String>) against delete(List<Model>).

Error handling

Every failure is an AirtableException — a sealed, unchecked hierarchy (checked exceptions would poison every generated signature). The subtypes are Http (non-2xx, with statusCode()/body()), Api (a structured Airtable error envelope, with code()/apiMessage()), RateLimited (429, with retryAfterSeconds()), Decoding, Network, InvalidUrl, and MissingCredentials. Catch the base type for everything, or a specific subtype for targeted handling:

try {
    var contacts = airtable.primary().get();
} catch (AirtableException.RateLimited e) {
    // Only reached after retries are exhausted (see below).
    log.warn("rate limited; retry after {}s", e.retryAfterSeconds());
} catch (AirtableException e) {
    log.error("airtable call failed", e);
}

Because the hierarchy is sealed, you can also pattern-match exhaustively with a switch (Java 21):

String describe(AirtableException e) {
    return switch (e) {
        case AirtableException.Http h -> "HTTP " + h.statusCode();
        case AirtableException.Api a -> a.code() + ": " + a.apiMessage();
        case AirtableException.RateLimited r -> "rate limited (" + r.retryAfterSeconds() + "s)";
        case AirtableException.Decoding d -> "decode error";
        case AirtableException.Network n -> "network error";
        case AirtableException.InvalidUrl u -> "bad url";
        case AirtableException.MissingCredentials m -> "missing credentials";
    };
}

[!NOTE] 429 and 5xx responses are automatically retried with exponential backoff (plus jitter, honoring a server Retry-After). AirtableException.RateLimited only surfaces once retries are exhausted.

Resource lifecycle

Airtable (and the underlying AirtableClient) implements AutoCloseable. A long-lived application should construct one Airtable and hold it for the process lifetime. For short-lived use, a try-with-resources block is idiomatic:

try (var airtable = new Airtable(baseId, apiKey)) {
    var contacts = airtable.primary().get();
}

[!NOTE] close() only closes the HttpClient the runtime owns — when you inject your own HttpClient (via the full AirtableClient constructor), close() is a no-op and the client remains yours to manage.

Clearing a field

To clear a field server-side, set it to null on a fetched model and update it. Dirty-tracking diffs the model against the snapshot taken at fetch time and emits an explicit JSON null for the cleared field:

var contact = airtable.primary().get("rec1234567890"); // fetched: carries a snapshot
contact.setSingleLineText(null);
airtable.primary().update(contact); // sends {"fields": {"fld...": null}}, clearing it

[!IMPORTANT] This only works on a model obtained from the table — it has a snapshot to diff against. A freshly built model (PrimaryModel.builder()...build()) has no snapshot, so a null field is simply omitted from the create payload rather than sent as an explicit clear.

Filtering by formula

Filter with the per-field f accessor and pass the resulting formula string via AirtableQuery.withFormula:

var bobs = airtable.primary()
    .get(new AirtableQuery().withFormula(PrimaryModel.f.singleLineText.contains("Bob")));

Text fields expose eq/neq/contains/startsWith/endsWith/regexMatch (etc.); number fields expose eq/neq/greaterThan/lessThan/between (etc.). Combine clauses with the Formulas combinators and / or / not / xor:

var formula = Formulas.and(
    PrimaryModel.f.singleLineText.contains("Bob"),
    PrimaryModel.f.numberInt.greaterThan(10));
var results = airtable.primary().get(new AirtableQuery().withFormula(formula));

Upsert

upsert(model, mergeOnFieldIds) inserts or updates a record, matching on the given field IDs. It returns an OrmTable.UpsertResult<T> exposing .model() (the merged record) and .wasCreated() (insert vs update). Merge keys come from the generated {Table}Fields ID constants:

var result = airtable.primary().upsert(
    PrimaryModel.builder().primaryKey("alice@example.com").build(),
    List.of(PrimaryFields.primaryKeyId));
if (result.wasCreated()) {
    log.info("created {}", result.model().getId());
}

Go

The generated Go code targets Go 1.21+ and depends on the standard library only (net/http + encoding/json — no third-party modules). All files (the static runtime and the generated code) live in one flat package airtable, and the generator emits no go.mod — the consuming project owns its module and imports the generated package:

import airtable "yourmodule/path/to/output"

at := airtable.NewWithBase(apiKey, baseID, 0) // cacheSeconds; 0 disables caching

Idioms:

  • Models are structs with json:"fldID" tags; optional fields are pointers (*string, *float64, …) so absent and cleared are distinguishable. Build them with the pointer helpers: airtable.PrimaryModel{PrimaryKey: airtable.String("x")}.
  • context.Context is the first argument of every I/O method: at.Primary.GetOne(ctx, id), rec.Save(ctx).
  • Errors are returned, never panicked — typed errors via errors.As (*airtable.APIError, *airtable.RateLimitError, …) and sentinels via errors.Is (airtable.ErrNotFound). 429/5xx are retried with backoff before surfacing.
  • Typed table layer uses generics: at.Primary is a Table[PrimaryModel, *PrimaryModel]. Methods follow an explicit *One/*Many naming convention (Go has no overloading): GetOne/GetMany, CreateOne/CreateMany, UpdateOne/UpdateMany, DeleteOne/DeleteMany, plus Upsert and .Dict() for raw access. Per-model fluent Save/Fetch/Delete work on models bound via at.Primary.Attach(&m) or returned from the table.
  • Select options are typed-string constants; computed fields decode into *MaybeSpecialOrError[T] (read with .Value()), lookups/rollups into *VecOrValue[MaybeSpecialOrError[T]] (read with CleanValues).
  • Filtering: at.Primary.GetMany(ctx, (&airtable.Query{}).WithFilterFormula(airtable.PrimaryF.PrimaryKey.Eq("alice@example.com"))).

Generated code is gofmt-clean by construction; format/lint via gofmt/go vet/golangci-lint.

C#

The generated C# code targets .NET 8 / C# 12 and depends on the base class library only (HttpClient + System.Text.Json — no third-party packages). Every file declares the single file-scoped namespace MyAirtable;, and the generator emits no .csproj — the consuming project owns its build:

var airtable = new Airtable(baseId, apiKey, cacheSeconds: 0); // 0 disables caching

Idioms:

  • Models are mutable sealed classes with [JsonPropertyName("fldID")] and nullable auto-properties. Writable fields are { get; set; }; computed fields are [JsonInclude] { get; private set; } (decode-only). Build them with object initializers: new PrimaryModel { PrimaryKey = "x" }.
  • async/await throughout: every I/O method is …Async and takes a CancellationTokenawait at.Primary.GetAsync(id), await model.SaveAsync().
  • Errors are an unchecked hierarchy — catch (AirtableException) or pattern-match a nested case (AirtableException.ApiError, .RateLimitedError, …). 429/5xx are retried with jittered backoff before surfacing.
  • Typed ORM is the default on each table accessor — at.Primary is an OrmTable<PrimaryModel> (reified generics, no class token), so at.Primary.GetAsync(id)/CreateAsync/UpdateAsync/UpsertAsync/DeleteAsync return typed models. at.Primary.Dict gives raw field-bag access. Per-model fluent SaveAsync/FetchAsync/DeleteAsync.
  • Select options are C# enums with a generated per-enum JsonConverter; computed fields decode into MaybeSpecialOrError<T>? (read with .ValueOrDefault), lookups/rollups into VecOrValue<MaybeSpecialOrError<T>>? (read with VecOrValue.CleanValues).
  • Filtering: at.Primary.GetAsync(new AirtableQuery().WithFormula(PrimaryModel.F.PrimaryKey.Eq("alice@example.com"))).

Generated code is formatted with CSharpier.

C++

The generated C++ code targets C++20 and is header-only: add the output folder to your include path, link libcurl (preinstalled on macOS/Linux), and compile the vendored timezone implementation in exactly one translation unit:

// tz_impl.cpp — one file in your project (stb-style single-TU implementation)
#define MYAIRTABLE_TZ_IMPLEMENTATION
#include "static/airtable_tz.hpp"
#include "airtable.hpp"
myairtable::Airtable at(apiKey, /*cache_seconds=*/0);  // 0 disables caching

Idioms:

  • Models are aggregate structs over a data-free CRTP behavior base: public std::optional<T> members, created with designated initializers — PrimaryModel{.primary_key = "x"} — and passed to the table's create(). Absent and JSON-null both decode to std::nullopt.
  • Blocking, thread-safe client: per-request curl handles over a shared DNS/TLS-session cache; 429/5xx retried with jittered backoff before surfacing.
  • Errors are exceptions rooted in std::runtime_errorcatch (const myairtable::AirtableException&) or a specific subclass (ApiError, RateLimitedError, …).
  • Typed ORM is the default on each table accessor — at.primary() is an OrmTable<PrimaryModel>, so get/get_all/create/update/upsert return typed models; .dict() gives raw field-bag access. Per-model fluent save()/fetch()/remove() (delete is a C++ keyword — the one naming divergence).
  • Select options are enum classes with generated wire serializers; computed fields decode into std::optional<MaybeSpecialOrError<T>> (read with ->value()), lookups/rollups into std::optional<VecOrValue<MaybeSpecialOrError<T>>> (read with ->clean_values()). Computed members are public but never serialized on write — mutating one and calling save() sends nothing for it (pinned by tests).
  • Filtering: at.primary().get_many(AirtableQuery{.formula = PrimaryModel::F.primary_key.eq("alice@example.com")}). Combinators are Formulas::and_/or_/not_ (and/or/not are C++ alternative tokens).
  • Portability note: designated-initializer-over-base relies on the C++20 P1975 defect resolution; AppleClang is the CI-gated compiler, GCC/MSVC are unverified.

Generated code is formatted with clang-format (vendored headers exempt).

Features

The following examples are in Python, but most features are supported in every language. See notes in each section for language-specific differences.

[!NOTE] In the examples below, myairtable_output stands in for your generated package — the actual import path depends on the output folder you generate into.

ORM Models

myAirtable generates strongly-typed RecordDicts and ORM classes, intended for use with the pyAirtable library.

# Fully-typed versions of pyAirtable's RecordDict TypedDict class
class ContactsRecordDict(RecordDict):
  fields: dict[ContactsField, Any] # ContactsField is a Literal of the field names in the Contacts table

name = contact["fields"]["Name"] # your IDE will suggest "Name"

# Instance of pyAirtable's ORM
class ContactsModel(Model):
  name: SingleLineTextField = SingleLineTextField(field_name="fld123")
  address: MultilineTextField = MultilineTextField(field_name="fld789")
  # etc

name = contact.name

[!NOTE] For JavaScript & TypeScript, the ORM models are custom to myAirtable, though they still use the Airtable.js client for save/delete, and contain methods for conversion to/from Airtable.js's "Record" class. Also, they use Zod validation under-the-hood.

For Rust, Swift, Kotlin, and Java, 100% of the code is custom to myAirtable. The convenient linked-record traversal syntax in Python and TS/JS is not (yet?) implemented in these targets — linked records are raw record-ID lists resolved through the linked table's get.

Formula Builders

myAirtable also generates formula builders, for use when filtering by formula. pyAirtable already includes decent formula builders, but their options are currently limited to simple operations (e.g. =, >, <, etc), without any type-specific operations. myAirtable's formula builders include additional operations (e.g. "string contains", "date is N days ago", etc). You can access the myAirtable formula helpers from the .f property on each ORM class. myAirtable's formula builders are fully compatible with pyAirtable's formula builders.

from myairtable_output import Airtable, AND, OR, ContactsModel

formula: str = AND(
  ContactsModel.f.first_name.contains("Bob") & (ContactsModel.f.last_name == "Smith"),
  ContactsModel.f.birthday.after().years_ago(30),
  ContactsModel.f.birthday < "2019-04-01",
  (ContactsModel.f.age < 10) | (ContactsModel.f.age == 12) | (ContactsModel.f.age > 15),
  "{fld1234567890}='you can also put raw strings here'",
)

Airtable().contacts.get(formula=formula)

[!NOTE] For JavaScript, TypeScript, Rust, Swift, Kotlin, and Java, the formula builders output strings, and lack the Python-specific convenience of dunder methods, but are otherwise the same. (Kotlin and Java name the equality pair eq/neqequals collides with Any.equals/Object.equals on the JVM.)

Table/CRUD Wrappers

Finally, myAirtable generates custom lightweight wrapper classes, which expose pyAirtable's CRUD methods with strongly-typed kwargs, and provide easy access to the tables through a simple interface.

from myairtable_output import Airtable, ContactsModel, ContactsRecordDict

airtable = Airtable()

# CRUD operations for pyAirtable ORMs
contact: ContactsModel = airtable.contacts.get("rec1234567890")
contact.name = "Bob"
contact.save() # pyAirtable's ORM models have handy functions like .save()
airtable.contacts.update(contact) # or you can use myAirtable's wrapper if you prefer that syntax

# table.get() method has kwargs for most of pyAirtable's options, which are otherwise less clear. View and Fields kwargs are typed.
contacts: list[ContactsModel] = airtable.contacts.get(view="Family & Friends", fields=["Name", "Age"])
for contact in contacts:
  contact.age = contact.age + 1
  contact.save()

# CRUD operations for pyAirtable RecordDicts
contact: ContactsRecordDict = airtable.contacts.dict.get("rec1234567890")
contact["fields"]["name"] = "Joe"
airtable.contacts.dict.update(contact)

[!NOTE] For JavaScript & TypeScript, the equivalents of .dict are integrated into the standard CRUD operations. They will return/accept the myAirtable's AirtableModel classes, Airtable.js's Record<FieldSet> class, or a plain interface containing the json data.

Name-locking and custom names

myAirtable optionally generates CSV files containing the names/ids of the tables and fields, including the "property name" or "model name" (for the ORM models) that they will be given in myAirtable's output. These CSV files, if present in the destination folder, will be used as the source of truth for table, model, and field names in the generated code. They can thus be used to prevent class/property names from changing unexpectedly when someone else changes a field name in Airtable, or for customizing the class/property names as they appear in code, if you prefer a different name for a given table/field. They can also be handy for resolving duplicate property name issues if that happens.

Documentation (Markdown and HTML)

myAirtable also includes support for generating documentation for your Airtable base. There is a version in Markdown (intended for Obsidian) and in HTML (intended as a static website). This documentation includes:

  • Files for every table & field, with metadata for each.
  • Tags for each field type for easy sorting/filtering
  • Links between related tables/fields, whether by link, lookup, rollup, or formula.
  • Formula fields are where it really shines. It shows:
    • A "flattened" version of the formula. If the formula references another formula (etc), the whole thing is shown.
    • A formatted and syntax-colored version of the formula, for easy readability.
    • A Mermaid representation of the formula.

Extra Goodies (because I can't stop coding...)

  • All the types: just about everything from the schema has a constant/dict/type for convenience. Want an array of the options for a select field? How about a map between field ids and names? Or perhaps a union type representing all table names? It's all in there.
  • Convenience functions to:
    • build an Airtable URL for base/table/view/record
    • get the schema, either static (the one used to build the code) or live (from Airtable's API)
  • Optional caching
  • Optional runtime formula evaluation: if enabled, formula fields have their formula transpiled to native code, to allow runtime (re)evaluation. Supports nearly all formulas (can't do LAST_MODIFIED_TIME or CREATED_TIME).
  • MCP Server: Allows agents to analyze your Airtable schema

Getting Started

Requires Python 3.12+.

Install

To run the code generator, install with the cli extra:

uv tool install "myairtable[cli]"
myairtable --help

Then provide credentials, either as flags (--base-id, --api-key) or via a .env file in the working directory:

AIRTABLE_API_KEY=your_airtable_api_key_here
AIRTABLE_BASE_ID=app1234567890

Extras

The package splits along what you actually need, so importing the analysis layer doesn't drag in the whole generator:

Install Gets you
myairtable The analysis layer only — myairtable.schema_tools (28 read-only tools over the public meta API), for importing into your own code
myairtable[cli] The above plus the code generator for all 11 targets (the myairtable command)
myairtable[mcp] The above plus the MCP server

Developing on myAirtable itself

  1. Clone the repo
  2. Install uv
  3. uv sync — installs the project editable, with every extra and dev tool
  4. Add the .env above
  5. uv run myairtable --help

MCP Server

myAirtable includes an MCP server that exposes read-only tools for Airtable schema introspection and analysis.

Setup

Add the following to your MCP client config (e.g. claude_desktop_config.json for Claude Desktop, or .claude/settings.json for Claude Code):

{
	"mcpServers": {
		"myairtable": {
			"command": "uv",
			"args": ["run", "--directory", "/path/to/myairtable", "python", "-m", "myairtable.mcp_server"],
			"env": {
				"AIRTABLE_API_KEY": "your_airtable_api_key_here",
				"AIRTABLE_BASE_ID": "app1234567890"
			}
		}
	}
}

If you have a .env file configured (see step 4 above), you can omit the env block.

Tools on the CLI

Every MCP tool is also a CLI subcommand under myairtable tools — handy for scripting, piping to jq, or testing without an MCP client:

myairtable tools list-tables            # public meta-API tools
myairtable tools transpile Jobs "Total" --language python
myairtable tools --help                 # list all commands

Output is JSON by default (--pretty for rich rendering). Add --save (before the subcommand) to offload large results to .data/tools/ and print the envelope, mirroring the MCP server. The same schema_tools.py functions back both the MCP tools and these commands.

Large results are saved to disk

Many tools can return large payloads (whole-base scans, full schema dumps). When a result exceeds the inline limit (default 16 KB; override with MYAIRTABLE_INLINE_MAX_BYTES), the full result is written to a gitignored .data/tools/<tool>__<args>.json and the caller receives a small envelope instead:

{ "saved_to": "...", "bytes": 412934, "record_count": 287,
  "summary": { ... }, "schema": { ...compact shape skeleton... }, "note": "..." }

schema is a depth-capped structural skeleton of the file (keys → type tags, arrays → length + element shape) — enough to jq/grep the file without re-running the tool. This applies to all MCP tools via a server middleware. From the CLI, add --save (e.g. myairtable tools --save list-automations) to exercise the same path. Note: audit_shares writes share-URL tokens to that local file.

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

myairtable-0.1.2.tar.gz (709.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

myairtable-0.1.2-py3-none-any.whl (846.5 kB view details)

Uploaded Python 3

File details

Details for the file myairtable-0.1.2.tar.gz.

File metadata

  • Download URL: myairtable-0.1.2.tar.gz
  • Upload date:
  • Size: 709.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for myairtable-0.1.2.tar.gz
Algorithm Hash digest
SHA256 2734074d42a5e2acb399ffe6338b5f440cbf3d17cc9881968a2d9fd1e430f4a1
MD5 3f59af95b81f07a681752e1e4372c248
BLAKE2b-256 960747fd1aae7396456ddfa0cd52b02f8d147c19b595096f803a692db9e3da7b

See more details on using hashes here.

Provenance

The following attestation bundles were made for myairtable-0.1.2.tar.gz:

Publisher: release.yml on danielrbaughman/myairtable

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file myairtable-0.1.2-py3-none-any.whl.

File metadata

  • Download URL: myairtable-0.1.2-py3-none-any.whl
  • Upload date:
  • Size: 846.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for myairtable-0.1.2-py3-none-any.whl
Algorithm Hash digest
SHA256 a2d5b6bc80897e08a262b32e9dd34949a0fffeb543fa7f2a29c27768ab184efb
MD5 e48ad34a5dab57a63c1a8edd1159941e
BLAKE2b-256 310f2f34a05c5976165b6d4658ed020aded2d83fe7dbdca20df537d47f674785

See more details on using hashes here.

Provenance

The following attestation bundles were made for myairtable-0.1.2-py3-none-any.whl:

Publisher: release.yml on danielrbaughman/myairtable

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page