Skip to content

Commit a5e48c9

Browse files
authored
Initial commit of adapter + dialect (#1)
1 parent f76a73c commit a5e48c9

23 files changed

+739
-0
lines changed

.coveragerc

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
[run]
2+
omit =
3+
setup.py
4+
5+
[report]
6+
exclude_lines =
7+
# Have to re-enable the standard pragma
8+
pragma: no cover
9+
10+
# Don't complain if tests don't hit defensive assertion code:
11+
raise NotImplementedError
12+
13+
# These lines are not run by tests
14+
if typing.TYPE_CHECKING:
15+
if TYPE_CHECKING:

.flake8

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
[flake8]
2+
max-line-length = 88
3+
extend-ignore = E203,R504
4+
exclude =
5+
# No need to traverse our git directory
6+
.git,
7+
# There's no value in checking cache directories
8+
__pycache__,
9+
per-file-ignores =
10+
tests/**:S101

.github/dependabot.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
version: 2
2+
updates:
3+
- package-ecosystem: pip
4+
directory: "/"
5+
schedule:
6+
interval: weekly
7+
open-pull-requests-limit: 10
8+
allow:
9+
- dependency-type: direct
10+
- dependency-type: indirect

.github/workflows/main.yml

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: ["main"]
6+
tags:
7+
- "*"
8+
pull_request:
9+
branches: ["main"]
10+
11+
jobs:
12+
build:
13+
runs-on: ubuntu-latest
14+
steps:
15+
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
16+
- uses: actions/checkout@v2
17+
with:
18+
fetch-depth: "2"
19+
20+
- name: Set up Python 3.9
21+
uses: actions/setup-python@v2
22+
with:
23+
# Semantic version range syntax or exact version of a Python version
24+
python-version: "3.9"
25+
26+
- name: Cache pip
27+
uses: actions/cache@v2
28+
with:
29+
# This path is specific to Ubuntu
30+
path: ~/.cache/pip
31+
# Look to see if there is a cache hit for the corresponding requirements file
32+
key: ${{ runner.os }}-pip-${{ hashFiles('requirements-dev.txt') }}
33+
restore-keys: |
34+
${{ runner.os }}-pip-
35+
36+
- name: Install dependencies
37+
run: |
38+
python -m pip install --upgrade pip setuptools wheel
39+
pip install -r requirements-dev.txt
40+
41+
- name: Test with black
42+
run: |
43+
black . --check --target-version py39
44+
45+
- name: Test with flake8
46+
run: |
47+
flake8 .
48+
49+
# Currently the version of apsw specified in requirements.txt file is quite old
50+
# https://github.com/rogerbinns/apsw/issues/310
51+
- name: Fix dependencies
52+
run: |
53+
pip uninstall -y apsw
54+
pip install "apsw==3.36.0.post1"
55+
56+
- name: Install self
57+
run: |
58+
pip install -e .
59+
60+
- name: Test with pytest
61+
run: |
62+
pytest --cov=./ --cov-report=xml
63+
64+
- name: Set up mypy cache
65+
uses: actions/cache@v2
66+
with:
67+
path: .mypy_cache
68+
key: mypy1-${{ hashFiles('./airtabledb/**/*.py') }}-${{ hashFiles('./tests/**/*.py') }}
69+
restore-keys: mypy1-
70+
71+
- name: Test with mypy
72+
run: |
73+
mypy .
74+
75+
- name: Upload coverage to Codecov
76+
uses: codecov/codecov-action@v2
77+
with:
78+
file: ./coverage.xml
79+
flags: unittests
80+
env_vars: OS,PYTHON
81+
name: codecov-umbrella
82+
fail_ci_if_error: true

.isort.cfg

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[settings]
2+
multi_line_output=3
3+
include_trailing_comma=True
4+
force_grid_wrap=0
5+
use_parentheses=True
6+
ensure_newline_before_comments = True
7+
line_length=88

.pre-commit-config.yaml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
repos:
2+
- repo: https://github.com/ambv/black
3+
rev: 21.12b0
4+
hooks:
5+
- id: black
6+
language_version: python3
7+
args: [--target-version=py39]
8+
- repo: https://github.com/PyCQA/isort
9+
rev: "5.10.1"
10+
hooks:
11+
- id: isort
12+
language_version: python3
13+
- repo: https://gitlab.com/pycqa/flake8
14+
rev: "4.0.1"
15+
hooks:
16+
- id: flake8
17+
language_version: python3
18+
additional_dependencies:
19+
[
20+
flake8-bandit==3.0.0,
21+
# No need for flake8-black
22+
flake8-bugbear==22.1.11,
23+
flake8-datetimez==20.10.0,
24+
flake8-debugger==4.0.0,
25+
# No need for flake8-isort
26+
flake8-print==4.0.0,
27+
flake8-return==1.1.3,
28+
]

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,13 @@
11
# airtable-db-api
22
A Python DB API 2.0 for Airtable
3+
4+
## Installation
5+
### Python
6+
```bash
7+
$ pip install -r requirements-dev.txt
8+
```
9+
10+
### `pre-commit`
11+
```bash
12+
$ pre-commit install
13+
```

airtabledb/__init__.py

Whitespace-only changes.

airtabledb/adapter.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
from typing import Any, Dict, Iterator, List, Optional, Tuple
2+
3+
from pyairtable import Table
4+
from shillelagh.adapters.base import Adapter
5+
from shillelagh.fields import Field, Filter, String
6+
from shillelagh.typing import RequestedOrder
7+
8+
from .types import BaseMetadata
9+
10+
# -----------------------------------------------------------------------------
11+
12+
13+
class AirtableAdapter(Adapter):
14+
safe = True
15+
16+
def __init__(
17+
self,
18+
table: str,
19+
base_id: str,
20+
api_key: str,
21+
base_metadata: BaseMetadata,
22+
):
23+
super().__init__()
24+
25+
self.table = table
26+
self.base_metadata = base_metadata
27+
28+
self._table_api = Table(api_key, base_id, table)
29+
30+
fields: List[str]
31+
if self.base_metadata is not None:
32+
# TODO(cancan101): Better error handling here
33+
# We search by name here.
34+
# Alternatively we could have the user specify the name as an id
35+
table_metadata = [
36+
table_value
37+
for table_value in self.base_metadata.values()
38+
if table_value["name"] == table
39+
][0]
40+
columns_metadata = table_metadata["columns"]
41+
fields = [col["name"] for col in columns_metadata]
42+
self.strict_col = True
43+
else:
44+
# This introspects the first row in the table.
45+
# This is super not reliable
46+
# as Airtable removes the key if the value is empty.
47+
# We should probably look at more than one entry.
48+
fields = self._table_api.first()["fields"]
49+
self.strict_col = False
50+
51+
# TODO(cancan101): parse out types
52+
self.columns: Dict[str, Field] = dict(
53+
{k: String() for k in fields}, id=String()
54+
)
55+
56+
@staticmethod
57+
def supports(uri: str, fast: bool = True, **kwargs: Any) -> Optional[bool]:
58+
# TODO the slow path here could connect to the GQL Server
59+
return True
60+
61+
@staticmethod
62+
def parse_uri(table: str) -> Tuple[str]:
63+
return (table,)
64+
65+
def get_columns(self) -> Dict[str, Field]:
66+
return self.columns
67+
68+
def get_data(
69+
self,
70+
bounds: Dict[str, Filter],
71+
order: List[Tuple[str, RequestedOrder]],
72+
) -> Iterator[Dict[str, Any]]:
73+
for page in self._table_api.iterate():
74+
for result in page:
75+
yield dict(
76+
{
77+
k: v
78+
for k, v in result["fields"].items()
79+
if self.strict_col or k in self.columns
80+
},
81+
id=result["id"],
82+
)

airtabledb/dialect.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
from typing import Any, Dict, List, Tuple
2+
3+
from shillelagh.backends.apsw.dialects.base import APSWDialect
4+
from sqlalchemy.engine import Connection
5+
from sqlalchemy.engine.url import URL
6+
7+
from .types import BaseMetadata
8+
9+
# -----------------------------------------------------------------------------
10+
11+
ADAPTER_NAME = "airtable"
12+
13+
# -----------------------------------------------------------------------------
14+
15+
16+
class APSWAirtableDialect(APSWDialect):
17+
supports_statement_cache = True
18+
19+
def __init__(
20+
self,
21+
airtable_api_key: str = None,
22+
base_metadata: BaseMetadata = None,
23+
**kwargs: Any,
24+
):
25+
# We tell Shillelagh that this dialect supports just one adapter
26+
super().__init__(safe=True, adapters=[ADAPTER_NAME], **kwargs)
27+
28+
self.airtable_api_key = airtable_api_key
29+
self.base_metadata = base_metadata
30+
31+
def get_table_names(
32+
self, connection: Connection, schema: str = None, **kwargs: Any
33+
) -> List[str]:
34+
if self.base_metadata is not None:
35+
return [table["name"] for table in self.base_metadata.values()]
36+
return []
37+
38+
def create_connect_args(
39+
self,
40+
url: URL,
41+
) -> Tuple[Tuple[()], Dict[str, Any]]:
42+
args, kwargs = super().create_connect_args(url)
43+
44+
if "adapter_kwargs" in kwargs and kwargs["adapter_kwargs"] != {}:
45+
raise ValueError(
46+
f"Unexpected adapter_kwargs found: {kwargs['adapter_kwargs']}"
47+
)
48+
49+
if url.password and self.airtable_api_key:
50+
raise ValueError("Both password and airtable_api_key were provided")
51+
52+
# At some point we might have args
53+
adapter_kwargs = {
54+
ADAPTER_NAME: {
55+
"api_key": self.airtable_api_key or url.password,
56+
"base_id": url.host,
57+
"base_metadata": self.base_metadata,
58+
}
59+
}
60+
61+
# this seems gross, esp the path override. unclear why memory has to be set here
62+
return args, {**kwargs, "path": ":memory:", "adapter_kwargs": adapter_kwargs}

0 commit comments

Comments
 (0)