Skip to content

Commit

Permalink
Merge branch 'release/0.2.0' into main
Browse files Browse the repository at this point in the history
  • Loading branch information
Charles Larivier committed Feb 7, 2022
2 parents b3a9691 + a677382 commit c62b57e
Show file tree
Hide file tree
Showing 30 changed files with 667 additions and 294 deletions.
5 changes: 2 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# This workflow will install Python dependencies, run tests and lint with a variety of Python versions
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions

name: build
name: ci

on:
push:
Expand All @@ -13,8 +13,7 @@ on:
- develop

jobs:
build:

test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
Expand Down
45 changes: 45 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: publish

on:
push:
branches:
- main

jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2

- name: Set up Python 3.8
uses: actions/setup-python@v1
with:
python-version: 3.8

- name: Install pypa/build
run: >-
python -m
pip install
build
--user
- name: Build a binary wheel and a source tarball
run: >-
python -m
build
--sdist
--wheel
--outdir dist/
.
- name: Publish distribution to Test PyPI
uses: pypa/gh-action-pypi-publish@master
with:
password: ${{ secrets.TEST_PYPI_API_TOKEN }}
repository_url: https://test.pypi.org/legacy/

- name: Publish distribution to PyPI
if: startsWith(github.ref, 'refs/tags')
uses: pypa/gh-action-pypi-publish@master
with:
password: ${{ secrets.PYPI_API_TOKEN }}
13 changes: 13 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
dev:
@pipenv install --dev --pre
@pipenv run pre-commit install

release: clear-builds build distribute

clear-builds:
@rm -rf dist

build:
@pipenv run python -m pip install --upgrade build
@pipenv run python -m build

