Skip to content

Commit 9af5b79

Browse files
committed
Python client moved from super repo
1 parent 582d07f commit 9af5b79

6 files changed

Lines changed: 669 additions & 5 deletions

File tree

.github/workflows/ci.yaml

Lines changed: 97 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,101 @@
1-
on: workflow_dispatch
1+
name: Tests
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
super_ref:
7+
description: 'brimdata/super branch, tag, or SHA to test against'
8+
default: main
9+
required: false
10+
repository_dispatch:
11+
types: [super-pr-merged]
12+
schedule:
13+
- cron: '5 8 * * *'
14+
push:
15+
branches: [main]
16+
pull_request:
217

318
jobs:
419
test:
5-
runs-on: ubuntu-latest
20+
runs-on: ubuntu-24.04
21+
steps:
22+
- uses: actions/checkout@v4
23+
- uses: actions/checkout@v4
24+
with:
25+
repository: brimdata/super
26+
ref: ${{ inputs.super_ref || 'main' }}
27+
path: super
28+
- uses: actions/setup-go@v5
29+
with:
30+
go-version-file: super/go.mod
31+
cache-dependency-path: super/go.sum
32+
- run: go build -o $GITHUB_WORKSPACE/bin/super ./cmd/super
33+
working-directory: super
34+
- name: Start super db serve (no auth)
35+
run: |
36+
$GITHUB_WORKSPACE/bin/super db -db $(mktemp -d) serve -l localhost:9867 &
37+
for i in $(seq 1 30); do
38+
curl -sf http://localhost:9867/status 2>/dev/null && break
39+
sleep 1
40+
done
41+
- uses: actions/setup-python@v5
42+
with:
43+
python-version: '3.x'
44+
- run: pip install '.[test]'
45+
- run: pytest test_superdb.py
46+
env:
47+
SUPER_DB: http://localhost:9867
48+
test-auth:
49+
runs-on: ubuntu-24.04
50+
steps:
51+
- uses: actions/checkout@v4
52+
- uses: actions/checkout@v4
53+
with:
54+
repository: brimdata/super
55+
ref: ${{ inputs.super_ref || 'main' }}
56+
path: super
57+
- uses: actions/setup-go@v5
58+
with:
59+
go-version-file: super/go.mod
60+
cache-dependency-path: super/go.sum
61+
- name: Build super and gentoken
62+
run: |
63+
go build -o $GITHUB_WORKSPACE/bin/super ./cmd/super
64+
go build -o $GITHUB_WORKSPACE/bin/gentoken ./cmd/gentoken
65+
working-directory: super
66+
- name: Start super db serve (with auth)
67+
run: |
68+
$GITHUB_WORKSPACE/bin/super db -db $(mktemp -d) serve -l localhost:9867 \
69+
-auth.enabled=t \
70+
-auth.audience=a \
71+
-auth.clientid=c \
72+
-auth.domain=d \
73+
-auth.jwkspath=$GITHUB_WORKSPACE/super/service/testdata/auth-public-jwks.json &
74+
for i in $(seq 1 30); do
75+
curl -sf http://localhost:9867/status 2>/dev/null && break
76+
sleep 1
77+
done
78+
- name: Store auth token
79+
run: |
80+
token=$($GITHUB_WORKSPACE/bin/gentoken \
81+
-audience a -domain d -keyid testkey \
82+
-privatekeyfile $GITHUB_WORKSPACE/super/service/testdata/auth-private-key \
83+
-tenantid t -userid u)
84+
$GITHUB_WORKSPACE/bin/super db auth store -access "$token" -db http://localhost:9867
85+
- uses: actions/setup-python@v5
86+
with:
87+
python-version: '3.x'
88+
- run: pip install '.[test]'
89+
- run: pytest test_superdb.py test_auth.py
90+
env:
91+
SUPER_DB: http://localhost:9867
92+
SUPER_DB_AUTH: '1'
93+
notify:
94+
runs-on: ubuntu-24.04
95+
needs: [test, test-auth]
96+
if: failure() && github.ref_name == 'main'
697
steps:
7-
- run: echo "placeholder"
98+
- run: |
99+
curl -s -X POST ${{ secrets.SLACK_WEBHOOK_BRIMLABS_TEST }} \
100+
-H 'Content-type: application/json' \
101+
--data '{"username":"superdb-python","text":"Python client tests failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"}'

