mailrows 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mailrows/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """mailrows: mail merge from Excel or CSV, one templated email per row, safely."""
2
+
3
+ __version__ = "0.1.0"
mailrows/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from mailrows.cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,87 @@
1
+ ; mailrows settings. Copy this file to config.ini and fill it in.
2
+ ; config.ini is listed in .gitignore: never commit it.
3
+ ;
4
+ ; Any value can come from an environment variable instead, named MAILROWS_<SECTION>_<KEY>,
5
+ ; for example MAILROWS_SMTP_PASSWORD or MAILROWS_AIRTABLE_TOKEN. The variable wins.
6
+ ; Paths are relative to this file's folder.
7
+
8
+ [input]
9
+ file = events.csv
10
+ ; for an .xlsx file with several tabs, the tab to read (default: the first)
11
+ sheet =
12
+ ; which columns mean what
13
+ id_column = id
14
+ email_column = email
15
+ ; mailrows writes progress here after each email. Leave blank to not touch the file.
16
+ status_column = status
17
+ ; rows with any of these empty are skipped
18
+ required_columns = first_name, email
19
+ ; for a large .xlsx: save the status column every N emails instead of after each one.
20
+ ; The send log is updated after every email either way.
21
+ status_save_every = 1
22
+
23
+ [template]
24
+ ; first line "Subject: ...", then the body. Placeholders look like {first_name}.
25
+ text = template.txt
26
+ ; optional HTML version with the same placeholders (leave blank for plain text only)
27
+ html =
28
+
29
+ [smtp]
30
+ host = smtp.example.com
31
+ port = 587
32
+ ; ssl (usually port 465), starttls (usually 587) or none. 'mailrows smtp-check' finds the right one.
33
+ security = starttls
34
+ username = your.name@example.com
35
+ password = CHANGE_ME
36
+ timeout = 30
37
+
38
+ [sender]
39
+ from_name = Example Community Events
40
+ from_email = events@example.com
41
+ reply_to = hello@example.com
42
+ ; optional: a copy of every real (not test) email goes here, invisibly
43
+ bcc =
44
+ ; seconds to wait between emails
45
+ delay_seconds = 2
46
+
47
+ [log]
48
+ ; the send log that stops anything going out twice. Keep it with the spreadsheet.
49
+ path = mailrows-log.sqlite3
50
+
51
+ [status]
52
+ ; words written to the status column
53
+ sent = sent
54
+ pending = sent, update pending
55
+ unknown = check: may have been sent
56
+
57
+ [hooks]
58
+ ; things to do after each email, comma separated: airtable, webhook, csv_append. Off by default.
59
+ enabled =
60
+
61
+ [airtable]
62
+ base_id = appXXXXXXXXXXXXXX
63
+ ; table name or ID
64
+ table = Attendees
65
+ ; the Airtable field to find the record by, and the spreadsheet column with the same value
66
+ match_field = Ticket ID
67
+ match_column = id
68
+ ; personal access token with data.records:read and data.records:write on this base only
69
+ token = CHANGE_ME
70
+
71
+ [airtable.fields]
72
+ ; Airtable field = value. {column} takes the value from the row. Built-ins: {_sent_at}, {_sent_date},
73
+ ; {_row_id}, {_recipient}, {_subject}.
74
+ Invite status = Invited
75
+ Invite sent on = {_sent_date}
76
+
77
+ [webhook]
78
+ url = https://hooks.example.com/mailrows
79
+ ; optional header, for example a shared secret
80
+ header_name =
81
+ header_value = CHANGE_ME
82
+ ; columns to include in the JSON (default: all)
83
+ columns =
84
+
85
+ [csv_append]
86
+ path = sent-copy.csv
87
+ columns = id, email, _sent_at
@@ -0,0 +1,6 @@
1
+ id,first_name,last_name,email,session,seats,status
2
+ E-1001,Ada,Testperson,ada.testperson@example.com,Morning workshop,1,
3
+ E-1002,Bram,Sampleton,bram.sampleton@example.com,Afternoon talks,2,
4
+ E-1003,Cleo,Placeholder,cleo.placeholder@example.com,Morning workshop,1,
5
+ E-1004,Dev,Fakename,dev.fakename@example,Afternoon talks,1,
6
+ E-1005,Esme,Notreal,esme.notreal@example.com,Evening social,3,
Binary file
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html>
3
+ <body style="font-family: Arial, sans-serif; line-height: 1.5; color: #222;">
4
+ <p>Hi {first_name},</p>
5
+ <p>Thanks for signing up for the <strong>Riverside Maker Day</strong> on Saturday 14 March.<br>
6
+ You're booked into: <strong>{session}</strong> ({seats} seat(s)).</p>
7
+ <p>Doors open at 9:30. Bring a laptop if you have one; we'll have power strips,
8
+ coffee and plenty of spare cables.</p>
9
+ <p>Your booking reference is <code>{id}</code>. If you can't make it, just reply to this email
10
+ and we'll offer your place to someone on the waiting list.</p>
11
+ <p>See you there,<br>The Maker Day volunteers</p>
12
+ </body>
13
+ </html>
@@ -0,0 +1,14 @@
1
+ Subject: Your place at the Riverside Maker Day, {first_name}
2
+ Hi {first_name},
3
+
4
+ Thanks for signing up for the Riverside Maker Day on Saturday 14 March.
5
+ You're booked into: {session} ({seats} seat(s)).
6
+
7
+ Doors open at 9:30. Bring a laptop if you have one; we'll have power strips,
8
+ coffee and plenty of spare cables.
9
+
10
+ Your booking reference is {id}. If you can't make it, just reply to this email
11
+ and we'll offer your place to someone on the waiting list.
12
+
13
+ See you there,
14
+ The Maker Day volunteers
mailrows/cli.py ADDED
@@ -0,0 +1,153 @@
1
+ """Command line: mailrows send | smtp-check | resolve | init."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import shutil
7
+ import smtplib
8
+ import sqlite3
9
+ from pathlib import Path
10
+
11
+ from mailrows import __version__, run, smtpcheck
12
+ from mailrows.config import ConfigError, load, looks_unset
13
+ from mailrows.table import TableError
14
+ from mailrows.template import TemplateError
15
+
16
+ STARTER_FILES = {
17
+ "config.example.ini": "config.ini",
18
+ "examples/events.csv": "events.csv",
19
+ "examples/template.txt": "template.txt",
20
+ "examples/template.html": "template.html",
21
+ }
22
+
23
+
24
+ def starter_source() -> Path:
25
+ """Example files: inside the installed package, or the repository root when run from source."""
26
+ pkg_dir = Path(__file__).resolve().parent
27
+ packaged = pkg_dir / "_starter"
28
+ return packaged if packaged.is_dir() else pkg_dir.parents[1]
29
+
30
+
31
+ def cmd_init(args) -> int:
32
+ dest = Path(args.folder)
33
+ dest.mkdir(parents=True, exist_ok=True)
34
+ src = starter_source()
35
+ made = []
36
+ for rel, name in STARTER_FILES.items():
37
+ target = dest / name
38
+ if target.exists():
39
+ print(f" kept {target} (already exists)")
40
+ continue
41
+ shutil.copyfile(src / rel, target)
42
+ made.append(target)
43
+ print(f" created {target}")
44
+ gitignore = dest / ".gitignore"
45
+ if not gitignore.exists():
46
+ gitignore.write_text("config.ini\n*.sqlite3\n*.sqlite3-journal\n", encoding="utf-8")
47
+ print(f" created {gitignore} (keeps config.ini and the send log out of git)")
48
+ print("\nNext: open config.ini, fill in the [smtp] and [sender] sections, then run")
49
+ print(f" mailrows smtp-check -c {dest / 'config.ini'}")
50
+ print(f" mailrows send --dry-run -c {dest / 'config.ini'}")
51
+ return 0
52
+
53
+
54
+ def cmd_send(args) -> int:
55
+ settings = load(args.config)
56
+ if args.dry_run:
57
+ mode = run.DRY
58
+ elif args.test_to:
59
+ mode = run.TEST
60
+ else:
61
+ mode = run.REAL
62
+ return run.send_command(settings, mode, test_to=args.test_to or "")
63
+
64
+
65
+ def cmd_smtp_check(args) -> int:
66
+ host, user, password = args.host, args.username, None
67
+ if args.config and Path(args.config).exists():
68
+ s = load(args.config)
69
+ host = host or s.get("smtp", "host", "")
70
+ user = user if user is not None else s.get("smtp", "username", "")
71
+ password = s.get("smtp", "password", "")
72
+ if not host:
73
+ raise ConfigError("Give a host: --host smtp.example.com, or set [smtp] host in the config file.")
74
+ user = user or ""
75
+ if user and (password is None or looks_unset(password)):
76
+ import getpass
77
+
78
+ password = getpass.getpass(f"Password for {user} (not shown, not saved): ")
79
+ best = smtpcheck.check(host, user, password or "", timeout=args.timeout)
80
+ return 0 if best else 1
81
+
82
+
83
+ def cmd_resolve(args) -> int:
84
+ return run.resolve_command(load(args.config), args.row_id, was_sent=args.sent)
85
+
86
+
87
+ def build_parser() -> argparse.ArgumentParser:
88
+ p = argparse.ArgumentParser(prog="mailrows",
89
+ description="Mail merge from Excel or CSV: one templated email per row, safely.")
90
+ p.add_argument("--version", action="version", version=f"mailrows {__version__}")
91
+ sub = p.add_subparsers(dest="command", required=True, metavar="command")
92
+
93
+ def with_config(sp):
94
+ sp.add_argument("-c", "--config", default="config.ini", help="settings file (default: config.ini)")
95
+
96
+ s = sub.add_parser("send", help="check every row, show a preview, then send after you type yes")
97
+ with_config(s)
98
+ mode = s.add_mutually_exclusive_group()
99
+ mode.add_argument("--dry-run", action="store_true", help="render and check everything, send and change nothing")
100
+ mode.add_argument("--test-to", metavar="ADDRESS",
101
+ help="send every email to ADDRESS with [TEST] in the subject; no files or logs change")
102
+ s.set_defaults(func=cmd_send)
103
+
104
+ c = sub.add_parser("smtp-check", help="find the port and security setting that work, without sending")
105
+ with_config(c)
106
+ c.add_argument("--host", help="mail server (default: [smtp] host from the config)")
107
+ c.add_argument("--username", help="login to test (default: [smtp] username)")
108
+ c.add_argument("--timeout", type=float, default=10, help="seconds to wait per attempt (default 10)")
109
+ c.set_defaults(func=cmd_smtp_check)
110
+
111
+ r = sub.add_parser("resolve", help="after checking your Sent folder, record whether an uncertain email went")
112
+ with_config(r)
113
+ r.add_argument("row_id")
114
+ which = r.add_mutually_exclusive_group(required=True)
115
+ which.add_argument("--sent", action="store_true", help="it arrived: never send it again")
116
+ which.add_argument("--not-sent", action="store_true", help="it did not go: send it on the next run")
117
+ r.set_defaults(func=cmd_resolve)
118
+
119
+ i = sub.add_parser("init", help="create a starter folder with a config, a sample sheet and templates")
120
+ i.add_argument("folder", nargs="?", default=".")
121
+ i.set_defaults(func=cmd_init)
122
+ return p
123
+
124
+
125
+ def main(argv=None) -> int:
126
+ run.setup_stdout()
127
+ args = build_parser().parse_args(argv)
128
+ try:
129
+ return args.func(args)
130
+ except (ConfigError, run.SetupError, TableError, TemplateError, sqlite3.Error,
131
+ smtplib.SMTPException, OSError, KeyboardInterrupt) as exc:
132
+ message, code = explain_stop(exc)
133
+ print("\n" + message)
134
+ return code
135
+
136
+
137
+ def explain_stop(exc: BaseException) -> tuple[str, int]:
138
+ """A readable line, and the exit code, for whatever ended the run early."""
139
+ if isinstance(exc, (ConfigError, run.SetupError)):
140
+ return f"Stopped before sending anything: {exc}", run.EXIT_SETUP
141
+ if isinstance(exc, smtplib.SMTPAuthenticationError):
142
+ return ("The mail server refused the username or password. Nothing was sent. "
143
+ "Run 'mailrows smtp-check' to test the login."), run.EXIT_SETUP
144
+ if isinstance(exc, sqlite3.Error):
145
+ return (f"Stopped: the send log couldn't be used ({exc}). If another mailrows run is using the same "
146
+ "log, wait for it to finish. Nothing already sent will be sent again."), run.EXIT_SETUP
147
+ if isinstance(exc, (TableError, TemplateError)):
148
+ return (f"Stopped: {exc}. Every email already sent is in the send log and won't be sent again.",
149
+ run.EXIT_SETUP)
150
+ if isinstance(exc, KeyboardInterrupt):
151
+ return "Stopped. Anything already sent is in the send log and won't be sent again.", 130
152
+ return (f"Stopped: {exc}. Every email already sent is in the send log and won't be sent again. "
153
+ "If this is a mail server problem, 'mailrows smtp-check' can help."), run.EXIT_SETUP
mailrows/config.py ADDED
@@ -0,0 +1,118 @@
1
+ """Settings: an INI file, with any value overridable by an environment variable.
2
+
3
+ Environment variables win over the file. The name is MAILROWS_<SECTION>_<KEY> in capitals,
4
+ with dots and dashes in the section name turned into underscores, for example
5
+ MAILROWS_SMTP_PASSWORD or MAILROWS_AIRTABLE_TOKEN.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import configparser
11
+ import os
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+
15
+
16
+ class ConfigError(Exception):
17
+ """A setting is missing or wrong. The message says which and how to fix it."""
18
+
19
+
20
+ PLACEHOLDERS = {"change_me", "changeme", "your_password", "your_token", "paste_here"}
21
+
22
+
23
+ def env_name(section: str, key: str) -> str:
24
+ clean = section.replace(".", "_").replace("-", "_")
25
+ return f"MAILROWS_{clean}_{key}".upper()
26
+
27
+
28
+ def looks_unset(value: str) -> bool:
29
+ """True for an empty value or an obvious placeholder. Only whole-value matches count, so a
30
+ real password that happens to contain '<' or 'your_' is accepted."""
31
+ v = value.strip()
32
+ if not v:
33
+ return True
34
+ if v.lower() in PLACEHOLDERS:
35
+ return True
36
+ return v.startswith("<") and v.endswith(">") # e.g. <paste your token here>
37
+
38
+
39
+ @dataclass
40
+ class Settings:
41
+ path: Path
42
+ parser: configparser.ConfigParser = field(repr=False)
43
+
44
+ @property
45
+ def folder(self) -> Path:
46
+ return self.path.parent
47
+
48
+ def get(self, section: str, key: str, default: str | None = None) -> str:
49
+ from_env = os.environ.get(env_name(section, key))
50
+ if from_env is not None:
51
+ return from_env.strip()
52
+ if self.parser.has_option(section, key):
53
+ return self.parser.get(section, key).strip()
54
+ if default is None:
55
+ raise ConfigError(
56
+ f"Missing setting [{section}] {key} in {self.path.name} "
57
+ f"(or set the environment variable {env_name(section, key)})."
58
+ )
59
+ return default
60
+
61
+ def secret(self, section: str, key: str) -> str:
62
+ """A password or token. Must be set, and not left as the example placeholder."""
63
+ value = self.get(section, key, "")
64
+ if looks_unset(value):
65
+ raise ConfigError(
66
+ f"[{section}] {key} is not set. Put it in {self.path.name} (which must stay out of git) "
67
+ f"or set the environment variable {env_name(section, key)}."
68
+ )
69
+ return value
70
+
71
+ def get_int(self, section: str, key: str, default: int) -> int:
72
+ raw = self.get(section, key, str(default))
73
+ try:
74
+ return int(raw)
75
+ except ValueError:
76
+ raise ConfigError(f"[{section}] {key} must be a whole number, not '{raw}'.") from None
77
+
78
+ def get_float(self, section: str, key: str, default: float) -> float:
79
+ raw = self.get(section, key, str(default))
80
+ try:
81
+ value = float(raw)
82
+ except ValueError:
83
+ raise ConfigError(f"[{section}] {key} must be a number, not '{raw}'.") from None
84
+ if value < 0:
85
+ raise ConfigError(f"[{section}] {key} can't be negative.")
86
+ return value
87
+
88
+ def get_list(self, section: str, key: str) -> list[str]:
89
+ raw = self.get(section, key, "")
90
+ return [part.strip() for part in raw.split(",") if part.strip()]
91
+
92
+ def items(self, section: str) -> dict[str, str]:
93
+ """Every key in a section, in file order, keeping the key's original capitals."""
94
+ if not self.parser.has_section(section):
95
+ return {}
96
+ return {k: self.get(section, k) for k in self.parser.options(section)}
97
+
98
+ def resolve(self, value: str) -> Path:
99
+ """A path from the config, relative to the config file's folder."""
100
+ p = Path(value).expanduser()
101
+ return p if p.is_absolute() else (self.folder / p)
102
+
103
+
104
+ def load(path: str | Path) -> Settings:
105
+ path = Path(path)
106
+ if not path.exists():
107
+ raise ConfigError(
108
+ f"Config file {path} not found. Copy config.example.ini to config.ini and fill it in, "
109
+ "or run 'mailrows init' to create a starter folder."
110
+ )
111
+ parser = configparser.ConfigParser(interpolation=None)
112
+ parser.optionxform = str # keep field names like "Last emailed" exactly as written
113
+ try:
114
+ parser.read(path, encoding="utf-8-sig")
115
+ except (configparser.Error, UnicodeDecodeError) as exc:
116
+ first_line = str(exc).strip().splitlines()[0] if str(exc).strip() else type(exc).__name__
117
+ raise ConfigError(f"{path.name} can't be read: {first_line}") from None
118
+ return Settings(path=path.resolve(), parser=parser)
@@ -0,0 +1,94 @@
1
+ """Post-send hooks: something to do for each row after its email has gone.
2
+
3
+ A hook has two steps:
4
+
5
+ * prepare(rows) runs before ANY email is sent. It checks that the hook will be able to do
6
+ its job for each row (for Airtable: that the matching record exists) and returns the
7
+ rows it can't handle, which are then skipped. Raising HookError stops the whole run.
8
+ * run(row) runs after that row's email was sent. If it raises, the row is marked
9
+ "sent, update pending" and a later run retries only the hook, never the email.
10
+
11
+ To add a target, subclass Hook, give it a unique name, and register it in HOOK_TYPES.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+
18
+ from mailrows.config import ConfigError, Settings
19
+ from mailrows.template import fill, placeholders
20
+
21
+ EXTRA_VALUES = ("_row_id", "_recipient", "_subject", "_sent_at", "_sent_date")
22
+
23
+
24
+ class HookError(Exception):
25
+ """The hook can't run at all (bad token, unknown table...). Nothing will be sent."""
26
+
27
+
28
+ @dataclass
29
+ class HookRow:
30
+ row_id: str
31
+ values: dict[str, str]
32
+ recipient: str
33
+ subject: str
34
+ sent_at: str = ""
35
+ scratch: dict = field(default_factory=dict) # whatever prepare() wants to hand to run()
36
+
37
+ def merged(self) -> dict[str, str]:
38
+ extra = {
39
+ "_row_id": self.row_id,
40
+ "_recipient": self.recipient,
41
+ "_subject": self.subject,
42
+ "_sent_at": self.sent_at,
43
+ "_sent_date": self.sent_at[:10],
44
+ }
45
+ return {**extra, **self.values}
46
+
47
+
48
+ class Hook:
49
+ name = "hook"
50
+
51
+ def __init__(self, settings: Settings):
52
+ self.settings = settings
53
+
54
+ def check_columns(self, columns: list[str]) -> None:
55
+ """Raise ConfigError if a configured value uses an unknown {column}."""
56
+
57
+ def prepare(self, rows: list[HookRow]) -> dict[str, str]:
58
+ return {}
59
+
60
+ def run(self, row: HookRow) -> None:
61
+ raise NotImplementedError
62
+
63
+ def describe(self) -> str:
64
+ return self.name
65
+
66
+
67
+ def check_value_placeholders(where: str, values: dict[str, str], columns: list[str]) -> None:
68
+ known = set(columns) | set(EXTRA_VALUES)
69
+ for key, value in values.items():
70
+ for name in placeholders(value, f"{where} {key}"):
71
+ if name not in known:
72
+ raise ConfigError(
73
+ f"{where} '{key}' uses {{{name}}}, which is not a spreadsheet column. "
74
+ f"Built-in values are {', '.join('{' + v + '}' for v in EXTRA_VALUES)}."
75
+ )
76
+
77
+
78
+ def fill_values(values: dict[str, str], row: HookRow) -> dict[str, str]:
79
+ merged = row.merged()
80
+ return {k: fill(v, merged) for k, v in values.items()}
81
+
82
+
83
+ def load_hooks(settings: Settings) -> list[Hook]:
84
+ from mailrows.hooks.airtable import UpdateAirtable
85
+ from mailrows.hooks.csv_append import CsvAppender
86
+ from mailrows.hooks.webhook import PostWebhook
87
+
88
+ types = {h.name: h for h in (UpdateAirtable, PostWebhook, CsvAppender)}
89
+ out = []
90
+ for name in settings.get_list("hooks", "enabled"):
91
+ if name not in types:
92
+ raise ConfigError(f"[hooks] enabled lists '{name}'. Known hooks: {', '.join(types)}.")
93
+ out.append(types[name](settings))
94
+ return out
@@ -0,0 +1,134 @@
1
+ """Update one Airtable record per sent email.
2
+
3
+ Config:
4
+
5
+ [airtable]
6
+ base_id = appXXXXXXXXXXXXXX ; from the base's API page
7
+ table = Attendees ; table name or ID
8
+ match_field = Ticket ID ; Airtable field to look the record up by
9
+ match_column = id ; spreadsheet column holding that value
10
+ token = ... ; or MAILROWS_AIRTABLE_TOKEN
11
+
12
+ [airtable.fields]
13
+ ; Airtable field = value. {column} takes the value from the spreadsheet row.
14
+ Status = Invited
15
+ Invite sent = {_sent_date}
16
+
17
+ Every matching record is found before any email is sent. Calls are kept under 5 per second
18
+ and retried with backoff when Airtable is busy.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import urllib.parse
24
+
25
+ from mailrows.config import ConfigError
26
+ from mailrows.hooks import Hook, HookError, HookRow, check_value_placeholders, fill_values
27
+ from mailrows.hooks.http import HttpError, JsonClient
28
+ from mailrows.table import as_text
29
+
30
+ LOOKUP_BATCH = 25 # values per lookup request: keeps the URL short
31
+
32
+ HINTS = {
33
+ 401: "the token was not accepted. Create a new one at airtable.com/create/tokens",
34
+ 403: "the token can't use this base or table. Give it the data.records:read and "
35
+ "data.records:write scopes and access to this base",
36
+ 404: "the base or table was not found. Check base_id and table under [airtable]",
37
+ 422: "Airtable turned the request down. Check match_field and the names under [airtable.fields]",
38
+ }
39
+
40
+
41
+ def quote_formula_string(value: str) -> str:
42
+ return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
43
+
44
+
45
+ class UpdateAirtable(Hook):
46
+ name = "airtable"
47
+
48
+ def __init__(self, settings, sleep=None):
49
+ super().__init__(settings)
50
+ s = settings
51
+ self.base = s.get("airtable", "base_id")
52
+ self.table = s.get("airtable", "table")
53
+ self.match_field = s.get("airtable", "match_field")
54
+ self.match_column = s.get("airtable", "match_column")
55
+ if any(ch in self.match_field for ch in "{}"):
56
+ raise ConfigError("[airtable] match_field can't contain { or }.")
57
+ self.fields = s.items("airtable.fields")
58
+ if not self.fields:
59
+ raise ConfigError("[airtable.fields] is empty: list at least one 'Field name = value'.")
60
+ self.api = s.get("airtable", "api_url", "https://api.airtable.com/v0").rstrip("/")
61
+ token = s.secret("airtable", "token")
62
+ extra = {"sleep": sleep} if sleep else {}
63
+ self.client = JsonClient({"Authorization": f"Bearer {token}"}, per_second=4.5,
64
+ retries=s.get_int("airtable", "retries", 5),
65
+ backoff=s.get_float("airtable", "backoff_seconds", 1.0), **extra)
66
+
67
+ def describe(self):
68
+ names = ", ".join(self.fields)
69
+ return f"airtable: set {names} on the record whose '{self.match_field}' matches '{self.match_column}'"
70
+
71
+ def _url(self, record_id: str = "") -> str:
72
+ path = f"{self.api}/{urllib.parse.quote(self.base, safe='')}/{urllib.parse.quote(self.table, safe='')}"
73
+ return f"{path}/{urllib.parse.quote(record_id, safe='')}" if record_id else path
74
+
75
+ def _request(self, method: str, url: str, payload=None):
76
+ try:
77
+ return self.client.call(method, url, payload)
78
+ except HttpError as exc:
79
+ hint = HINTS.get(exc.status or 0)
80
+ detail = hint or (exc.body[:200] if exc.body else str(exc))
81
+ raise HookError(f"Airtable: {exc} - {detail}") from None
82
+
83
+ def check_columns(self, columns):
84
+ if self.match_column not in columns:
85
+ raise ConfigError(f"[airtable] match_column '{self.match_column}' is not a spreadsheet column.")
86
+ check_value_placeholders("[airtable.fields]", self.fields, columns)
87
+
88
+ def prepare(self, rows: list[HookRow]) -> dict[str, str]:
89
+ wanted: dict[str, list[HookRow]] = {}
90
+ problems: dict[str, str] = {}
91
+ for row in rows:
92
+ value = row.values.get(self.match_column, "").strip()
93
+ if not value:
94
+ problems[row.row_id] = f"'{self.match_column}' is empty, so no Airtable record can be matched"
95
+ else:
96
+ wanted.setdefault(value, []).append(row)
97
+ found: dict[str, list[str]] = {v: [] for v in wanted}
98
+ values = list(wanted)
99
+ for i in range(0, len(values), LOOKUP_BATCH):
100
+ chunk = values[i:i + LOOKUP_BATCH]
101
+ # &'' turns numbers into text, so 101 and "101" both match
102
+ terms = [f"{{{self.match_field}}}&''={quote_formula_string(v)}" for v in chunk]
103
+ formula = terms[0] if len(terms) == 1 else "OR(" + ",".join(terms) + ")"
104
+ offset = None
105
+ while True:
106
+ params = [("filterByFormula", formula), ("fields[]", self.match_field), ("pageSize", "100")]
107
+ if offset:
108
+ params.append(("offset", offset))
109
+ page = self._request("GET", self._url() + "?" + urllib.parse.urlencode(params))
110
+ for record in page.get("records") or []:
111
+ got = (record.get("fields") or {}).get(self.match_field)
112
+ if isinstance(got, list):
113
+ got = got[0] if got else None
114
+ key = as_text(got)
115
+ if key in found:
116
+ found[key].append(record["id"])
117
+ offset = page.get("offset") or None
118
+ if not offset:
119
+ break
120
+ for value, matched in found.items():
121
+ for row in wanted[value]:
122
+ if not matched:
123
+ problems[row.row_id] = f"no Airtable record has {self.match_field} = {value}"
124
+ elif len(matched) > 1:
125
+ problems[row.row_id] = f"{len(matched)} Airtable records have {self.match_field} = {value}"
126
+ else:
127
+ row.scratch["airtable_id"] = matched[0]
128
+ return problems
129
+
130
+ def run(self, row: HookRow) -> None:
131
+ record_id = row.scratch.get("airtable_id")
132
+ if not record_id:
133
+ raise HookError("the Airtable record was not looked up first")
134
+ self._request("PATCH", self._url(record_id), {"fields": fill_values(self.fields, row), "typecast": True})