distribute:
@pipenv run python -m pip install --upgrade twine
@pipenv run python -m twine upload dist/*
94 changes: 48 additions & 46 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ pip install metabase-python
This API is still experimental and may change significantly between minor versions.


Start by creating an instance of Metabase with your credentials. This connection will automatically be used by any
object that interacts with the Metabase API.
Start by creating an instance of Metabase with your credentials.
```python
from metabase import Metabase

Expand All @@ -28,17 +27,18 @@ metabase = Metabase(
)
```

You can then interact with any of the supported endpoints through the classes included in this package. All changes
are reflected in Metabase instantly.
You can then interact with any of the supported endpoints through the classes included in this package. Methods that
instantiate an object from the Metabase API require the `using` parameter which expects an instance of `Metabase` such
as the one we just instantiated above. All changes are reflected in Metabase instantly.

```python
from metabase import User

# get all objects
users = User.list()
users = User.list(using=metabase)

# get an object by ID
user = User.get(1)
user = User.get(1, using=metabase)

# attributes are automatically loaded and available in the instance
if user.is_active:
Expand All @@ -52,6 +52,7 @@ user.delete()

# create an object
new_user = User.create(
using=metabase,
first_name="<first_name>",
last_name="<last_name>",
email="<email>",
Expand All @@ -67,7 +68,7 @@ Some endpoints also support additional methods:
```python
from metabase import User

user = User.get(1)
user = User.get(1, using=metabase)

user.reactivate() # Reactivate user
user.send_invite() # Resend the user invite email for a given user.
Expand All @@ -78,11 +79,12 @@ Here's a slightly more advanced example:
from metabase import User, PermissionGroup, PermissionMembership

# create a new PermissionGroup
my_group = PermissionGroup.create(name="My Group")
my_group = PermissionGroup.create(name="My Group", using=metabase)

for user in User.list():
# add all users to my_group
PermissionMembership.create(
using=metabase,
group_id=my_group.id,
user_id=user.id
)
Expand All @@ -94,6 +96,7 @@ the exact MBQL (i.e. Metabase Query Language) as the `query` argument.
from metabase import Dataset

dataset = Dataset.create(
using.metabase,
database=1,
type="query",
query={
Expand All @@ -111,44 +114,43 @@ df = dataset.to_pandas()

For a full list of endpoints and methods, see [Metabase API](https://www.metabase.com/docs/latest/api-documentation.html).

| Endpoints | Support |
|-----------------------|:----------:|
| Activity ||
| Alert ||
| Automagic dashboards ||
| Card ||
| Collection ||
| Card ||
| Dashboard ||
| Database ||
| Dataset ||
| Email ||
| Embed ||
| Field ||
| Geojson ||
| Ldap ||
| Login history ||
| Metric ||
| Native query snippet ||
| Notify ||
| Permissions ||
| Premium features ||
| Preview embed ||
| Public ||
| Pulse ||
| Revision ||
| Search ||
| Segment ||
| Session ||
| Setting ||
| Setup ||
| Slack ||
| Table ||
| Task ||
| Tiles ||
| Transform ||
| User ||
| Util ||
| Endpoints | Support | Notes |
|-----------------------|:----------:|-------|
| Activity || |
| Alert || |
| Automagic dashboards || |
| Card || |
| Collection || |
| Dashboard || |
| Database || |
| Dataset || |
| Email || |
| Embed || |
| Field || |
| Geojson || |
| Ldap || |
| Login history || |
| Metric || |
| Native query snippet || |
| Notify || |
| Permissions || |
| Premium features || |
| Preview embed || |
| Public || |
| Pulse || |
| Revision || |
| Search || |
| Segment || |
| Session || |
| Setting || |
| Setup || |
| Slack || |
| Table || |
| Task || |
| Tiles || |
| Transform || |
| User || |
| Util || |

## Contributing
Contributions are welcome!
Expand Down
1 change: 1 addition & 0 deletions src/metabase/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from metabase.metabase import Metabase
from metabase.resources.card import Card
from metabase.resources.database import Database
from metabase.resources.dataset import Dataset
from metabase.resources.field import Field
Expand Down
4 changes: 4 additions & 0 deletions src/metabase/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
class NotFoundError(Exception):
pass


class AuthenticationError(Exception):
pass
18 changes: 6 additions & 12 deletions src/metabase/metabase.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,9 @@
from weakref import WeakValueDictionary

import requests


class Singleton(type):
_instances = WeakValueDictionary()

def __call__(cls, *args, **kw):
if cls not in cls._instances:
instance = super(Singleton, cls).__call__(*args, **kw)
cls._instances[cls] = instance
return cls._instances[cls]
from metabase.exceptions import AuthenticationError


class Metabase(metaclass=Singleton):
class Metabase:
def __init__(self, host: str, user: str, password: str, token: str = None):
self._host = host
self.user = user
Expand All @@ -40,6 +30,10 @@ def token(self):
self.host + "/api/session",
json={"username": self.user, "password": self.password},
)

if response.status_code != 200:
raise AuthenticationError(response.content.decode())

self._token = response.json()["id"]

return self._token
Expand Down
31 changes: 14 additions & 17 deletions src/metabase/resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ class Resource:
ENDPOINT: str
PRIMARY_KEY: str = "id"

def __init__(self, **kwargs):
def __init__(self, _using: Metabase, **kwargs):
self._attributes = []
self._using = _using

for k, v in kwargs.items():
self._attributes.append(k)
Expand All @@ -31,42 +32,38 @@ def __repr__(self):
+ ")"
)

@staticmethod
def connection() -> Metabase:
return Metabase()


class ListResource(Resource):
@classmethod
def list(cls):
def list(cls, using: Metabase):
"""List all instances."""
response = cls.connection().get(cls.ENDPOINT)
records = [cls(**record) for record in response.json()]
response = using.get(cls.ENDPOINT)
records = [cls(_using=using, **record) for record in response.json()]
return records


class GetResource(Resource):
@classmethod
def get(cls, id: int):
def get(cls, id: int, using: Metabase):
"""Get a single instance by ID."""
response = cls.connection().get(cls.ENDPOINT + f"/{id}")
response = using.get(cls.ENDPOINT + f"/{id}")

if response.status_code == 404 or response.status_code == 204:
raise NotFoundError(f"{cls.__name__}(id={id}) was not found.")

return cls(**response.json())
return cls(_using=using, **response.json())


class CreateResource(Resource):
@classmethod
def create(cls, **kwargs):
def create(cls, using: Metabase, **kwargs):
"""Create an instance and save it."""
response = cls.connection().post(cls.ENDPOINT, json=kwargs)
response = using.post(cls.ENDPOINT, json=kwargs)

if response.status_code not in (200, 202):
raise HTTPError(response.content.decode())

return cls(**response.json())
return cls(_using=using, **response.json())


class UpdateResource(Resource):
Expand All @@ -77,11 +74,11 @@ def update(self, **kwargs) -> None:
ignored from the request.
"""
params = {k: v for k, v in kwargs.items() if v != MISSING}
response = self.connection().put(
response = self._using.put(
self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}", json=params
)

if response.status_code != 200:
if response.status_code not in (200, 202):
raise HTTPError(response.json())

for k, v in kwargs.items():
Expand All @@ -91,7 +88,7 @@ def update(self, **kwargs) -> None:
class DeleteResource(Resource):
def delete(self) -> None:
"""Delete an instance."""
response = self.connection().delete(
response = self._using.delete(
self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}"
)

Expand Down
Loading

0 comments on commit c62b57e

Please sign in to comment.