README.md

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,25 @@
1-
# superdb-python
2-
A Python module for interacting with a persistent SuperDB database
1+
# `superdb` Python Package
2+
3+
Visit <https://superdb.org/dev/libraries/python.html> for installation
4+
instructions and example usage.
5+
6+
## Running the tests
7+
8+
Create and activate a virtual environment, install the package with its test
9+
dependencies, and start a local SuperDB service:
10+
11+
```
12+
python3 -m venv .venv
13+
source .venv/bin/activate
14+
pip3 install -e '.[test]'
15+
super db -db $(mktemp -d) serve
16+
```
17+
18+
Then in another shell (with the virtual environment activated):
19+
20+
```
21+
source .venv/bin/activate
22+
pytest
23+
```
24+
25+
Tests are skipped automatically if the SuperDB service is not reachable.

pyproject.toml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
[build-system]
2+
requires = ["setuptools", "setuptools-scm"]
3+
build-backend = "setuptools.build_meta"
4+
5+
[project]
6+
name = "superdb"
7+
dynamic = ["version"]
8+
dependencies = ["pyarrow", "requests"]
9+
requires-python = ">=3.8"
10+
11+
[tool.setuptools]
12+
py-modules = ["superdb"]
13+
14+
[project.optional-dependencies]
15+
test = ["pytest"]
16+
17+
[tool.pytest.ini_options]
18+
addopts = "-rs"
19+
20+
[tool.setuptools_scm]
21+
fallback_version = "0+unknown"
22+
version_scheme = "post-release"

