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
|
+
]
|