Skip to content

Commit

Permalink
More unit tests
Browse files Browse the repository at this point in the history
  • Loading branch information
gouline committed Jan 24, 2024
1 parent d12b078 commit 86f1a17
Show file tree
Hide file tree
Showing 9 changed files with 159 additions and 22 deletions.
14 changes: 4 additions & 10 deletions dbtmetabase/_exposures.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,10 @@
from pathlib import Path
from typing import Iterable, Mapping, MutableMapping, MutableSequence, Optional, Tuple

import yaml

from dbtmetabase.metabase import Metabase

from .errors import ArgumentError
from .format import Filter, YAMLDumper, safe_description, safe_name
from .format import Filter, dump_yaml, safe_description, safe_name
from .manifest import Manifest

_RESOURCE_VERSION = 2
Expand Down Expand Up @@ -379,16 +377,12 @@ def __write_exposures(
exps_sorted = sorted(exps_unwrapped, key=itemgetter("name"))

with open(path, "w", encoding="utf-8") as f:
yaml.dump(
{
dump_yaml(
data={
"version": _RESOURCE_VERSION,
"exposures": exps_sorted,
},
f,
Dumper=YAMLDumper,
default_flow_style=False,
allow_unicode=True,
sort_keys=False,
stream=f,
)

@dc.dataclass
Expand Down
8 changes: 4 additions & 4 deletions dbtmetabase/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ def export_models(
ctx = self.__Context()
success = True

database_id = self.metabase.find_database(name=metabase_database)
if not database_id:
database = self.metabase.find_database(name=metabase_database)
if not database:
raise MetabaseStateError(f"Database not found: {metabase_database}")

models = self.__filtered_models(
Expand All @@ -70,14 +70,14 @@ def export_models(
skip_sources=skip_sources,
)

self.metabase.sync_database_schema(database_id)
self.metabase.sync_database_schema(database["id"])

deadline = int(time.time()) + sync_timeout
synced = False
while not synced:
time.sleep(self.__SYNC_PERIOD)

tables = self.__get_tables(database_id)
tables = self.__get_tables(database["id"])

synced = True
for model in models:
Expand Down
23 changes: 20 additions & 3 deletions dbtmetabase/format.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import re
from logging.handlers import RotatingFileHandler
from pathlib import Path
from typing import MutableSequence, Optional, Sequence
from typing import Any, MutableSequence, Optional, Sequence, TextIO

import yaml
from rich.logging import RichHandler
Expand Down Expand Up @@ -37,7 +37,7 @@ def _norm(x: str) -> str:
return x.upper()


class YAMLDumper(yaml.Dumper):
class _YAMLDumper(yaml.Dumper):
"""Custom YAML dumper for uniform formatting."""

def increase_indent(self, flow=False, indentless=False):
Expand All @@ -54,6 +54,23 @@ def __eq__(self, other: object) -> bool:
NullValue = _NullValue()


def dump_yaml(data: Any, stream: TextIO):
"""Uniform way to dump object to YAML file.
Args:
data (Any): Payload.
stream (TextIO): Text file handle.
"""
yaml.dump(
data,
stream,
Dumper=_YAMLDumper,
default_flow_style=False,
allow_unicode=True,
sort_keys=False,
)


def setup_logging(level: int, path: Optional[Path] = None):
"""Basic logger configuration for the CLI.
Expand Down Expand Up @@ -118,4 +135,4 @@ def safe_description(text: Optional[str]) -> str:
Returns:
str: Sanitized string with escaped Jinja syntax.
"""
return re.sub(r"{{(.*)}}", r"\1", text or "")
return re.sub(r"{{(.*)}}", r"(\1)", text or "")
17 changes: 15 additions & 2 deletions dbtmetabase/metabase.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,16 +89,19 @@ def _api(

return response_json

def find_database(self, name: str) -> Optional[str]:
def find_database(self, name: str) -> Optional[Mapping]:
"""Finds database by name attribute or returns none."""
for api_database in list(self._api("get", "/api/database")):
if api_database["name"].upper() == name.upper():
return api_database["id"]
return api_database
return None

def sync_database_schema(self, uid: str):
"""Triggers schema sync on a database."""
self._api("post", f"/api/database/{uid}/sync_schema")

def get_database_metadata(self, uid: str) -> Mapping:
"""Retrieves metadata for all tables and fields in a database, including hidden ones."""
return dict(
self._api(
method="get",
Expand All @@ -108,9 +111,11 @@ def get_database_metadata(self, uid: str) -> Mapping:
)

def get_tables(self) -> Sequence[Mapping]:
"""Retrieves all tables for all databases."""
return list(self._api("get", "/api/table"))

def get_collections(self, exclude_personal: bool) -> Sequence[Mapping]:
"""Retrieves all collections and optionally filters out personal collections."""
results = list(
self._api(
method="get",
Expand All @@ -127,6 +132,7 @@ def get_collection_items(
uid: str,
models: Sequence[str],
) -> Sequence[Mapping]:
"""Retrieves collection items of specific types (e.g. card, dashboard, collection)."""
results = list(
self._api(
method="get",
Expand All @@ -138,18 +144,23 @@ def get_collection_items(
return results

def get_card(self, uid: str) -> Mapping:
"""Retrieves card (known as question in Metabase UI)."""
return dict(self._api("get", f"/api/card/{uid}"))

def format_card_url(self, uid: str) -> str:
"""Formats URL link to a card (known as question in Metabase UI)."""
return f"{self.url}/card/{uid}"

def get_dashboard(self, uid: str) -> Mapping:
"""Retrieves dashboard."""
return dict(self._api("get", f"/api/dashboard/{uid}"))

def format_dashboard_url(self, uid: str) -> str:
"""Formats URL link to a dashboard."""
return f"{self.url}/dashboard/{uid}"

def find_user(self, uid: str) -> Optional[Mapping]:
"""Finds user by ID or returns none."""
try:
return dict(self._api("get", f"/api/user/{uid}"))
except requests.exceptions.HTTPError as error:
Expand All @@ -159,7 +170,9 @@ def find_user(self, uid: str) -> Optional[Mapping]:
raise

def update_table(self, uid: str, body: Mapping):
"""Posts update to an existing table."""
self._api("put", f"/api/table/{uid}", json=body)

def update_field(self, uid: str, body: Mapping):
"""Posts an update to an existing table field."""
self._api("put", f"/api/field/{uid}", json=body)
1 change: 1 addition & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from dbtmetabase.format import setup_logging

from .test_exposures import *
from .test_format import *
from .test_models import *

setup_logging(level=logging.DEBUG, path=None)
34 changes: 33 additions & 1 deletion tests/_common.py → tests/_core.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import json
import unittest
from pathlib import Path
from typing import Any, Dict, Mapping, Optional, Sequence
from typing import Any, Dict, Mapping, Optional

from dbtmetabase.core import DbtMetabase
from dbtmetabase.format import NullValue
Expand Down Expand Up @@ -49,6 +49,38 @@ class TestCore(unittest.TestCase):
def setUp(self):
self.c = MockDbtMetabase()

def test_metabase_find_database(self):
db = self.c.metabase.find_database(name="unit_testing")
assert db
self.assertEqual(2, db["id"])
self.assertIsNone(self.c.metabase.find_database(name="foo"))

def test_metabase_get_collections(self):
excluded = self.c.metabase.get_collections(exclude_personal=True)
self.assertEqual(3, len(excluded))

included = self.c.metabase.get_collections(exclude_personal=False)
self.assertEqual(4, len(included))

def test_metabase_get_collection_items(self):
cards = self.c.metabase.get_collection_items(
uid="3",
models=("card",),
)
self.assertEqual({"card"}, {item["model"] for item in cards})

dashboards = self.c.metabase.get_collection_items(
uid="3",
models=("dashboard",),
)
self.assertEqual({"dashboard"}, {item["model"] for item in dashboards})

both = self.c.metabase.get_collection_items(
uid="3",
models=("card", "dashboard"),
)
self.assertEqual({"card", "dashboard"}, {item["model"] for item in both})

def test_manifest_reader(self):
self.assertEqual(
self.c.manifest.read_models(),
Expand Down
2 changes: 1 addition & 1 deletion tests/test_exposures.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import yaml

from ._common import FIXTURES_PATH, TMP_PATH, TestCore
from ._core import FIXTURES_PATH, TMP_PATH, TestCore


class TestExposures(TestCore):
Expand Down
80 changes: 80 additions & 0 deletions tests/test_format.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import unittest
from pathlib import Path

from dbtmetabase.format import Filter, NullValue, dump_yaml, safe_description, safe_name


class TestFormat(unittest.TestCase):
def test_filter(self):
self.assertTrue(
Filter(
include=("alpHa", "bRavo"),
).match("Alpha")
)
self.assertTrue(Filter().match("Alpha"))
self.assertTrue(Filter().match(""))
self.assertFalse(
Filter(
include=("alpHa", "bRavo"),
exclude=("alpha",),
).match("Alpha")
)
self.assertFalse(
Filter(
exclude=("alpha",),
).match("Alpha")
)

def test_null_value(self):
self.assertIsNotNone(NullValue)
self.assertFalse(NullValue)

def test_safe_name(self):
self.assertEqual(
"somebody_s_2_collections_",
safe_name("Somebody's 2 collections!"),
)
self.assertEqual(
"somebody_s_2_collections_",
safe_name("somebody_s_2_collections_"),
)
self.assertEqual("", safe_name(""))

def test_safe_description(self):
self.assertEqual(
"Depends on\n\nQuestion ( #2 )!",
safe_description("Depends on\n\nQuestion {{ #2 }}!"),
)
self.assertEqual(
"Depends on\n\nQuestion ( #2 )!",
safe_description("Depends on\n\nQuestion ( #2 )!"),
)
self.assertEqual(
"Depends on\n\nQuestion { #2 }!",
safe_description("Depends on\n\nQuestion { #2 }!"),
)

def test_dump_yaml(self):
path = Path("tests") / "tmp" / "test_dump_yaml.yml"
with open(path, "w", encoding="utf-8") as f:
dump_yaml(
data={
"root": {
"attr1": "val1\nend",
"attr2": ["val2", "val3"],
},
},
stream=f,
)
with open(path, "r", encoding="utf-8") as f:
self.assertEqual(
"""root:
attr1: 'val1
end'
attr2:
- val2
- val3
""",
f.read(),
)
2 changes: 1 addition & 1 deletion tests/test_models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# pylint: disable=protected-access,no-member

from ._common import TestCore
from ._core import TestCore


class TestModels(TestCore):
Expand Down

0 comments on commit 86f1a17

Please sign in to comment.