superdb.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import getpass
2+
import json
3+
import os
4+
import os.path
5+
import urllib.parse
6+
7+
import pyarrow as pa
8+
import pyarrow.ipc
9+
import requests
10+
11+
12+
class Client():
13+
def __init__(self,
14+
base_url=os.environ.get('SUPER_DB', 'http://localhost:9867'),
15+
config_dir=os.path.expanduser('~/.super')):
16+
self.base_url = base_url.rstrip('/')
17+
self.session = requests.Session()
18+
self.session.headers.update({'Accept': 'application/vnd.apache.arrow.stream'})
19+
token = self.__get_auth_token(config_dir)
20+
if token is not None:
21+
self.session.headers.update({'Authorization': 'Bearer ' + token})
22+
23+
def __get_auth_token(self, config_dir):
24+
creds_path = os.path.join(config_dir, 'credentials.json')
25+
try:
26+
with open(creds_path) as f:
27+
data = f.read()
28+
except FileNotFoundError:
29+
return None
30+
creds = json.loads(data)
31+
if self.base_url in creds['services']:
32+
return creds['services'][self.base_url]['access']
33+
return None
34+
35+
def create_pool(self, name, layout={'order': 'desc', 'keys': [['ts']]},
36+
thresh=0):
37+
r = self.session.post(self.base_url + '/pool', json={
38+
'name': name,
39+
'layout': layout,
40+
'thresh': thresh,
41+
})
42+
self.__raise_for_status(r)
43+
44+
def load(self, pool_name_or_id, data, branch_name='main',
45+
commit_author=getpass.getuser(), commit_body='',
46+
mime_type=None):
47+
pool = urllib.parse.quote(pool_name_or_id, safe='')
48+
branch = urllib.parse.quote(branch_name, safe='')
49+
url = self.base_url + '/pool/' + pool + '/branch/' + branch
50+
commit_message = {'author': commit_author, 'body': commit_body}
51+
headers = {'SuperDB-Commit': json.dumps(commit_message)}
52+
if mime_type is not None:
53+
headers['Content-Type'] = mime_type
54+
r = self.session.post(url, headers=headers, data=data)
55+
self.__raise_for_status(r)
56+
57+
def delete_pool(self, pool_name_or_id):
58+
pool = urllib.parse.quote(pool_name_or_id, safe='')
59+
r = self.session.delete(self.base_url + '/pool/' + pool)
60+
self.__raise_for_status(r)
61+
62+
def query(self, query, safe=True):
63+
if safe:
64+
# Pre-flight: verify all top-level values are records of a single
65+
# type. Arrow requires top-level records and silently truncates on
66+
# type changes, so we detect both problems before issuing the real
67+
# query.
68+
safety_r = self.query_raw(
69+
query + ' | union(typeof(this)) by kind(this)',
70+
headers={'Accept': 'application/x-ndjson'},
71+
)
72+
rows = [
73+
json.loads(line)
74+
for line in safety_r.iter_lines(decode_unicode=True)
75+
if line
76+
]
77+
if rows:
78+
if any(row['kind'] != 'record' for row in rows):
79+
kinds = sorted({row['kind'] for row in rows})
80+
raise NonRecordError(
81+
f"Query result contains non-record values "
82+
f"(kind: {', '.join(repr(k) for k in kinds)}). "
83+
f"Arrow requires top-level records.",
84+
kinds,
85+
)
86+
type_count = len(rows[0]['union'])
87+
if type_count > 1:
88+
raise MixedTypesError(
89+
f'Query result contains {type_count} distinct types; results '
90+
f'would be silently truncated. Use \'| blend\' to merge types '
91+
f'into one, or pass safe=False to skip this check and accept '
92+
f'partial results.',
93+
type_count,
94+
)
95+
r = self.query_raw(query)
96+
try:
97+
reader = pa.ipc.open_stream(r.raw)
98+
except pa.lib.ArrowInvalid as e:
99+
# An empty response body (no schema) means either the pool has no
100+
# data or the data contains a type the Arrow encoder can't handle
101+
# (e.g. an empty record). Both cases are indistinguishable at the
102+
# HTTP level when streaming, so both are silently treated as an
103+
# empty result. Any other ArrowInvalid (wrong format, mid-stream
104+
# corruption, etc.) is re-raised.
105+
if 'null or length 0' in str(e):
106+
return
107+
raise
108+
for batch in reader:
109+
yield from batch.to_pylist(maps_as_pydicts='strict')
110+
111+
def query_raw(self, query, headers=None):
112+
r = self.session.post(self.base_url + '/query', headers=headers,
113+
json={'query': query}, stream=True)
114+
self.__raise_for_status(r)
115+
r.raw.decode_content = True
116+
return r
117+
118+
@staticmethod
119+
def __raise_for_status(response):
120+
if response.status_code >= 400:
121+
try:
122+
error = response.json()['error']
123+
except Exception:
124+
response.raise_for_status()
125+
else:
126+
raise RequestError(error, response)
127+
128+
129+
class RequestError(Exception):
130+
"""Raised by Client methods when an HTTP request fails."""
131+
def __init__(self, message, response):
132+
super(RequestError, self).__init__(message)
133+
self.response = response
134+
135+
136+
class MixedTypesError(Exception):
137+
"""Raised by query() when the result contains more than one distinct type."""
138+
def __init__(self, message, type_count):
139+
super().__init__(message)
140+
self.type_count = type_count
141+
142+
143+
class NonRecordError(Exception):
144+
"""Raised by query() when the result contains non-record top-level values."""
145+
def __init__(self, message, kinds):
146+
super().__init__(message)
147+
self.kinds = kinds
148+
149+
150+
if __name__ == '__main__':
151+
import argparse
152+
import pprint
153+
154+
parser = argparse.ArgumentParser(
155+
description='Query default SuperDB service and print results.',
156+
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
157+
parser.add_argument('query')
158+
args = parser.parse_args()
159+
160+
c = Client()
161+
for record in c.query(args.query):
162+
pprint.pprint(record)

test_auth.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""
2+
Auth integration tests for the SuperDB Python client.
3+
4+
These tests require a SuperDB service running with authentication enabled
5+
and valid credentials stored in ~/.super/credentials.json. In CI they
6+
are run automatically by the test-auth GitHub Actions job. When run
7+
manually with plain pytest they are skipped unless the SUPER_DB_AUTH
8+
environment variable is set.
9+
"""
10+
11+
import os
12+
import uuid
13+
14+
import pytest
15+
import requests
16+
17+
from superdb import Client, RequestError
18+
19+
if not os.environ.get('SUPER_DB_AUTH'):
20+
pytest.skip('auth not configured (SUPER_DB_AUTH not set)', allow_module_level=True)
21+
22+
_BASE_URL = os.environ.get('SUPER_DB', 'http://localhost:9867').rstrip('/')
23+
try:
24+
requests.get(_BASE_URL + '/status', timeout=2)
25+
except requests.exceptions.ConnectionError:
26+
pytest.skip(
27+
f'SuperDB service not reachable at {_BASE_URL}',
28+
allow_module_level=True,
29+
)
30+
31+
32+
def test_authenticated_client_can_query():
33+
client = Client()
34+
name = 'test_auth_' + uuid.uuid4().hex[:8]
35+
client.create_pool(name)
36+
try:
37+
client.load(name, b'{a: 1}', mime_type='application/x-sup')
38+
assert list(client.query(f'from {name}')) == [{'a': 1}]
39+
finally:
40+
client.delete_pool(name)
41+
42+
43+
def test_unauthenticated_client_raises_request_error():
44+
# A client with no credentials (config_dir='') should be rejected by an
45+
# auth-enabled service on any request.
46+
with pytest.raises(RequestError):
47+
Client(config_dir='').create_pool('x')

0 commit comments

Comments
 (0)