Skip to content

Commit a9bc00a

Browse files
author
Charles Larivier
committed
Merge branch 'feature/add-connection' into develop
2 parents 37e5a59 + d3e66fe commit a9bc00a

38 files changed

+2502
-31
lines changed
Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
# This workflow will install Python dependencies, run tests and lint with a variety of Python versions
22
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
33

4-
name: Python package
4+
name: main
55

66
on:
77
push:
8-
branches: [ main ]
8+
branches:
9+
- main
910
pull_request:
10-
branches: [ main ]
11+
branches:
12+
- main
13+
- develop
1114

1215
jobs:
1316
build:
@@ -16,19 +19,41 @@ jobs:
1619
strategy:
1720
fail-fast: false
1821
matrix:
19-
python-version: ["3.8", "3.9", "3.10"]
22+
python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"]
23+
os: [ubuntu-latest, macos-latest, windows-latest]
24+
25+
services:
26+
metabase:
27+
image: metabase/metabase
28+
ports:
29+
- 3000:3000
2030

2131
steps:
2232
- uses: actions/checkout@v2
33+
2334
- name: Set up Python ${{ matrix.python-version }}
2435
uses: actions/setup-python@v2
2536
with:
2637
python-version: ${{ matrix.python-version }}
38+
2739
- name: Install dependencies
2840
run: |
2941
python -m pip install --upgrade pip
3042
python -m pip install pipenv
31-
pipenv install --deploy
43+
pipenv install --deploy --dev
44+
45+
- name: Wait for Metabase to be initialized
46+
run: sleep 60s
47+
shell: bash
48+
3249
- name: Test with pytest
3350
run: |
34-
pipenv run pytest
51+
pipenv run pytest --cov=./ --cov-report=xml
52+
53+
- name: Upload coverage to Codecov
54+
uses: codecov/codecov-action@v2
55+
with:
56+
token: ${{ secrets.CODECOV_TOKEN }}
57+
directory: ./
58+
env_vars: OS,PYTHON
59+
fail_ci_if_error: true

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,4 +139,6 @@ dmypy.json
139139
# Cython debug symbols
140140
cython_debug/
141141

142-
.idea
142+
143+
.idea/
144+
/metabase/_version.py

.pre-commit-config.yaml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
repos:
2+
- repo: https://github.com/pre-commit/pre-commit-hooks
3+
rev: v3.2.0
4+
hooks:
5+
- id: trailing-whitespace
6+
- id: end-of-file-fixer
7+
- id: check-yaml
8+
- id: check-added-large-files
9+
10+
- repo: https://github.com/psf/black
11+
rev: 21.11b0
12+
hooks:
13+
- id: black
14+
15+
- repo: https://github.com/PyCQA/isort
16+
rev: 5.10.1
17+
hooks:
18+
- id: isort

Makefile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
dev:
2+
@pipenv install --dev --pre
3+
@pipenv run pre-commit install

Pipfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ verify_ssl = true
44
name = "pypi"
55

66
[packages]
7+
metabase-python = {editable = true, path = "."}
78

89
[dev-packages]
910
pre-commit = "*"

Pipfile.lock

