aracnid-airtable 1.0.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jason Romano
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.4
2
+ Name: aracnid-airtable
3
+ Version: 1.0.0
4
+ Summary: Airtable connector for the Aracnid integration framework
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Keywords: connector
8
+ Author: Jason Romano
9
+ Author-email: aracnid@gmail.com
10
+ Requires-Python: >=3.10,<4.0
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Requires-Dist: aracnid-core (==1.0.0)
19
+ Requires-Dist: pyairtable (>=3.1.1,<4.0.0)
20
+ Project-URL: Repository, https://github.com/aracnid/aracnid-airtable
21
+ Description-Content-Type: text/markdown
22
+
23
+ # aracnid-airtable
24
+
25
+ A Python Airtable connector implementation for the `aracnid-base` contract.
26
+
27
+ ## Overview
28
+
29
+ `aracnid-airtable` provides an `AirtableConnector` implementation that follows the shared connector interface used across `i-*` connector modules. It wraps Airtable CRUD operations with consistent validation, normalization, and error handling semantics.
30
+
31
+ ## Installation
32
+
33
+ Using Poetry:
34
+
35
+ ```bash
36
+ poetry add aracnid-airtable
37
+ ```
38
+
39
+ Using pip:
40
+
41
+ ```bash
42
+ pip install aracnid-airtable
43
+ ```
44
+
45
+ ## Requirements
46
+
47
+ - Python 3.12+
48
+ - Airtable API key with access to the target base/table
49
+
50
+ ## Configuration
51
+
52
+ Set your Airtable API key in the environment:
53
+
54
+ ```bash
55
+ export AIRTABLE_API_KEY=your_api_key
56
+ ```
57
+
58
+ ## Quick Start
59
+
60
+ ```python
61
+ from aracnid_airtable.connector import AirtableConnector
62
+
63
+ connector = AirtableConnector(
64
+ base_id="appXXXXXXXXXXXXXX",
65
+ table_name="MyTable",
66
+ )
67
+
68
+ created = connector.create_one({"name": "example", "status": "active"})
69
+ record_id = created["id"]
70
+
71
+ one = connector.read_one(record_id)
72
+ many = connector.read_many({"status": "active"})
73
+
74
+ updated = connector.update_one(record_id, {"status": "inactive"})
75
+ replaced = connector.replace_one(record_id, {"name": "replacement"})
76
+
77
+ deleted = connector.delete_one(record_id, hard=True)
78
+ ```
79
+
80
+ ## API Notes
81
+
82
+ - `read_one(record_id)` returns `None` when the record is not found.
83
+ - `delete_one(record_id, hard=False)` raises `RuntimeError` because soft delete is not supported by this connector.
84
+ - `delete_one(record_id, hard=True)` performs a hard delete and returns:
85
+ - `True` if deleted
86
+ - `False` if the record does not exist
87
+
88
+ ## Testing
89
+
90
+ Run default tests (excluding external-service integration tests):
91
+
92
+ ```bash
93
+ pytest -m "not integration" -q
94
+ ```
95
+
96
+ Run integration tests (real Airtable):
97
+
98
+ ```bash
99
+ pytest -m integration -q
100
+ ```
101
+
102
+ ### Integration test environment variables
103
+
104
+ - `AIRTABLE_API_KEY`
105
+ - `AIRTABLE_BASE_ID`
106
+ - `AIRTABLE_TABLE_NAME`
107
+
108
+ If these are not set, integration tests will skip.
109
+
110
+ ## Development
111
+
112
+ Run all tests:
113
+
114
+ ```bash
115
+ pytest -q
116
+ ```
117
+
118
+ Run linting/type checks (if configured in your environment):
119
+
120
+ ```bash
121
+ ruff check .
122
+ pyright
123
+ ```
124
+
125
+ ## License
126
+
127
+ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details
128
+
@@ -0,0 +1,105 @@
1
+ # aracnid-airtable
2
+
3
+ A Python Airtable connector implementation for the `aracnid-base` contract.
4
+
5
+ ## Overview
6
+
7
+ `aracnid-airtable` provides an `AirtableConnector` implementation that follows the shared connector interface used across `i-*` connector modules. It wraps Airtable CRUD operations with consistent validation, normalization, and error handling semantics.
8
+
9
+ ## Installation
10
+
11
+ Using Poetry:
12
+
13
+ ```bash
14
+ poetry add aracnid-airtable
15
+ ```
16
+
17
+ Using pip:
18
+
19
+ ```bash
20
+ pip install aracnid-airtable
21
+ ```
22
+
23
+ ## Requirements
24
+
25
+ - Python 3.12+
26
+ - Airtable API key with access to the target base/table
27
+
28
+ ## Configuration
29
+
30
+ Set your Airtable API key in the environment:
31
+
32
+ ```bash
33
+ export AIRTABLE_API_KEY=your_api_key
34
+ ```
35
+
36
+ ## Quick Start
37
+
38
+ ```python
39
+ from aracnid_airtable.connector import AirtableConnector
40
+
41
+ connector = AirtableConnector(
42
+ base_id="appXXXXXXXXXXXXXX",
43
+ table_name="MyTable",
44
+ )
45
+
46
+ created = connector.create_one({"name": "example", "status": "active"})
47
+ record_id = created["id"]
48
+
49
+ one = connector.read_one(record_id)
50
+ many = connector.read_many({"status": "active"})
51
+
52
+ updated = connector.update_one(record_id, {"status": "inactive"})
53
+ replaced = connector.replace_one(record_id, {"name": "replacement"})
54
+
55
+ deleted = connector.delete_one(record_id, hard=True)
56
+ ```
57
+
58
+ ## API Notes
59
+
60
+ - `read_one(record_id)` returns `None` when the record is not found.
61
+ - `delete_one(record_id, hard=False)` raises `RuntimeError` because soft delete is not supported by this connector.
62
+ - `delete_one(record_id, hard=True)` performs a hard delete and returns:
63
+ - `True` if deleted
64
+ - `False` if the record does not exist
65
+
66
+ ## Testing
67
+
68
+ Run default tests (excluding external-service integration tests):
69
+
70
+ ```bash
71
+ pytest -m "not integration" -q
72
+ ```
73
+
74
+ Run integration tests (real Airtable):
75
+
76
+ ```bash
77
+ pytest -m integration -q
78
+ ```
79
+
80
+ ### Integration test environment variables
81
+
82
+ - `AIRTABLE_API_KEY`
83
+ - `AIRTABLE_BASE_ID`
84
+ - `AIRTABLE_TABLE_NAME`
85
+
86
+ If these are not set, integration tests will skip.
87
+
88
+ ## Development
89
+
90
+ Run all tests:
91
+
92
+ ```bash
93
+ pytest -q
94
+ ```
95
+
96
+ Run linting/type checks (if configured in your environment):
97
+
98
+ ```bash
99
+ ruff check .
100
+ pyright
101
+ ```
102
+
103
+ ## License
104
+
105
+ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details
@@ -0,0 +1,9 @@
1
+ from .connector import AirtableConnector
2
+
3
+ try:
4
+ from importlib.metadata import version as _version
5
+ except ImportError: # pragma: no cover
6
+ from importlib_metadata import version as _version # type: ignore
7
+
8
+ __all__ = ["__version__", "AirtableConnector"]
9
+ __version__ = _version("aracnid-core")
@@ -0,0 +1,284 @@
1
+ """A connector for Airtable using the pyairtable library.
2
+ """
3
+
4
+ import os
5
+ from typing import Any
6
+
7
+ from pyairtable import Api
8
+ from pyairtable.formulas import match
9
+ from pyairtable.api.types import RecordDict
10
+
11
+ from aracnid_core.base import BaseConnector
12
+
13
+
14
+ class AirtableConnector(BaseConnector):
15
+ """A connector for Airtable using the pyairtable library.
16
+ """
17
+ @property
18
+ def capabilities(self) -> dict[str, bool]:
19
+ """Returns a mapping of capabilities.
20
+
21
+ Returns:
22
+ dict[str, bool]: Mapping of capabilities.
23
+ """
24
+ return {
25
+ "supports_filters": True,
26
+ "supports_partial_update": True,
27
+ "supports_replace_one": True,
28
+ "supports_soft_delete": False,
29
+ "supports_hard_delete": True,
30
+ "supports_transactions": False,
31
+ }
32
+
33
+
34
+ def __init__(self, base_id: str, table_name: str):
35
+ """Initialize the AirtableConnector.
36
+
37
+ Args:
38
+ base_id (str): The ID of the Airtable base.
39
+ table_name (str): The name of the Airtable table.
40
+
41
+ Raises:
42
+ ValueError: If base_id or table_name is not a non-empty string, or if AIRTABLE_API_KEY is not set.
43
+ """
44
+ if not isinstance(base_id, str) or not base_id.strip():
45
+ raise ValueError("base_id must be a non-empty string")
46
+ if not isinstance(table_name, str) or not table_name.strip():
47
+ raise ValueError("table_name must be a non-empty string")
48
+
49
+ api_key = os.getenv("AIRTABLE_API_KEY")
50
+ if not api_key or not api_key.strip():
51
+ raise ValueError("AIRTABLE_API_KEY environment variable is required.")
52
+
53
+ self.air_api_key = api_key.strip()
54
+ self.base_id = base_id.strip()
55
+ self.table_name = table_name.strip()
56
+
57
+ self.api = Api(self.air_api_key)
58
+ self.table = self.api.table(self.base_id, self.table_name)
59
+
60
+
61
+ def _normalize_record(self, rec: RecordDict | dict[str, Any]) -> dict[str, Any]:
62
+ """Normalize an Airtable record into a dictionary with standard keys.
63
+
64
+ Args:
65
+ rec (RecordDict | dict[str, Any]): The Airtable record to normalize.
66
+
67
+ Returns:
68
+ dict[str, Any]: The normalized record.
69
+ """
70
+ fields = rec.get("fields") or {}
71
+ return {
72
+ "id": rec.get("id"),
73
+ **fields,
74
+ "_created_time": rec.get("createdTime"),
75
+ }
76
+
77
+
78
+ def _as_runtime_error(self, exc: Exception, op: str) -> RuntimeError:
79
+ """Convert an exception into a RuntimeError with a standardized message.
80
+
81
+ Args:
82
+ exc (Exception): The original exception.
83
+ op (str): The operation being performed.
84
+
85
+ Returns:
86
+ RuntimeError: The converted runtime error.
87
+ """
88
+ return RuntimeError(f"{op} failed: {exc}")
89
+
90
+
91
+ def _is_not_found_error(self, exc: Exception) -> bool:
92
+ """Determine if an exception indicates a "not found" error.
93
+
94
+ Args:
95
+ exc (Exception): The exception to check.
96
+
97
+ Returns:
98
+ bool: True if the exception indicates a "not found" error, False otherwise.
99
+ """
100
+ msg = str(exc).lower()
101
+ return "404" in msg or "not found" in msg
102
+
103
+
104
+ def create_one(self, record: dict[str, Any]) -> dict[str, Any]:
105
+ """Create a new record in the Airtable table.
106
+
107
+ Args:
108
+ record (dict[str, Any]): The record to create.
109
+
110
+ Returns:
111
+ dict[str, Any]: The created record.
112
+
113
+ Raises:
114
+ ValueError: If record is not a dict or is empty.
115
+ """
116
+ if not isinstance(record, dict):
117
+ raise ValueError("record must be a dict")
118
+ if not record:
119
+ raise ValueError("record must not be empty")
120
+
121
+ fields = dict(record) # do not mutate caller input
122
+ try:
123
+ created = self.table.create(fields)
124
+ except Exception as exc:
125
+ raise self._as_runtime_error(exc, "create_one") from exc
126
+
127
+ return self._normalize_record(created)
128
+
129
+
130
+ def read_one(self, record_id: str) -> dict[str, Any] | None:
131
+ """Read a single record from the Airtable table by its ID.
132
+
133
+ Args:
134
+ record_id (str): The ID of the record to read.
135
+
136
+ Returns:
137
+ dict[str, Any] | None: The record if found, None otherwise.
138
+
139
+ Raises:
140
+ ValueError: If record_id is not a non-empty string.
141
+ """
142
+ if not isinstance(record_id, str) or not record_id.strip():
143
+ raise ValueError("record_id must be a non-empty string")
144
+
145
+ try:
146
+ rec = self.table.get(record_id.strip())
147
+ except Exception as exc:
148
+ if self._is_not_found_error(exc):
149
+ return None
150
+ raise self._as_runtime_error(exc, "read_one") from exc
151
+
152
+ return self._normalize_record(rec) if rec else None
153
+
154
+
155
+ def read_many(self, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]:
156
+ """Read multiple records from the Airtable table, optionally filtered by a dictionary of field values.
157
+
158
+ Args:
159
+ filters (dict[str, Any] | None): A dictionary of field values to filter records.
160
+
161
+ Returns:
162
+ list[dict[str, Any]]: A list of records matching the filters.
163
+
164
+ Raises:
165
+ ValueError: If filters is not a dict or None.
166
+ """
167
+ if filters is not None and not isinstance(filters, dict):
168
+ raise ValueError("filters must be a dict or None")
169
+
170
+ filter_obj: dict[str, Any] = dict(filters) if filters else {}
171
+
172
+ try:
173
+ recs = self.table.all(formula=match(filter_obj)) if filter_obj else self.table.all()
174
+ except Exception as exc:
175
+ raise self._as_runtime_error(exc, "read_many") from exc
176
+
177
+ return [self._normalize_record(rec) for rec in recs]
178
+
179
+
180
+ def update_one(self, record_id: str, changes: dict[str, Any]) -> dict[str, Any]:
181
+ """Update a single record in the Airtable table by its ID.
182
+
183
+ Args:
184
+ record_id (str): The ID of the record to update.
185
+ changes (dict[str, Any]): A dictionary of field changes to apply.
186
+
187
+ Returns:
188
+ dict[str, Any]: The updated record.
189
+
190
+ Raises:
191
+ ValueError: If record_id is not a non-empty string or changes is not a non-empty dict.
192
+ """
193
+ if not isinstance(record_id, str) or not record_id.strip():
194
+ raise ValueError("record_id must be a non-empty string")
195
+ if not isinstance(changes, dict):
196
+ raise ValueError("changes must be a dict")
197
+ if not changes:
198
+ raise ValueError("changes must not be empty")
199
+
200
+ rid = record_id.strip()
201
+ fields = dict(changes) # do not mutate caller input
202
+
203
+ try:
204
+ updated = self.table.update(rid, fields)
205
+ except Exception as exc:
206
+ if self._is_not_found_error(exc):
207
+ raise RuntimeError(f"update_one failed: record '{rid}' not found") from exc
208
+ raise self._as_runtime_error(exc, "update_one") from exc
209
+
210
+ if not updated:
211
+ raise RuntimeError(f"update_one failed: record '{rid}' not found")
212
+
213
+ return self._normalize_record(updated)
214
+
215
+
216
+ def replace_one(self, record_id: str, new_record: dict[str, Any]) -> dict[str, Any]:
217
+ """Replace a single record in the Airtable table by its ID.
218
+
219
+ Args:
220
+ record_id (str): The ID of the record to replace.
221
+ new_record (dict[str, Any]): A dictionary representing the new record.
222
+
223
+ Returns:
224
+ dict[str, Any]: The replaced record.
225
+
226
+ Raises:
227
+ ValueError: If record_id is not a non-empty string or new_record is not a non-empty dict.
228
+ """
229
+ if not isinstance(record_id, str) or not record_id.strip():
230
+ raise ValueError("record_id must be a non-empty string")
231
+ if not isinstance(new_record, dict):
232
+ raise ValueError("new_record must be a dict")
233
+ if not new_record:
234
+ raise ValueError("new_record must not be empty")
235
+
236
+ rid = record_id.strip()
237
+ fields = dict(new_record) # do not mutate caller input
238
+
239
+ try:
240
+ replaced = self.table.update(rid, fields, replace=True)
241
+ except Exception as exc:
242
+ if self._is_not_found_error(exc):
243
+ raise RuntimeError(f"replace_one failed: record '{rid}' not found") from exc
244
+ raise self._as_runtime_error(exc, "replace_one") from exc
245
+
246
+ if not replaced:
247
+ raise RuntimeError(f"replace_one failed: record '{rid}' not found")
248
+
249
+ return self._normalize_record(replaced)
250
+
251
+
252
+ def delete_one(self, record_id: str, hard: bool = False) -> bool:
253
+ """Delete a single record from the Airtable table by its ID.
254
+
255
+ Args:
256
+ record_id (str): The ID of the record to delete.
257
+ hard (bool): Whether to perform a hard delete. Defaults to False.
258
+
259
+ Returns:
260
+ bool: True if the record was deleted, False if it was not found.
261
+
262
+ Raises:
263
+ ValueError: If record_id is not a non-empty string or hard is not a bool.
264
+ RuntimeError: If soft delete is requested (not supported).
265
+ """
266
+ if not isinstance(record_id, str) or not record_id.strip():
267
+ raise ValueError("record_id must be a non-empty string")
268
+ if not isinstance(hard, bool):
269
+ raise ValueError("hard must be a bool")
270
+
271
+ # Airtable connector supports hard delete only.
272
+ if not hard:
273
+ raise RuntimeError("delete_one failed: soft delete is not supported; pass hard=True")
274
+
275
+ rid = record_id.strip()
276
+ try:
277
+ result = self.table.delete(rid)
278
+ except Exception as exc:
279
+ if self._is_not_found_error(exc):
280
+ return False
281
+ raise self._as_runtime_error(exc, "delete_one") from exc
282
+
283
+ return bool((result or {}).get("deleted", False))
284
+
@@ -0,0 +1,53 @@
1
+ [tool.poetry]
2
+ name = "aracnid-airtable"
3
+ version = "1.0.0"
4
+ description = "Airtable connector for the Aracnid integration framework"
5
+ authors = ["Jason Romano <aracnid@gmail.com>"]
6
+ license = "MIT"
7
+ readme = "README.md"
8
+ repository = "https://github.com/aracnid/aracnid-airtable"
9
+ keywords = ["connector"]
10
+
11
+ [tool.poetry.dependencies]
12
+ python = "^3.10"
13
+ aracnid-core = "1.0.0"
14
+ pyairtable = "^3.1.1"
15
+
16
+ [tool.poetry.group.dev.dependencies]
17
+ aracnid-core = { path = "../aracnid-core", develop = true }
18
+ pytest = "^8.3.5"
19
+ ruff = "^0.15.21"
20
+ build = "^1.5.1"
21
+ twine = "^6.2.0"
22
+ pyright = "^1.1.411"
23
+
24
+ [build-system]
25
+ requires = ["poetry-core"]
26
+ build-backend = "poetry.core.masonry.api"
27
+
28
+ [tool.pytest.ini_options]
29
+ addopts = "-p no:warnings"
30
+ env_files = [".env"]
31
+ log_cli = true
32
+ log_cli_level = "DEBUG"
33
+ markers = [
34
+ "contract: shared BaseConnector contract/conformance tests",
35
+ "integration: tests that call external services"
36
+ ]
37
+
38
+ [tool.ruff]
39
+ # Target your current version
40
+ target-version = "py310"
41
+ line-length = 88
42
+
43
+ [tool.ruff.lint]
44
+ # E = pycodestyle (Errors), F = Pyflakes (Logic Bugs)
45
+ # These are the "Pylint-like" essentials without the fluff.
46
+ select = ["E", "F", "B"]
47
+
48
+ # Specifically ignore the stuff that usually annoys Pylint users
49
+ ignore = [
50
+ "E501", # Ignore line length (let the formatter handle it)
51
+ #"D", # Ignore all Docstring requirements
52
+ "ANN", # Ignore missing type annotations (if you aren't ready for strict typing)
53
+ ]