diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 612bb3f..0a4ea34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: @@ -13,8 +13,7 @@ on: - develop jobs: - build: - + test: runs-on: ubuntu-latest strategy: fail-fast: false diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..b87bdfe --- /dev/null +++ b/.github/workflows/publish.yml @@ -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 }} diff --git a/Makefile b/Makefile index 60823de..86481d6 100644 --- a/Makefile +++ b/Makefile @@ -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/* diff --git a/README.md b/README.md index 90af4b1..fa662e6 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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: @@ -52,6 +52,7 @@ user.delete() # create an object new_user = User.create( + using=metabase, first_name="", last_name="", email="", @@ -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. @@ -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 ) @@ -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={ @@ -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! diff --git a/src/metabase/__init__.py b/src/metabase/__init__.py index f285c10..30dc4c7 100644 --- a/src/metabase/__init__.py +++ b/src/metabase/__init__.py @@ -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 diff --git a/src/metabase/exceptions.py b/src/metabase/exceptions.py index c901a5c..d1f1f7b 100644 --- a/src/metabase/exceptions.py +++ b/src/metabase/exceptions.py @@ -1,2 +1,6 @@ class NotFoundError(Exception): pass + + +class AuthenticationError(Exception): + pass diff --git a/src/metabase/metabase.py b/src/metabase/metabase.py index ed46bf4..0fbf20e 100644 --- a/src/metabase/metabase.py +++ b/src/metabase/metabase.py @@ -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 @@ -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 diff --git a/src/metabase/resource.py b/src/metabase/resource.py index 20c76d9..409c35c 100644 --- a/src/metabase/resource.py +++ b/src/metabase/resource.py @@ -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) @@ -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): @@ -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(): @@ -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)}" ) diff --git a/src/metabase/resources/card.py b/src/metabase/resources/card.py new file mode 100644 index 0000000..bf8570d --- /dev/null +++ b/src/metabase/resources/card.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +from typing import List + +from metabase import Metabase +from metabase.missing import MISSING +from metabase.resource import CreateResource, GetResource, ListResource, UpdateResource + + +class Card(ListResource, CreateResource, GetResource, UpdateResource): + ENDPOINT = "/api/card" + + id: int + table_id: int + database_id: int + collection_id: int + creator_id: int + made_public_by_id: int + public_uuid: str + + name: str + description: str + + collection: dict # TODO: Collection + collection_position: int + + query_type: str + dataset_query: dict # TODO: DatasetQuery + display: str + visualization_settings: dict # TODO: VisualizationSettings + result_metadata: List[dict] + + embedding_params: dict + cache_ttl: str + creator: "User" + + favorite: bool + archived: bool + enable_embedding: bool + + updated_at: str + created_at: str + + @classmethod + def list(cls, using: Metabase) -> List[Card]: + """ + Get all the Cards. Option filter param f can be used to change the set + of Cards that are returned; default is all, but other options include + mine, fav, database, table, recent, popular, and archived. + + See corresponding implementation functions above for the specific behavior + of each filter option. :card_index:. + """ + # TODO: add support for endpoint parameters: f, model_id. + return super(Card, cls).list(using) + + @classmethod + def get(cls, id: int, using: Metabase) -> Card: + """ + Get Card with ID. + """ + return super(Card, cls).get(id, using) + + @classmethod + def create( + cls, + using: Metabase, + name: str, + dataset_query: dict, # TODO: DatasetQuery + visualization_settings: dict, # TODO: VisualizationSettings + display: str, + description: str = None, + collection_id: str = None, + collection_position: int = None, + result_metadata: List[dict] = None, + metadata_checksum: str = None, + cache_ttl: int = None, + **kwargs, + ) -> Card: + """ + Create a new Card. + """ + return super(Card, cls).create( + using=using, + name=name, + dataset_query=dataset_query, + visualization_settings=visualization_settings, + display=display, + description=description, + collection_id=collection_id, + collection_position=collection_position, + result_metadata=result_metadata, + metadata_checksum=metadata_checksum, + cache_ttl=cache_ttl, + **kwargs, + ) + + def update( + self, + name: str = MISSING, + dataset_query: dict = MISSING, # TODO: DatasetQuery + visualization_settings: dict = MISSING, # TODO: VisualizationSettings + display: str = MISSING, + description: str = MISSING, + collection_id: str = MISSING, + collection_position: int = MISSING, + result_metadata: List[dict] = MISSING, + metadata_checksum: str = MISSING, + archived: bool = MISSING, + enable_embedding: bool = MISSING, + embedding_params: dict = MISSING, + cache_ttl: int = None, + **kwargs, + ) -> None: + """ + Update a Card. + """ + return super(Card, self).update( + name=name, + dataset_query=dataset_query, + visualization_settings=visualization_settings, + display=display, + description=description, + collection_id=collection_id, + collection_position=collection_position, + result_metadata=result_metadata, + metadata_checksum=metadata_checksum, + archived=archived, + enable_embedding=enable_embedding, + embedding_params=embedding_params, + cache_ttl=cache_ttl, + ) + + def archive(self): + """Archive a Card.""" + return self.update( + archived=True, revision_message="Archived by metabase-python." + ) diff --git a/src/metabase/resources/database.py b/src/metabase/resources/database.py index 4c6b7d8..5bbe56a 100644 --- a/src/metabase/resources/database.py +++ b/src/metabase/resources/database.py @@ -2,6 +2,7 @@ from typing import Any, Dict, List +from metabase import Metabase from metabase.missing import MISSING from metabase.resource import ( CreateResource, @@ -47,18 +48,19 @@ class Database( created_at: str @classmethod - def list(cls) -> List[Database]: - response = cls.connection().get(cls.ENDPOINT) - records = [cls(**db) for db in response.json().get("data", [])] + def list(cls, using: Metabase) -> List[Database]: + response = using.get(cls.ENDPOINT) + records = [cls(_using=using, **db) for db in response.json().get("data", [])] return records @classmethod - def get(cls, id: int) -> Database: - return super(Database, cls).get(id) + def get(cls, id: int, using: Metabase) -> Database: + return super(Database, cls).get(id, using=using) @classmethod def create( cls, + using: Metabase, name: str, engine: str, details: dict, @@ -75,6 +77,7 @@ def create( You must be a superuser to do this. """ return super(Database, cls).create( + using=using, name=name, engine=engine, details=details, @@ -128,43 +131,33 @@ def delete(self) -> None: def fields(self) -> List[Field]: """Get a list of all Fields in Database.""" - fields = ( - self.connection() - .get(self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}" + "/fields") - .json() - ) - return [Field(**payload) for payload in fields] + fields = self._using.get( + self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}" + "/fields" + ).json() + return [Field(_using=self._using, **payload) for payload in fields] def idfields(self) -> List[Field]: """Get a list of all primary key Fields for Database.""" - fields = ( - self.connection() - .get(self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}" + "/idfields") - .json() - ) - return [Field(**payload) for payload in fields] + fields = self._using.get( + self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}" + "/idfields" + ).json() + return [Field(_using=self._using, **payload) for payload in fields] def schemas(self) -> List[str]: """Returns a list of all the schemas found for the database id.""" - return ( - self.connection() - .get(self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}" + "/schemas") - .json() - ) + return self._using.get( + self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}" + "/schemas" + ).json() def tables(self, schema: str) -> List[Table]: """Returns a list of Tables for the given Database id and schema.""" - tables = ( - self.connection() - .get( - self.ENDPOINT - + f"/{getattr(self, self.PRIMARY_KEY)}" - + "/schema" - + f"/{schema}" - ) - .json() - ) - return [Table(**payload) for payload in tables] + tables = self._using.get( + self.ENDPOINT + + f"/{getattr(self, self.PRIMARY_KEY)}" + + "/schema" + + f"/{schema}" + ).json() + return [Table(_using=self._using, **payload) for payload in tables] def discard_values(self): """ @@ -172,7 +165,7 @@ def discard_values(self): You must be a superuser to do this. """ - return self.connection().post( + return self._using.post( self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}" + "/discard_values" ) @@ -182,13 +175,13 @@ def rescan_values(self): You must be a superuser to do this. """ - return self.connection().post( + return self._using.post( self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}" + "/rescan_values" ) def sync(self): """Update the metadata for this Database. This happens asynchronously.""" - return self.connection().post( + return self._using.post( self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}" + "/sync" ) @@ -198,6 +191,6 @@ def sync_schema(self): You must be a superuser to do this. """ - return self.connection().post( + return self._using.post( self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}" + "/sync_schema" ) diff --git a/src/metabase/resources/dataset.py b/src/metabase/resources/dataset.py index a25de97..ab83548 100644 --- a/src/metabase/resources/dataset.py +++ b/src/metabase/resources/dataset.py @@ -4,6 +4,7 @@ import pandas as pd +from metabase import Metabase from metabase.resource import CreateResource, Resource @@ -37,10 +38,14 @@ class Dataset(CreateResource): average_execution_time: int = None @classmethod - def create(cls, database: int, type: str, query: dict, **kwargs) -> Dataset: + def create( + cls, using: Metabase, database: int, type: str, query: dict, **kwargs + ) -> Dataset: """Execute a query and retrieve the results in the usual format.""" - dataset = super(Dataset, cls).create(database=database, type=type, query=query) - dataset.data = Data(**dataset.data) + dataset = super(Dataset, cls).create( + using=using, database=database, type=type, query=query + ) + dataset.data = Data(_using=using, **dataset.data) return dataset def to_pandas(self) -> pd.DataFrame: diff --git a/src/metabase/resources/field.py b/src/metabase/resources/field.py index 5831b15..66353ee 100644 --- a/src/metabase/resources/field.py +++ b/src/metabase/resources/field.py @@ -3,6 +3,7 @@ from enum import Enum from typing import Any, Dict, List, Optional +from metabase import Metabase from metabase.missing import MISSING from metabase.resource import GetResource, UpdateResource @@ -111,9 +112,9 @@ class VisibilityType(str, Enum): sensitive = "sensitive" @classmethod - def get(cls, id: int) -> Field: + def get(cls, id: int, using: Metabase) -> Field: """Get Field with ID.""" - return super(Field, cls).get(id) + return super(Field, cls).get(id, using=using) def update( self, @@ -145,11 +146,9 @@ def update( def related(self) -> Dict[str, Any]: """Return related entities.""" - return ( - self.connection() - .get(self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}" + "/related") - .json() - ) + return self._using.get( + self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}" + "/related" + ).json() def discard_values(self): """ @@ -159,7 +158,7 @@ def discard_values(self): You must be a superuser to do this. """ - return self.connection().post( + return self._using.post( self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}" + "/discard_values" ) @@ -170,6 +169,6 @@ def rescan_values(self): You must be a superuser to do this. """ - return self.connection().post( + return self._using.post( self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}" + "/rescan_values" ) diff --git a/src/metabase/resources/metric.py b/src/metabase/resources/metric.py index d803b89..2bebab0 100644 --- a/src/metabase/resources/metric.py +++ b/src/metabase/resources/metric.py @@ -3,6 +3,7 @@ from datetime import datetime from typing import List +from metabase import Metabase from metabase.missing import MISSING from metabase.resource import CreateResource, GetResource, ListResource, UpdateResource @@ -31,16 +32,17 @@ class Metric(ListResource, CreateResource, GetResource, UpdateResource): creator: dict @classmethod - def list(cls) -> List[Metric]: - return super(Metric, cls).list() + def list(cls, using: Metabase) -> List[Metric]: + return super(Metric, cls).list(using=using) @classmethod - def get(cls, id: int) -> Metric: - return super(Metric, cls).get(id) + def get(cls, id: int, using: Metabase) -> Metric: + return super(Metric, cls).get(id, using=using) @classmethod def create( cls, + using: Metabase, name: str, table_id: int, definition: dict, @@ -48,6 +50,7 @@ def create( **kwargs ) -> Metric: return super(Metric, cls).create( + using=using, name=name, table_id=table_id, definition=definition, diff --git a/src/metabase/resources/permission_group.py b/src/metabase/resources/permission_group.py index de13346..eaa27d2 100644 --- a/src/metabase/resources/permission_group.py +++ b/src/metabase/resources/permission_group.py @@ -2,6 +2,7 @@ from typing import List +from metabase import Metabase from metabase.resource import ( CreateResource, DeleteResource, @@ -21,31 +22,31 @@ class PermissionGroup( member_count: int @classmethod - def list(cls) -> List[PermissionGroup]: + def list(cls, using: Metabase) -> List[PermissionGroup]: """ Fetch all PermissionsGroups, including a count of the number of :members in that group. You must be a superuser to do this. """ - return super(PermissionGroup, cls).list() + return super(PermissionGroup, cls).list(using=using) @classmethod - def get(cls, id: int) -> PermissionGroup: + def get(cls, id: int, using: Metabase) -> PermissionGroup: """ Fetch the details for a certain permissions group. You must be a superuser to do this. """ - return super(PermissionGroup, cls).get(id) + return super(PermissionGroup, cls).get(id, using=using) @classmethod - def create(cls, name: str, **kwargs) -> PermissionGroup: + def create(cls, using: Metabase, name: str, **kwargs) -> PermissionGroup: """ Create a new PermissionsGroup. You must be a superuser to do this. """ - return super(PermissionGroup, cls).create(name=name, **kwargs) + return super(PermissionGroup, cls).create(using=using, name=name, **kwargs) def update(self, name: str, **kwargs) -> None: """ diff --git a/src/metabase/resources/permission_membership.py b/src/metabase/resources/permission_membership.py index 58b7ac6..dcbc39c 100644 --- a/src/metabase/resources/permission_membership.py +++ b/src/metabase/resources/permission_membership.py @@ -4,6 +4,7 @@ from requests import HTTPError +from metabase import Metabase from metabase.resource import CreateResource, DeleteResource, ListResource @@ -18,7 +19,7 @@ class PermissionMembership(ListResource, CreateResource, DeleteResource): # TODO: allow for bulk updates through /api/permissions/membership/graph @classmethod - def list(cls) -> List[PermissionMembership]: + def list(cls, using: Metabase) -> List[PermissionMembership]: """ Fetch a map describing the group memberships of various users. This map’s format is: @@ -26,21 +27,23 @@ def list(cls) -> List[PermissionMembership]: :group_id }]}. You must be a superuser to do this. """ - response = cls.connection().get(cls.ENDPOINT) + response = using.get(cls.ENDPOINT) all_memberships = [ item for sublist in response.json().values() for item in sublist ] - records = [cls(**record) for record in all_memberships] + records = [cls(_using=using, **record) for record in all_memberships] return records @classmethod - def create(cls, group_id: int, user_id: int, **kwargs) -> PermissionMembership: + def create( + cls, using: Metabase, group_id: int, user_id: int, **kwargs + ) -> PermissionMembership: """ Add a User to a PermissionsGroup. Returns updated list of members belonging to the group. You must be a superuser to do this. """ - response = cls.connection().post( + response = using.post( cls.ENDPOINT, json={"group_id": group_id, "user_id": user_id} ) @@ -50,4 +53,4 @@ def create(cls, group_id: int, user_id: int, **kwargs) -> PermissionMembership: # metabase returns a list of all memberships for the given group_id membership = next(filter(lambda x: x["user_id"] == user_id, response.json())) - return cls(**membership) + return cls(_using=using, **membership) diff --git a/src/metabase/resources/segment.py b/src/metabase/resources/segment.py index 1cd1da7..5998b82 100644 --- a/src/metabase/resources/segment.py +++ b/src/metabase/resources/segment.py @@ -2,6 +2,7 @@ from typing import List +from metabase import Metabase from metabase.missing import MISSING from metabase.resource import CreateResource, GetResource, ListResource, UpdateResource @@ -26,16 +27,17 @@ class Segment(ListResource, CreateResource, GetResource, UpdateResource): created_at: str @classmethod - def list(cls) -> List[Segment]: - return super(Segment, cls).list() + def list(cls, using: Metabase) -> List[Segment]: + return super(Segment, cls).list(using=using) @classmethod - def get(cls, id: int) -> Segment: - return super(Segment, cls).get(id) + def get(cls, id: int, using: Metabase) -> Segment: + return super(Segment, cls).get(id, using=using) @classmethod def create( cls, + using: Metabase, name: str, table_id: int, definition: dict, @@ -43,6 +45,7 @@ def create( **kwargs ) -> Segment: return super(Segment, cls).create( + using=using, name=name, table_id=table_id, definition=definition, diff --git a/src/metabase/resources/table.py b/src/metabase/resources/table.py index 0910c69..7fdeea4 100644 --- a/src/metabase/resources/table.py +++ b/src/metabase/resources/table.py @@ -3,6 +3,7 @@ from enum import Enum from typing import Any, Dict, List +from metabase import Metabase from metabase.missing import MISSING from metabase.resource import GetResource, ListResource, Resource, UpdateResource from metabase.resources.field import Field @@ -60,14 +61,14 @@ class FieldOrder(str, Enum): smart = "smart" @classmethod - def list(cls) -> List[Table]: + def list(cls, using: Metabase) -> List[Table]: """Get all Tables.""" - return super(Table, cls).list() + return super(Table, cls).list(using=using) @classmethod - def get(cls, id: int) -> Table: + def get(cls, id: int, using: Metabase) -> Table: """Get Table with ID.""" - return super(Table, cls).get(id) + return super(Table, cls).get(id, using=using) def update( self, @@ -95,11 +96,9 @@ def update( def fks(self) -> List[dict]: """Get all foreign keys whose destination is a Field that belongs to this Table.""" - return ( - self.connection() - .get(self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}" + "/fks") - .json() - ) + return self._using.get( + self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}" + "/fks" + ).json() def query_metadata(self) -> Dict[str, Any]: """ @@ -112,23 +111,15 @@ def query_metadata(self) -> Dict[str, Any]: These options are provided for use in the Admin Edit Metadata page. """ - return ( - self.connection() - .get( - self.ENDPOINT - + f"/{getattr(self, self.PRIMARY_KEY)}" - + "/query_metadata" - ) - .json() - ) + return self._using.get( + self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}" + "/query_metadata" + ).json() def related(self) -> Dict[str, Any]: """Return related entities.""" - return ( - self.connection() - .get(self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}" + "/related") - .json() - ) + return self._using.get( + self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}" + "/related" + ).json() def discard_values(self): """ @@ -138,7 +129,7 @@ def discard_values(self): You must be a superuser to do this. """ - self.connection().post( + self._using.post( self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}" + "/discard_values" ) @@ -149,18 +140,21 @@ def rescan_values(self): You must be a superuser to do this. """ - self.connection().post( + self._using.post( self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}" + "/rescan_values" ) def fields(self) -> List[Field]: """Get all Fields associated with this Table..""" - return [Field(**field) for field in self.query_metadata().get("fields")] + return [ + Field(_using=self._using, **field) + for field in self.query_metadata().get("fields") + ] def dimensions(self) -> List[Dimension]: """Get all Dimensions associated with this Table.""" return [ - Dimension(id=id, **dimension) + Dimension(id=id, _using=self._using, **dimension) for id, dimension in self.query_metadata() .get("dimension_options", {}) .items() @@ -168,8 +162,14 @@ def dimensions(self) -> List[Dimension]: def metrics(self) -> List[Metric]: """Get all Metrics associated with this Table.""" - return [Metric(**metric) for metric in self.related().get("metrics")] + return [ + Metric(_using=self._using, **metric) + for metric in self.related().get("metrics") + ] def segments(self) -> List[Segment]: """Get all Segments associated with this Table.""" - return [Segment(**segment) for segment in self.related().get("segments")] + return [ + Segment(_using=self._using, **segment) + for segment in self.related().get("segments") + ] diff --git a/src/metabase/resources/user.py b/src/metabase/resources/user.py index 2cbacc1..3c09ae2 100644 --- a/src/metabase/resources/user.py +++ b/src/metabase/resources/user.py @@ -3,6 +3,7 @@ from datetime import datetime from typing import Any, Dict, List +from metabase import Metabase from metabase.missing import MISSING from metabase.resource import ( CreateResource, @@ -37,7 +38,7 @@ class User(ListResource, CreateResource, GetResource, UpdateResource, DeleteReso updated_at: datetime @classmethod - def list(cls) -> List[User]: + def list(cls, using: Metabase) -> List[User]: """ Fetch a list of Users. By default returns every active user but only active users. @@ -47,13 +48,16 @@ def list(cls) -> List[User]: Takes limit, offset for pagination. Takes query for filtering on first name, last name, email. Also takes group_id, which filters on group id. """ - response = cls.connection().get(cls.ENDPOINT) - records = [cls(**user) for user in response.json().get("data", [])] + response = using.get(cls.ENDPOINT) + records = [ + cls(_using=using, **user) for user in response.json().get("data", []) + ] return records @classmethod def create( cls, + using: Metabase, first_name: str, last_name: str, email: str, @@ -68,6 +72,7 @@ def create( You must be a superuser to do this. """ return super(User, cls).create( + using=using, first_name=first_name, last_name=last_name, email=email, @@ -107,7 +112,7 @@ def delete(self) -> None: def password(self, password: str, old_password: str): """Update a user’s password.""" - return self.connection().put( + return self._using.put( self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}" + "/password", json={"password": password, "old_password": old_password}, ) @@ -118,7 +123,7 @@ def send_invite(self): You must be a superuser to do this. """ - return self.connection().put( + return self._using.put( self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}" + "/send_invite" ) @@ -128,6 +133,6 @@ def reactivate(self): You must be a superuser to do this. """ - return self.connection().put( + return self._using.put( self.ENDPOINT + f"/{getattr(self, self.PRIMARY_KEY)}" + "/reactivate" ) diff --git a/tests/resources/test_card.py b/tests/resources/test_card.py new file mode 100644 index 0000000..92e2be4 --- /dev/null +++ b/tests/resources/test_card.py @@ -0,0 +1,145 @@ +from metabase import Card +from tests.helpers import IntegrationTestCase + + +class CardTests(IntegrationTestCase): + def setUp(self) -> None: + super(CardTests, self).setUp() + + def test_import(self): + """Ensure Card can be imported from Metabase.""" + from metabase import Card + + self.assertIsNotNone(Card(_using=None)) + + def test_list(self): + """Ensure Card.list() returns a list of Card instances.""" + # fixture + card = Card.create( + name="My Card", + dataset_query={ + "type": "query", + "query": { + "source-table": 2, + "aggregation": [["count"]], + "breakout": [["field", 12, {"temporal-unit": "month"}]], + }, + "database": 1, + }, + visualization_settings={ + "graph.dimensions": ["CREATED_AT"], + "graph.metrics": ["count"], + }, + display="line", + using=self.metabase, + ) + + cards = Card.list(using=self.metabase) + + self.assertIsInstance(cards, list) + self.assertTrue(len(cards) > 0) + self.assertTrue(all(isinstance(t, Card) for t in cards)) + + def test_get(self): + """Ensure Card.get() returns a Card instance for a given ID.""" + card = Card.get(1, using=self.metabase) + + self.assertIsInstance(card, Card) + self.assertEqual(1, card.id) + + def test_create(self): + """Ensure Card.create() creates a Card in Metabase and returns a Card instance.""" + card = Card.create( + name="My Card", + dataset_query={ + "type": "query", + "query": { + "source-table": 2, + "aggregation": [["count"]], + "breakout": [["field", 12, {"temporal-unit": "month"}]], + }, + "database": 1, + }, + visualization_settings={ + "graph.dimensions": ["CREATED_AT"], + "graph.metrics": ["count"], + }, + display="line", + using=self.metabase, + ) + + self.assertIsInstance(card, Card) + self.assertEqual("My Card", card.name) + self.assertEqual("line", card.display) + self.assertIsInstance( + Card.get(card.id, using=self.metabase), Card + ) # instance exists in Metabase + + # teardown + card.archive() + + def test_update(self): + """Ensure Card.update() updates an existing Card in Metabase.""" + # fixture + card = Card.create( + name="My Card", + dataset_query={ + "type": "query", + "query": { + "source-table": 2, + "aggregation": [["count"]], + "breakout": [["field", 12, {"temporal-unit": "month"}]], + }, + "database": 1, + }, + visualization_settings={ + "graph.dimensions": ["CREATED_AT"], + "graph.metrics": ["count"], + }, + display="line", + using=self.metabase, + ) + + card = Card.get(1, using=self.metabase) + + name = card.name + card.update(name="New Name") + + # assert local instance is mutated + self.assertEqual("New Name", card.name) + + # assert metabase object is mutated + t = Card.get(card.id, using=self.metabase) + self.assertEqual("New Name", t.name) + + # teardown + t.archive() + + def test_archive(self): + """Ensure Card.archive() deletes a Card in Metabase.""" + # fixture + card = Card.create( + name="My Card", + dataset_query={ + "type": "query", + "query": { + "source-table": 2, + "aggregation": [["count"]], + "breakout": [["field", 12, {"temporal-unit": "month"}]], + }, + "database": 1, + }, + visualization_settings={ + "graph.dimensions": ["CREATED_AT"], + "graph.metrics": ["count"], + }, + display="line", + using=self.metabase, + ) + self.assertIsInstance(card, Card) + + card.archive() + self.assertEqual(True, card.archived) + + c = Card.get(card.id, using=self.metabase) + self.assertEqual(True, c.archived) diff --git a/tests/resources/test_database.py b/tests/resources/test_database.py index a1afa13..db86415 100644 --- a/tests/resources/test_database.py +++ b/tests/resources/test_database.py @@ -11,11 +11,11 @@ def test_import(self): """Ensure Database can be imported from Metabase.""" from metabase import Database - self.assertIsNotNone(Database()) + self.assertIsNotNone(Database(_using=None)) def test_list(self): """Ensure Database.list() returns a list of Database instances.""" - databases = Database.list() + databases = Database.list(using=self.metabase) self.assertIsInstance(databases, list) self.assertTrue(len(databases) > 0) @@ -23,7 +23,7 @@ def test_list(self): def test_get(self): """Ensure Database.get() returns a Database instance for a given ID.""" - database = Database.get(1) + database = Database.get(1, using=self.metabase) self.assertIsInstance(database, Database) self.assertEqual(1, database.id) @@ -36,13 +36,14 @@ def test_create(self): details={ "db": "zip:/app/metabase.jar!/sample-dataset.db;USER=GUEST;PASSWORD=guest" }, + using=self.metabase, ) self.assertIsInstance(database, Database) self.assertEqual("Test", database.name) self.assertEqual("h2", database.engine) self.assertIsInstance( - Database.get(database.id), Database + Database.get(database.id, using=self.metabase), Database ) # instance exists in Metabase # teardown @@ -50,7 +51,7 @@ def test_create(self): def test_update(self): """Ensure Database.update() updates an existing Database in Metabase.""" - database = Database.get(1) + database = Database.get(1, using=self.metabase) name = database.name database.update(name="New Name") @@ -59,7 +60,7 @@ def test_update(self): self.assertEqual("New Name", database.name) # assert metabase object is mutated - t = Database.get(database.id) + t = Database.get(database.id, using=self.metabase) self.assertEqual("New Name", t.name) # teardown @@ -74,6 +75,7 @@ def test_delete(self): details={ "db": "zip:/app/metabase.jar!/sample-dataset.db;USER=GUEST;PASSWORD=guest" }, + using=self.metabase, ) self.assertIsInstance(database, Database) @@ -81,11 +83,11 @@ def test_delete(self): # assert metabase object is mutated with self.assertRaises(NotFoundError): - _ = Database.get(database.id) + _ = Database.get(database.id, using=self.metabase) def test_fields(self): """Ensure Database.fields() returns a list of Field instances.""" - database = Database.get(1) + database = Database.get(1, using=self.metabase) fields = database.fields() self.assertIsInstance(fields, list) @@ -94,7 +96,7 @@ def test_fields(self): def test_idfields(self): """Ensure Database.idfields() returns a list of Field instances.""" - database = Database.get(1) + database = Database.get(1, using=self.metabase) fields = database.idfields() self.assertIsInstance(fields, list) @@ -103,7 +105,7 @@ def test_idfields(self): def test_schemas(self): """Ensure Database.schemas() returns a list of strings.""" - database = Database.get(1) + database = Database.get(1, using=self.metabase) schemas = database.schemas() self.assertIsInstance(schemas, list) @@ -112,7 +114,7 @@ def test_schemas(self): def test_tables(self): """Ensure Database.tables() returns a list of Table instances.""" - database = Database.get(1) + database = Database.get(1, using=self.metabase) schema = database.schemas()[0] tables = database.tables(schema) @@ -122,28 +124,28 @@ def test_tables(self): def test_discard_values(self): """Ensure Database.discard_values() does not raise an error.""" - database = Database.get(1) + database = Database.get(1, using=self.metabase) response = database.discard_values() self.assertEqual(200, response.status_code) def test_rescan_values(self): """Ensure Database.rescan_values() does not raise an error.""" - database = Database.get(1) + database = Database.get(1, using=self.metabase) response = database.rescan_values() self.assertEqual(200, response.status_code) def test_sync(self): """Ensure Database.sync() does not raise an error.""" - database = Database.get(1) + database = Database.get(1, using=self.metabase) response = database.sync() self.assertEqual(200, response.status_code) def test_sync_schema(self): """Ensure Database.sync_schema() does not raise an error.""" - database = Database.get(1) + database = Database.get(1, using=self.metabase) response = database.sync_schema() self.assertEqual(200, response.status_code) diff --git a/tests/resources/test_dataset.py b/tests/resources/test_dataset.py index 232df67..e66a331 100644 --- a/tests/resources/test_dataset.py +++ b/tests/resources/test_dataset.py @@ -9,7 +9,7 @@ def test_import(self): """Ensure Metric can be imported from Metabase.""" from metabase import Dataset - self.assertIsNotNone(Dataset()) + self.assertIsNotNone(Dataset(_using=None)) def test_create(self): """Ensure Dataset.create() executes a query and returns a Dataset instance with the query results.""" @@ -21,6 +21,7 @@ def test_create(self): "breakout": [["field", 7, {"temporal-unit": "year"}]], "aggregation": [["count"]], }, + using=self.metabase, ) self.assertIsInstance(dataset, Dataset) self.assertIsInstance(dataset.data, Data) @@ -35,6 +36,7 @@ def test_to_pandas(self): "breakout": [["field", 7, {"temporal-unit": "year"}]], "aggregation": [["count"]], }, + using=self.metabase, ) df = dataset.to_pandas() self.assertIsInstance(df, pd.DataFrame) diff --git a/tests/resources/test_field.py b/tests/resources/test_field.py index 223a74c..36f4ae8 100644 --- a/tests/resources/test_field.py +++ b/tests/resources/test_field.py @@ -10,18 +10,18 @@ def test_import(self): """Ensure Field can be imported from Metabase.""" from metabase import Field - self.assertIsNotNone(Field()) + self.assertIsNotNone(Field(_using=None)) def test_get(self): """Ensure Field.get() returns a Field instance for a given ID.""" - field = Field.get(1) + field = Field.get(1, using=self.metabase) self.assertIsInstance(field, Field) self.assertEqual(1, field.id) def test_update(self): """Ensure Field.update() updates an existing Field in Metabase.""" - field = Field.get(1) + field = Field.get(1, using=self.metabase) display_name = field.display_name semantic_type = field.semantic_type @@ -32,7 +32,7 @@ def test_update(self): self.assertEqual(Field.SemanticType.zip_code, field.semantic_type) # assert metabase object is mutated - f = Field.get(field.id) + f = Field.get(field.id, using=self.metabase) self.assertEqual("New Name", f.display_name) self.assertEqual(Field.SemanticType.zip_code, f.semantic_type) @@ -41,7 +41,7 @@ def test_update(self): def test_related(self): """Ensure Field.related() returns a dict.""" - field = Field.get(1) + field = Field.get(1, using=self.metabase) related = field.related() self.assertIsInstance(related, dict) diff --git a/tests/resources/test_metric.py b/tests/resources/test_metric.py index b1b302b..bf0da86 100644 --- a/tests/resources/test_metric.py +++ b/tests/resources/test_metric.py @@ -5,7 +5,7 @@ class MetricTests(IntegrationTestCase): def tearDown(self) -> None: - metrics = Metric.list() + metrics = Metric.list(using=self.metabase) for metric in metrics: metric.archive() @@ -13,7 +13,7 @@ def test_import(self): """Ensure Metric can be imported from Metabase.""" from metabase import Metric - self.assertIsNotNone(Metric()) + self.assertIsNotNone(Metric(_using=None)) def test_list(self): """Ensure Metric.list returns a list of Metric instances.""" @@ -24,6 +24,7 @@ def test_list(self): definition={ "aggregation": [["count"]], }, + using=self.metabase, ) _ = Metric.create( name="My Metric", @@ -31,9 +32,10 @@ def test_list(self): definition={ "aggregation": [["count"]], }, + using=self.metabase, ) - metrics = Metric.list() + metrics = Metric.list(using=self.metabase) self.assertIsInstance(metrics, list) self.assertEqual(2, len(metrics)) @@ -51,15 +53,16 @@ def test_get(self): definition={ "aggregation": [["count"]], }, + using=self.metabase, ) self.assertIsInstance(metric, Metric) - m = Metric.get(metric.id) + m = Metric.get(metric.id, using=self.metabase) self.assertIsInstance(m, Metric) self.assertEqual(metric.id, m.id) with self.assertRaises(NotFoundError): - _ = Metric.get(12345) + _ = Metric.get(12345, using=self.metabase) def test_create(self): """Ensure Metric.create creates a Metric in Metabase and returns a Metric instance.""" @@ -69,6 +72,7 @@ def test_create(self): definition={ "aggregation": [["count"]], }, + using=self.metabase, ) self.assertIsInstance(metric, Metric) @@ -85,6 +89,7 @@ def test_update(self): definition={ "aggregation": [["count"]], }, + using=self.metabase, ) self.assertIsInstance(metric, Metric) @@ -95,7 +100,7 @@ def test_update(self): self.assertEqual("New Name", metric.name) # assert metabase object is mutated - m = Metric.get(metric.id) + m = Metric.get(metric.id, using=self.metabase) self.assertEqual("New Name", m.name) def test_archive(self): @@ -107,6 +112,7 @@ def test_archive(self): definition={ "aggregation": [["count"]], }, + using=self.metabase, ) self.assertIsInstance(metric, Metric) @@ -117,5 +123,5 @@ def test_archive(self): self.assertEqual(True, metric.archived) # assert metabase object is mutated - m = Metric.get(metric.id) + m = Metric.get(metric.id, using=self.metabase) self.assertEqual(True, m.archived) diff --git a/tests/resources/test_permission_group.py b/tests/resources/test_permission_group.py index 3f7c1c5..8f1e66b 100644 --- a/tests/resources/test_permission_group.py +++ b/tests/resources/test_permission_group.py @@ -5,7 +5,7 @@ class PermissionMembershipTests(IntegrationTestCase): def tearDown(self) -> None: - groups = PermissionGroup.list() + groups = PermissionGroup.list(using=self.metabase) for group in groups: if group.id not in (1, 2): # can't delete default groups @@ -15,11 +15,11 @@ def test_import(self): """Ensure PermissionGroup can be imported from Metabase.""" from metabase import PermissionGroup - self.assertIsNotNone(PermissionGroup()) + self.assertIsNotNone(PermissionGroup(_using=None)) def test_list(self): """Ensure PermissionGroup.list returns a list of PermissionGroup instances.""" - groups = PermissionGroup.list() + groups = PermissionGroup.list(using=self.metabase) self.assertIsInstance(groups, list) self.assertEqual(2, len(groups)) # there are 2 default groups in Metabase @@ -31,21 +31,19 @@ def test_get(self): raises a NotFoundError when it does not exist. """ # fixture - group = PermissionGroup.create(name="My Group") + group = PermissionGroup.create(name="My Group", using=self.metabase) self.assertIsInstance(group, PermissionGroup) - g = PermissionGroup.get(group.id) + g = PermissionGroup.get(group.id, using=self.metabase) self.assertIsInstance(g, PermissionGroup) self.assertEqual(group.id, g.id) with self.assertRaises(NotFoundError): - _ = PermissionGroup.get(12345) + _ = PermissionGroup.get(12345, using=self.metabase) def test_create(self): """Ensure PermissionGroup.create creates a Metric in Metabase and returns a PermissionGroup instance.""" - group = PermissionGroup.create( - name="My Group", - ) + group = PermissionGroup.create(name="My Group", using=self.metabase) self.assertIsInstance(group, PermissionGroup) self.assertEqual("My Group", group.name) @@ -53,9 +51,7 @@ def test_create(self): def test_update(self): """Ensure PermissionGroup.update updates an existing PermissionGroup in Metabase.""" # fixture - group = PermissionGroup.create( - name="My Group", - ) + group = PermissionGroup.create(name="My Group", using=self.metabase) self.assertIsInstance(group, PermissionGroup) self.assertEqual("My Group", group.name) @@ -65,15 +61,13 @@ def test_update(self): self.assertEqual("New Name", group.name) # assert metabase object is mutated - m = PermissionGroup.get(group.id) + m = PermissionGroup.get(group.id, using=self.metabase) self.assertEqual("New Name", m.name) def test_delete(self): """Ensure PermissionGroup.delete deletes a PermissionGroup in Metabase.""" # fixture - group = PermissionGroup.create( - name="My Metric", - ) + group = PermissionGroup.create(name="My Metric", using=self.metabase) self.assertIsInstance(group, PermissionGroup) @@ -81,4 +75,4 @@ def test_delete(self): # assert metabase object is mutated with self.assertRaises(NotFoundError): - _ = PermissionGroup.get(group.id) + _ = PermissionGroup.get(group.id, using=self.metabase) diff --git a/tests/resources/test_permission_membership.py b/tests/resources/test_permission_membership.py index 8a90151..dce5af8 100644 --- a/tests/resources/test_permission_membership.py +++ b/tests/resources/test_permission_membership.py @@ -4,13 +4,13 @@ class PermissionMembershipTests(IntegrationTestCase): def tearDown(self) -> None: - memberships = PermissionMembership.list() + memberships = PermissionMembership.list(using=self.metabase) for membership in memberships: if membership.group_id not in (1, 2): # can't delete memberships in the default groups membership.delete() - groups = PermissionGroup.list() + groups = PermissionGroup.list(using=self.metabase) for group in groups: if group.id not in (1, 2): # can't delete default groups @@ -20,19 +20,21 @@ def test_import(self): """Ensure PermissionMembership can be imported from Metabase.""" from metabase import PermissionMembership - self.assertIsNotNone(PermissionMembership()) + self.assertIsNotNone(PermissionMembership(_using=None)) def test_list(self): """Ensure PermissionMembership.list returns a list of PermissionMembership instances.""" - memberships = PermissionMembership.list() + memberships = PermissionMembership.list(using=self.metabase) self.assertIsInstance(memberships, list) self.assertTrue(len(memberships) > 0) self.assertTrue(all([isinstance(m, PermissionMembership) for m in memberships])) def test_create(self): """Ensure PermissionMembership.create creates a Metric in Metabase and returns a PermissionMembership instance.""" - group = PermissionGroup.create(name="My Group") - membership = PermissionMembership.create(group_id=group.id, user_id=1) + group = PermissionGroup.create(name="My Group", using=self.metabase) + membership = PermissionMembership.create( + group_id=group.id, user_id=1, using=self.metabase + ) self.assertIsInstance(membership, PermissionMembership) self.assertEqual(1, membership.user_id) @@ -40,17 +42,19 @@ def test_create(self): def test_delete(self): """Ensure PermissionMembership.delete deletes a PermissionMembership in Metabase.""" # fixture - group = PermissionGroup.create(name="My Group") - membership = PermissionMembership.create(group_id=group.id, user_id=1) + group = PermissionGroup.create(name="My Group", using=self.metabase) + membership = PermissionMembership.create( + group_id=group.id, user_id=1, using=self.metabase + ) self.assertIsInstance(membership, PermissionMembership) self.assertTrue( membership.membership_id - in [m.membership_id for m in PermissionMembership.list()] + in [m.membership_id for m in PermissionMembership.list(using=self.metabase)] ) membership.delete() self.assertFalse( membership.membership_id - in [m.membership_id for m in PermissionMembership.list()] + in [m.membership_id for m in PermissionMembership.list(using=self.metabase)] ) diff --git a/tests/resources/test_segment.py b/tests/resources/test_segment.py index 4dd7a55..d159514 100644 --- a/tests/resources/test_segment.py +++ b/tests/resources/test_segment.py @@ -5,7 +5,7 @@ class SegmentTests(IntegrationTestCase): def tearDown(self) -> None: - segments = Segment.list() + segments = Segment.list(using=self.metabase) for segment in segments: segment.archive() @@ -13,7 +13,7 @@ def test_import(self): """Ensure Segment can be imported from Metabase.""" from metabase import Segment - self.assertIsNotNone(Segment()) + self.assertIsNotNone(Segment(_using=None)) def test_list(self): """Ensure Segment.list returns a list of Segment instances.""" @@ -24,6 +24,7 @@ def test_list(self): definition={ "filter": ["=", ["field", 1, None], 0], }, + using=self.metabase, ) _ = Segment.create( name="My Segment", @@ -31,9 +32,10 @@ def test_list(self): definition={ "filter": ["=", ["field", 1, None], 0], }, + using=self.metabase, ) - segments = Segment.list() + segments = Segment.list(using=self.metabase) self.assertIsInstance(segments, list) self.assertEqual(2, len(segments)) @@ -51,15 +53,16 @@ def test_get(self): definition={ "filter": ["=", ["field", 1, None], 0], }, + using=self.metabase, ) self.assertIsInstance(segment, Segment) - m = Segment.get(segment.id) + m = Segment.get(segment.id, using=self.metabase) self.assertIsInstance(m, Segment) self.assertEqual(segment.id, m.id) with self.assertRaises(NotFoundError): - _ = Segment.get(12345) + _ = Segment.get(12345, using=self.metabase) def test_create(self): """Ensure Segment.create creates a Segment in Metabase and returns a Segment instance.""" @@ -69,6 +72,7 @@ def test_create(self): definition={ "filter": ["=", ["field", 1, None], 0], }, + using=self.metabase, ) self.assertIsInstance(segment, Segment) @@ -85,6 +89,7 @@ def test_update(self): definition={ "filter": ["=", ["field", 1, None], 0], }, + using=self.metabase, ) self.assertIsInstance(segment, Segment) @@ -95,7 +100,7 @@ def test_update(self): self.assertEqual("New Name", segment.name) # assert metabase object is mutated - m = Segment.get(segment.id) + m = Segment.get(segment.id, using=self.metabase) self.assertEqual("New Name", m.name) def test_archive(self): @@ -107,6 +112,7 @@ def test_archive(self): definition={ "filter": ["=", ["field", 1, None], 0], }, + using=self.metabase, ) self.assertIsInstance(segment, Segment) @@ -117,5 +123,5 @@ def test_archive(self): self.assertEqual(True, segment.archived) # assert metabase object is mutated - m = Segment.get(segment.id) + m = Segment.get(segment.id, using=self.metabase) self.assertEqual(True, m.archived) diff --git a/tests/resources/test_table.py b/tests/resources/test_table.py index 14ca8f7..230abc4 100644 --- a/tests/resources/test_table.py +++ b/tests/resources/test_table.py @@ -12,11 +12,11 @@ def test_import(self): """Ensure Table can be imported from Metabase.""" from metabase import Table - self.assertIsNotNone(Table()) + self.assertIsNotNone(Table(_using=None)) def test_list(self): """Ensure Table.list() returns a list of Table instances.""" - tables = Table.list() + tables = Table.list(using=self.metabase) self.assertIsInstance(tables, list) self.assertTrue(len(tables) > 0) @@ -24,14 +24,14 @@ def test_list(self): def test_get(self): """Ensure Table.get() returns a Table instance for a given ID.""" - table = Table.get(1) + table = Table.get(1, using=self.metabase) self.assertIsInstance(table, Table) self.assertEqual(1, table.id) def test_update(self): """Ensure Table.update() updates an existing Table in Metabase.""" - table = Table.get(1) + table = Table.get(1, using=self.metabase) display_name = table.display_name table.update(display_name="New Name") @@ -40,7 +40,7 @@ def test_update(self): self.assertEqual("New Name", table.display_name) # assert metabase object is mutated - t = Table.get(table.id) + t = Table.get(table.id, using=self.metabase) self.assertEqual("New Name", t.display_name) # teardown @@ -48,7 +48,7 @@ def test_update(self): def test_foreign_keys(self): """Ensure Table.fks() returns a list of foreign keys as dict.""" - table = Table.get(1) + table = Table.get(1, using=self.metabase) fks = table.fks() self.assertIsInstance(fks, list) @@ -57,14 +57,14 @@ def test_foreign_keys(self): def test_query_metadata(self): """Ensure Table.query_metadata() returns a dict.""" - table = Table.get(1) + table = Table.get(1, using=self.metabase) query_metadata = table.query_metadata() self.assertIsInstance(query_metadata, dict) def test_related(self): """Ensure Table.related() returns a dict.""" - table = Table.get(1) + table = Table.get(1, using=self.metabase) related = table.related() self.assertIsInstance(related, dict) @@ -79,7 +79,7 @@ def test_rescan_values(self): def test_fields(self): """Ensure Table.fields() returns a list of Field instances.""" - table = Table.get(1) + table = Table.get(1, using=self.metabase) fields = table.fields() self.assertIsInstance(fields, list) @@ -88,7 +88,7 @@ def test_fields(self): def test_dimensions(self): """Ensure Table.dimensions() returns a list of Dimension instances.""" - table = Table.get(1) + table = Table.get(1, using=self.metabase) dimensions = table.dimensions() self.assertIsInstance(dimensions, list) @@ -97,7 +97,7 @@ def test_dimensions(self): def test_metrics(self): """Ensure Table.metrics() returns a list of Metric instances.""" - table = Table.get(1) + table = Table.get(1, using=self.metabase) metrics = table.metrics() self.assertIsInstance(metrics, list) @@ -110,6 +110,7 @@ def test_metrics(self): definition={ "aggregation": [["count"]], }, + using=self.metabase, ) metrics = table.metrics() @@ -122,7 +123,7 @@ def test_metrics(self): def test_segments(self): """Ensure Table.segments() returns a list of Segment instances.""" - table = Table.get(1) + table = Table.get(1, using=self.metabase) segments = table.segments() self.assertIsInstance(segments, list) @@ -135,6 +136,7 @@ def test_segments(self): definition={ "filter": ["=", ["field", 1, None], 0], }, + using=self.metabase, ) segments = table.segments() diff --git a/tests/resources/test_user.py b/tests/resources/test_user.py index 4328471..d818c38 100644 --- a/tests/resources/test_user.py +++ b/tests/resources/test_user.py @@ -7,7 +7,7 @@ class UserTests(IntegrationTestCase): def tearDown(self) -> None: - users = User.list() + users = User.list(using=self.metabase) for user in users: if user.id != 1: user.delete() @@ -16,7 +16,7 @@ def test_import(self): """Ensure User can be imported from Metabase.""" from metabase import User - self.assertIsNotNone(User()) + self.assertIsNotNone(User(_using=None)) def test_get(self): """ @@ -29,21 +29,26 @@ def test_get(self): last_name="Test", email=f"{randint(2, 10000)}@example.com", password="example123", + using=self.metabase, ) self.assertIsInstance(user, User) - u = User.get(user.id) + u = User.get(user.id, using=self.metabase) self.assertIsInstance(u, User) self.assertEqual(user.id, u.id) with self.assertRaises(NotFoundError): - _ = User.get(12345) + _ = User.get(12345, using=self.metabase) def test_create(self): """Ensure User.create() creates a User in Metabase and returns a User instance.""" email = f"{randint(2, 10000)}@example.com" user = User.create( - first_name="Test", last_name="Test", email=email, password="example123" + first_name="Test", + last_name="Test", + email=email, + password="example123", + using=self.metabase, ) self.assertIsInstance(user, User) @@ -59,6 +64,7 @@ def test_update(self): last_name="Test", email=f"{randint(2, 10000)}@example.com", password="example123", + using=self.metabase, ) self.assertIsInstance(user, User) @@ -69,7 +75,7 @@ def test_update(self): self.assertEqual("Test1", user.first_name) # assert metabase object is mutated - u = User.get(user.id) + u = User.get(user.id, using=self.metabase) self.assertEqual("Test1", u.first_name) def test_delete(self): @@ -80,6 +86,7 @@ def test_delete(self): last_name="Test", email=f"{randint(2, 10000)}@example.com", password="example123", + using=self.metabase, ) self.assertIsInstance(user, User) @@ -87,4 +94,4 @@ def test_delete(self): # assert metabase object is mutated with self.assertRaises(NotFoundError): - _ = User.get(user.id) + _ = User.get(user.id, using=self.metabase) diff --git a/tests/test_metabase.py b/tests/test_metabase.py index b9a7cfc..57e0631 100644 --- a/tests/test_metabase.py +++ b/tests/test_metabase.py @@ -1,20 +1,11 @@ from unittest import TestCase from metabase import Metabase - +from metabase.exceptions import AuthenticationError from tests.helpers import IntegrationTestCase class MetabaseTests(TestCase): - def test_singleton(self): - """Ensure Metabase acts as a singleton; the same instance is always returned when instantiated.""" - metabase = Metabase(host="", user="", password="") - metabase1 = Metabase() - - self.assertEqual(metabase, metabase1) - self.assertEqual(metabase.host, metabase1.host) - self.assertEqual(Metabase(), Metabase()) - def test_host(self): """Ensure Metabase.host adds https:// and trims trailing /.""" metabase = Metabase(host="example.com/", user="", password="") @@ -27,11 +18,18 @@ def test_host(self): def test_token(self): """Ensure Metabase.token returns Metabase._token if not None, else gets a new token.""" - metabase = Metabase(host="example.co.", user="", password="", token="123") + metabase = Metabase(host="example.com", user="", password="", token="123") self.assertEqual(metabase.token, "123") # TODO: add test case when token is None + def test_token_invalid_auth(self): + """Ensure Metabase.token raises AuthenticationException is the user or password is invalid.""" + metabase = Metabase(host="http://0.0.0.0:3000", user="", password="") + + with self.assertRaises(AuthenticationError): + _ = metabase.token + def test_headers(self): """Ensure Metabase.headers returns a dictionary with the token.""" metabase = Metabase(host="example.com", user="", password="", token="123") diff --git a/tests/test_resource.py b/tests/test_resource.py index 055ef9e..7eab526 100644 --- a/tests/test_resource.py +++ b/tests/test_resource.py @@ -2,7 +2,6 @@ from requests import HTTPError -from metabase import Metabase from metabase.exceptions import NotFoundError from metabase.missing import MISSING from metabase.resource import ( @@ -19,7 +18,7 @@ class ResourceTests(IntegrationTestCase): def test_resource_initializes_all_attributes(self): """Ensure Resource accepts arbitrary attributes when initializing an instance.""" - resource = Resource(a="a", b="b") + resource = Resource(a="a", b="b", _using=None) self.assertEqual("a", resource.a) self.assertEqual("b", resource.b) @@ -31,17 +30,12 @@ def test_resource_initializes_all_attributes(self): def test_repr(self): """Ensure Resource repr prints all class attributes with the PRIMARY_KEY first, if any.""" - resource = Resource(a="a", b="b", id=1) + resource = Resource(a="a", b="b", id=1, _using=None) self.assertEqual("Resource(id=1, a=a, b=b)", resource.__repr__()) resource.PRIMARY_KEY = None self.assertEqual("Resource(a=a, b=b, id=1)", resource.__repr__()) - def test_connection(self): - """Ensure Resource.connection() returns an instance of Metabase.""" - resource = Resource() - self.assertIsInstance(resource.connection(), Metabase) - class ListResourceTests(IntegrationTestCase): def test_list(self): @@ -51,7 +45,7 @@ class Setting(ListResource): ENDPOINT = "/api/setting" PRIMARY_KEY = None - settings = Setting.list() + settings = Setting.list(using=self.metabase) self.assertIsInstance(settings, list) self.assertTrue(all([isinstance(s, Setting) for s in settings])) @@ -63,7 +57,7 @@ def test_get(self): class User(GetResource): ENDPOINT = "/api/user" - user = User.get(1) + user = User.get(1, using=self.metabase) self.assertIsInstance(user, User) def test_get_404(self): @@ -73,7 +67,7 @@ class User(GetResource): ENDPOINT = "/api/user" with self.assertRaises(NotFoundError): - user = User.get(1234) + user = User.get(1234, using=self.metabase) class CreateResourceTests(IntegrationTestCase): @@ -83,9 +77,13 @@ def test_create(self): class Collection(CreateResource, GetResource): ENDPOINT = "/api/collection" - collection = Collection.create(name="My Collection", color="#123456") + collection = Collection.create( + name="My Collection", color="#123456", using=self.metabase + ) self.assertIsInstance(collection, Collection) - self.assertIsNotNone(Collection.get(collection.id)) # metabase was updated + self.assertIsNotNone( + Collection.get(collection.id, using=self.metabase) + ) # metabase was updated class UpdateResourceTests(IntegrationTestCase): @@ -96,15 +94,19 @@ class Collection(CreateResource, GetResource, UpdateResource): ENDPOINT = "/api/collection" # fixture - collection = Collection.create(name="My Collection", color="#123456") + collection = Collection.create( + name="My Collection", color="#123456", using=self.metabase + ) self.assertIsInstance(collection, Collection) - self.assertIsNotNone(Collection.get(collection.id)) + self.assertIsNotNone(Collection.get(collection.id, using=self.metabase)) collection.update(name="My New Collection") self.assertEqual("My New Collection", collection.name) # metabase was updated - self.assertEqual("My New Collection", Collection.get(collection.id).name) + self.assertEqual( + "My New Collection", Collection.get(collection.id, using=self.metabase).name + ) def test_update_missing(self): """Ensure UpdateResource.update() ignores arguments equal to MISSING.""" @@ -121,7 +123,7 @@ class Collection(UpdateResource): for kwargs, expected in test_matrix: with patch("metabase.resource.Metabase.put") as mock: try: - Collection(id=1).update(**kwargs) + Collection(id=1, _using=self.metabase).update(**kwargs) except HTTPError: pass @@ -139,12 +141,12 @@ class Group(CreateResource, GetResource, DeleteResource): ENDPOINT = "/api/permissions/group" PRIMARY_KEY = "id" - group = Group.create(name="My Group 4") + group = Group.create(name="My Group 4", using=self.metabase) self.assertIsNotNone(group) - self.assertIsNotNone(Group.get(group.id)) + self.assertIsNotNone(Group.get(group.id, using=self.metabase)) group.delete() with self.assertRaises(NotFoundError): - Group.get(group.id) + Group.get(group.id, using=self.metabase)