Lines changed: 530 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,4 @@
11
# Metabase Python
2+
[![main](https://github.com/chasleslr/metabase-python/actions/workflows/main.yml/badge.svg)](https://github.com/chasleslr/metabase-python/actions/workflows/main.yml)
3+
[![codecov](https://codecov.io/gh/chasleslr/metabase-python/branch/main/graph/badge.svg?token=15G7HOQ1CM)](https://codecov.io/gh/chasleslr/metabase-python)
4+
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)

docker-compose.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
version: "3.9"
2+
services:
3+
metabase:
4+
image: metabase/metabase
5+
ports:
6+
- 3000:3000

metabase/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from metabase.metabase import Metabase
2+
from metabase.resources.dataset import Dataset
3+
from metabase.resources.field import Field
4+
from metabase.resources.metric import Metric
5+
from metabase.resources.permission_group import PermissionGroup
6+
from metabase.resources.permission_membership import PermissionMembership
7+
from metabase.resources.segment import Segment
8+
from metabase.resources.table import Table
9+
from metabase.resources.user import User

metabase/exceptions.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
class NotFoundError(Exception):
2+
pass

metabase/metabase.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
from weakref import WeakValueDictionary
2+
3+
import requests
4+
5+
6+
class Singleton(type):
7+
_instances = WeakValueDictionary()
8+
9+
def __call__(cls, *args, **kw):
10+
if cls not in cls._instances:
11+
instance = super(Singleton, cls).__call__(*args, **kw)
12+
cls._instances[cls] = instance
13+
return cls._instances[cls]
14+
15+
16+
class Metabase(metaclass=Singleton):
17+
def __init__(self, host: str, user: str, password: str, token: str = None):
18+
self._host = host
19+
self.user = user
20+
self.password = password
21+
self._token = token
22+
23+
@property
24+
def host(self):
25+
host = self._host
26+
27+
if not host.startswith("http"):
28+
host = "https://" + self._host
29+
30+
return host.rstrip("/")
31+
32+
@host.setter
33+
def host(self, value):
34+
self._host = value
35+
36+
@property
37+
def token(self):
38+
if self._token is None:
39+
response = requests.post(
40+
self.host + "/api/session",
41+
json={"username": self.user, "password": self.password},
42+
)
43+
self._token = response.json()["id"]
44+
45+
return self._token
46+
47+
@token.setter
48+
def token(self, value):
49+
self._token = value
50+
51+
@property
52+
def headers(self):
53+
return {"X-Metabase-Session": self.token}
54+
55+
def get(self, endpoint: str, **kwargs):
56+
return requests.get(self.host + endpoint, headers=self.headers, **kwargs)
57+
58+
def post(self, endpoint: str, **kwargs):
59+
return requests.post(self.host + endpoint, headers=self.headers, **kwargs)
60+
61+
def put(self, endpoint: str, **kwargs):
62+
return requests.put(self.host + endpoint, headers=self.headers, **kwargs)
63+
64+
def delete(self, endpoint: str, **kwargs):
65+
return requests.delete(self.host + endpoint, headers=self.headers, **kwargs)

metabase/missing.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
class MISSING:
2+
pass

metabase/resource.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
from __future__ import annotations
2+
3+
from exceptions import NotFoundError
4+
from requests import HTTPError
5+
6+
from metabase import Metabase
7+
from metabase.missing import MISSING
8+
9+
10+
class Resource:
11+
ENDPOINT: str
12+
PRIMARY_KEY: str = "id"
13+
14+
def __init__(self, **kwargs):
15+
self._attributes = []
16+
17+
for k, v in kwargs.items():
18+
self._attributes.append(k)
19+
setattr(self, k, v)
20+
21+
def __repr__(self):
22+
# move primary key to beginning of the list
23+
attributes = self._attributes.copy()
24+
if self.PRIMARY_KEY is not None:
25+
attributes.insert(0, attributes.pop(attributes.index(self.PRIMARY_KEY)))
26+
27+
return (
28+
self.__class__.__qualname__
29+
+ "("
30+
+ ", ".join([f"{attr}={getattr(self, attr)}" for attr in attributes])
31+
+ ")"
32+
)
33+
34+
@staticmethod
35+
def connection() -> Metabase:
36+
return Metabase()
37+
38+
39+
class ListResource(Resource):
40+
@classmethod
41+
def list(cls):
42+
"""List all instances."""
43+
response = cls.connection().get(cls.ENDPOINT)
44+
records = [cls(**record) for record in response.json()]
45+
return records
46+
47+
48+
class GetResource(Resource):
49+
@classmethod
50+
def get(cls, id: int):
51+
"""Get a single instance by ID."""
52+
response = cls.connection().get(cls.ENDPOINT + f"/{id}")
53+
54+
if response.status_code == 404 or response.status_code == 204:
55+
raise NotFoundError(f"{cls.__name__}(id={id}) was not found.")
56+
57+
return cls(**response.json())
58+
59+
60+
class CreateResource(Resource):
61+
@classmethod
62+
def create(cls, **kwargs):
63+
"""Create an instance and save it."""
64+
response = cls.connection().post(cls.ENDPOINT, json=kwargs)
65+
66+
if response.status_code not in (200, 202):
67+
raise HTTPError(response.content.decode())
68+
69+
return cls(**response.json())
70+
71+
72+
class UpdateResource(Resource):
73+
def update(self, **kwargs) -> None:
74+
"""
75+
Update an instance by providing function arguments.
76+
Providing any argument with metabase.MISSING will result in this argument being
77+
ignored from the request.
78+
"""
79+
params = {k: v for k, v in kwargs.items() if v != MISSING}
80+
response = self.connection().put(
81+
self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}", json=params
82+
)
83+
84+
if response.status_code != 200:
85+
raise HTTPError(response.json())
86+
87+
for k, v in kwargs.items():
88+
setattr(self, k, v)
89+
90+
91+
class DeleteResource(Resource):
92+
def delete(self) -> None:
93+
"""Delete an instance."""
94+
response = self.connection().delete(
95+
self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}"
96+
)
97+
98+
if response.status_code not in (200, 204):
99+
raise HTTPError(response.content.decode())

metabase/resources/__init__.py

Whitespace-only changes.

metabase/resources/dataset.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from typing import Any, List
5+
6+
import pandas as pd
7+
8+
from metabase.resource import CreateResource, Resource
9+
10+
11+
class Data(Resource):
12+
ENDPOINT = None
13+
PRIMARY_KEY = None
14+
15+
rows: List[List[Any]]
16+
cols: dict
17+
native_form: dict
18+
19+
def to_pandas(self):
20+
"""Returns the query results as a Pandas DataFrame."""
21+
columns = [col["display_name"] for col in self.cols]
22+
return pd.DataFrame(data=self.rows, columns=columns)
23+
24+
25+
class Dataset(CreateResource):
26+
ENDPOINT = "/api/dataset"
27+
PRIMARY_KEY = None
28+
29+
context: str
30+
status: str
31+
database_id: int
32+
data: Data
33+
row_count: int
34+
35+
started_at: str
36+
running_time: int
37+
json_query: str
38+
average_execution_time: int = None
39+
40+
@classmethod
41+
def create(cls, database: int, type: str, query: dict, **kwargs) -> Dataset:
42+
dataset = super(Dataset, cls).create(database=database, type=type, query=query)
43+
dataset.data = Data(**dataset.data)
44+
return dataset
45+
46+
def to_pandas(self) -> pd.DataFrame:
47+
"""Returns the query results as a Pandas DataFrame."""
48+
return self.data.to_pandas()

0 commit comments

Comments
 (0)