|
1 |
| -from __future__ import absolute_import |
2 |
| -from .airtable import ( |
3 |
| - Airtable as Airtable, |
4 |
| - AirtableError as AirtableError, |
5 |
| - IsNotInteger as IsNotInteger, |
6 |
| - IsNotString as IsNotString, |
7 |
| - Record as Record, |
8 |
| - Table as Table, |
9 |
| -) |
| 1 | +import json |
| 2 | +import posixpath |
| 3 | +import requests |
| 4 | +import six |
| 5 | +from typing import Any, Generic, Mapping, TypeVar |
| 6 | +from collections import OrderedDict |
| 7 | + |
| 8 | +API_URL = 'https://api.airtable.com/v%s/' |
| 9 | +API_VERSION = '0' |
| 10 | + |
| 11 | + |
| 12 | +class IsNotInteger(Exception): |
| 13 | + pass |
| 14 | + |
| 15 | + |
| 16 | +class IsNotString(Exception): |
| 17 | + pass |
| 18 | + |
| 19 | + |
| 20 | +def check_integer(n): |
| 21 | + if not n: |
| 22 | + return False |
| 23 | + elif not isinstance(n, six.integer_types): |
| 24 | + raise IsNotInteger('Expected an integer', n) |
| 25 | + else: |
| 26 | + return True |
| 27 | + |
| 28 | + |
| 29 | +def check_string(s): |
| 30 | + if not s: |
| 31 | + return False |
| 32 | + elif not isinstance(s, six.string_types): |
| 33 | + raise IsNotString('Expected a string', s) |
| 34 | + else: |
| 35 | + return True |
| 36 | + |
| 37 | + |
| 38 | +def create_payload(data): |
| 39 | + return {'fields': data} |
| 40 | + |
| 41 | + |
| 42 | +_T = TypeVar('_T', bound=Mapping[str, Any]) |
| 43 | + |
| 44 | + |
| 45 | +class Record(Generic[_T]): |
| 46 | + |
| 47 | + def __init__(self): |
| 48 | + raise NotImplementedError( |
| 49 | + 'This class is only used as a type of records returned by this module, however ' |
| 50 | + 'it should only be used as a type (see typing stubs) and not as an actual class.' |
| 51 | + ) |
| 52 | + |
| 53 | + def __getitem__(self, key): |
| 54 | + pass |
| 55 | + |
| 56 | + def get(self, key, default=None): |
| 57 | + pass |
| 58 | + |
| 59 | + |
| 60 | +class AirtableError(Exception): |
| 61 | + |
| 62 | + def __init__(self, error_type, message): |
| 63 | + super(AirtableError, self).__init__() |
| 64 | + # Examples of types are: |
| 65 | + # TABLE_NOT_FOUND |
| 66 | + # VIEW_NAME_NOT_FOUND |
| 67 | + self.type = error_type |
| 68 | + self.message = message |
| 69 | + |
| 70 | + def __repr__(self): |
| 71 | + return '%s(%r, %r)' % (self.__class__.__name__, self.type, self.message) |
| 72 | + |
| 73 | + def __str__(self): |
| 74 | + return self.message or self.__class__.__name__ |
| 75 | + |
| 76 | + |
| 77 | +class Airtable(object): |
| 78 | + def __init__(self, base_id, api_key, dict_class=OrderedDict): |
| 79 | + """Create a client to connect to an Airtable Base. |
| 80 | +
|
| 81 | + Args: |
| 82 | + - base_id: The ID of the base, e.g. "appA0CDAE34F" |
| 83 | + - api_key: The API secret key, e.g. "keyBAAE123C" |
| 84 | + - dict_class: the class to use to build dictionaries for returning |
| 85 | + fields. By default the fields are kept in the order they were |
| 86 | + returned by the API using an OrderedDict, but you can switch |
| 87 | + to a simple dict if you prefer. |
| 88 | + """ |
| 89 | + self.airtable_url = API_URL % API_VERSION |
| 90 | + self.base_url = posixpath.join(self.airtable_url, base_id) |
| 91 | + self.headers = {'Authorization': 'Bearer %s' % api_key} |
| 92 | + self._dict_class = dict_class |
| 93 | + |
| 94 | + def __request(self, method, url, params=None, payload=None): |
| 95 | + if method in ['POST', 'PUT', 'PATCH']: |
| 96 | + self.headers.update({'Content-type': 'application/json'}) |
| 97 | + r = requests.request(method, |
| 98 | + posixpath.join(self.base_url, url), |
| 99 | + params=params, |
| 100 | + data=payload, |
| 101 | + headers=self.headers) |
| 102 | + if r.status_code == requests.codes.ok: |
| 103 | + return r.json(object_pairs_hook=self._dict_class) |
| 104 | + else: |
| 105 | + error_json = r.json().get('error', {}) |
| 106 | + raise AirtableError( |
| 107 | + error_type=error_json.get('type', str(r.status_code)), |
| 108 | + message=error_json.get('message', json.dumps(r.json()))) |
| 109 | + |
| 110 | + def get( |
| 111 | + self, table_name, record_id=None, limit=0, offset=None, |
| 112 | + filter_by_formula=None, view=None, max_records=0, fields=[]): |
| 113 | + params = {} |
| 114 | + if check_string(record_id): |
| 115 | + url = posixpath.join(table_name, record_id) |
| 116 | + else: |
| 117 | + url = table_name |
| 118 | + if limit and check_integer(limit): |
| 119 | + params.update({'pageSize': limit}) |
| 120 | + if offset and check_string(offset): |
| 121 | + params.update({'offset': offset}) |
| 122 | + if filter_by_formula is not None: |
| 123 | + params.update({'filterByFormula': filter_by_formula}) |
| 124 | + if view is not None: |
| 125 | + params.update({'view': view}) |
| 126 | + if max_records and check_integer(max_records): |
| 127 | + params.update({'maxRecords': max_records}) |
| 128 | + if fields and type(fields) is list: |
| 129 | + for field in fields: |
| 130 | + check_string(field) |
| 131 | + params.update({'fields': fields}) |
| 132 | + |
| 133 | + return self.__request('GET', url, params) |
| 134 | + |
| 135 | + def iterate( |
| 136 | + self, table_name, batch_size=0, filter_by_formula=None, |
| 137 | + view=None, max_records=0, fields=[]): |
| 138 | + """Iterate over all records of a table. |
| 139 | +
|
| 140 | + Args: |
| 141 | + table_name: the name of the table to list. |
| 142 | + batch_size: the number of records to fetch per request. The default |
| 143 | + (0) is using the default of the API which is (as of 2016-09) |
| 144 | + 100. Note that the API does not allow more than that (but |
| 145 | + allow for less). |
| 146 | + filter_by_formula: a formula used to filter records. The formula |
| 147 | + will be evaluated for each record, and if the result is not 0, |
| 148 | + false, "", NaN, [], or #Error! the record will be included in |
| 149 | + the response. If combined with view, only records in that view |
| 150 | + which satisfy the formula will be returned. |
| 151 | + view: the name or ID of a view in the table. If set, only the |
| 152 | + records in that view will be returned. The records will be |
| 153 | + sorted according to the order of the view. |
| 154 | + Yields: |
| 155 | + A dict for each record containing at least three fields: "id", |
| 156 | + "createdTime" and "fields". |
| 157 | + """ |
| 158 | + offset = None |
| 159 | + while True: |
| 160 | + response = self.get( |
| 161 | + table_name, limit=batch_size, offset=offset, max_records=max_records, |
| 162 | + fields=fields, filter_by_formula=filter_by_formula, view=view) |
| 163 | + for record in response.pop('records'): |
| 164 | + yield record |
| 165 | + if 'offset' in response: |
| 166 | + offset = response['offset'] |
| 167 | + else: |
| 168 | + break |
| 169 | + |
| 170 | + def create(self, table_name, data): |
| 171 | + if check_string(table_name): |
| 172 | + payload = create_payload(data) |
| 173 | + return self.__request('POST', table_name, |
| 174 | + payload=json.dumps(payload)) |
| 175 | + |
| 176 | + def update(self, table_name, record_id, data): |
| 177 | + if check_string(table_name) and check_string(record_id): |
| 178 | + url = posixpath.join(table_name, record_id) |
| 179 | + payload = create_payload(data) |
| 180 | + return self.__request('PATCH', url, |
| 181 | + payload=json.dumps(payload)) |
| 182 | + |
| 183 | + def update_all(self, table_name, record_id, data): |
| 184 | + if check_string(table_name) and check_string(record_id): |
| 185 | + url = posixpath.join(table_name, record_id) |
| 186 | + payload = create_payload(data) |
| 187 | + return self.__request('PUT', url, |
| 188 | + payload=json.dumps(payload)) |
| 189 | + |
| 190 | + def delete(self, table_name, record_id): |
| 191 | + if check_string(table_name) and check_string(record_id): |
| 192 | + url = posixpath.join(table_name, record_id) |
| 193 | + return self.__request('DELETE', url) |
| 194 | + |
| 195 | + def table(self, table_name): |
| 196 | + return Table(self, table_name) |
| 197 | + |
| 198 | + |
| 199 | +class Table(Generic[_T]): |
| 200 | + def __init__(self, base_id, table_name, api_key=None, dict_class=OrderedDict): |
| 201 | + """Create a client to connect to an Airtable Table. |
| 202 | +
|
| 203 | + Args: |
| 204 | + - base_id: The ID of the base, e.g. "appA0CDAE34F" |
| 205 | + - table_name: The name or ID of the table, e.g. "tbl123adfe4" |
| 206 | + - api_key: The API secret key, e.g. "keyBAAE123C" |
| 207 | + - dict_class: the class to use to build dictionaries for returning |
| 208 | + fields. By default the fields are kept in the order they were |
| 209 | + returned by the API using an OrderedDict, but you can switch |
| 210 | + to a simple dict if you prefer. |
| 211 | + """ |
| 212 | + self.table_name = table_name |
| 213 | + if isinstance(base_id, Airtable): |
| 214 | + self._client = base_id |
| 215 | + return |
| 216 | + self._client = Airtable(base_id, api_key, dict_class=dict_class) |
| 217 | + |
| 218 | + def get( |
| 219 | + self, record_id=None, limit=0, offset=None, |
| 220 | + filter_by_formula=None, view=None, max_records=0, fields=[]): |
| 221 | + return self._client.get( |
| 222 | + self.table_name, record_id, limit, offset, filter_by_formula, view, max_records, fields) |
| 223 | + |
| 224 | + def iterate( |
| 225 | + self, batch_size=0, filter_by_formula=None, |
| 226 | + view=None, max_records=0, fields=[]): |
| 227 | + return self._client.iterate( |
| 228 | + self.table_name, batch_size, filter_by_formula, view, max_records, fields) |
| 229 | + |
| 230 | + def create(self, data): |
| 231 | + return self._client.create(self.table_name, data) |
| 232 | + |
| 233 | + def update(self, record_id, data): |
| 234 | + return self._client.update(self.table_name, record_id, data) |
| 235 | + |
| 236 | + def update_all(self, record_id, data): |
| 237 | + return self._client.update_all(self.table_name, record_id, data) |
| 238 | + |
| 239 | + def delete(self, record_id): |
| 240 | + return self._client.delete(self.table_name, record_id) |
0 commit comments