Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion cubejs/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
"""CubeJS client package."""

from cubejs.client import get_measures
from cubejs.client import get_measures, list_cubes
from cubejs.errors import ContinueWaitError
from cubejs.model import (
CubeJSAuth,
CubeJSMetaCube,
CubeJSMetaDimension,
CubeJSMetaMeasure,
CubeJSMetaResponse,
CubeJSMetaSegment,
CubeJSRequest,
CubeJSResponse,
Filter,
Expand All @@ -16,8 +21,14 @@

__all__ = [
"get_measures",
"list_cubes",
"ContinueWaitError",
"CubeJSAuth",
"CubeJSMetaCube",
"CubeJSMetaDimension",
"CubeJSMetaMeasure",
"CubeJSMetaResponse",
"CubeJSMetaSegment",
"CubeJSRequest",
"CubeJSResponse",
"TimeDimension",
Expand Down
36 changes: 35 additions & 1 deletion cubejs/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
ServerError,
UnexpectedResponseError,
)
from cubejs.model import CubeJSAuth, CubeJSRequest, CubeJSResponse
from cubejs.model import CubeJSAuth, CubeJSMetaResponse, CubeJSRequest, CubeJSResponse


def _error_handler(response: httpx.Response) -> None:
Expand Down Expand Up @@ -76,3 +76,37 @@ async def get_measures(auth: CubeJSAuth, request: CubeJSRequest) -> CubeJSRespon
cube_js_response = CubeJSResponse(**response.json())
logger.debug("CubeJS response succesfully received!")
return cube_js_response


@tenacity.retry(
retry=tenacity.retry_if_exception_type(RetryableError),
wait=tenacity.wait_exponential(multiplier=2, min=1, max=30),
stop=tenacity.stop_after_attempt(5),
)
async def list_cubes(auth: CubeJSAuth) -> CubeJSMetaResponse:
"""List cubes and views available in cubejs metadata endpoint.

Args:
auth: cubejs auth.

Returns:
CubeJS metadata response with available cubes and views.

Raises:
AuthorizationError: if the request is not authorized.
RequestError: if the request is invalid.
ContinueWaitError: if the request is not ready yet.
ServerError: if the server is not available.
UnexpectedResponseError: if the response is unexpected.

"""
logger.debug(f"Listing cubes from {auth.host}")
url = f"{auth.host}/cubejs-api/v1/meta"
headers = {"Authorization": auth.token}
async with httpx.AsyncClient(timeout=60) as client:
response = await client.get(url=url, headers=headers)
_error_handler(response)

meta_response = CubeJSMetaResponse(**response.json())
logger.debug(f"Retrieved {len(meta_response.cubes)} cubes/views from metadata")
return meta_response
72 changes: 72 additions & 0 deletions cubejs/model.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
"""Data model."""

from __future__ import annotations

from enum import Enum
from typing import Any

from pydantic import BaseModel, Field, model_validator

Expand Down Expand Up @@ -234,3 +237,72 @@ class CubeJSResponse(BaseModel):
"""

data: list[dict[str, str | int | float | None]]


class CubeJSMetaCube(BaseModel):
"""CubeJS metadata entry for a cube or view."""

name: str
title: str
type: str | None = None
meta: dict[str, Any] | None = None
connected_component: int | None = Field(default=None, alias="connectedComponent")
measures: list[CubeJSMetaMeasure] = Field(default_factory=list)
dimensions: list[CubeJSMetaDimension] = Field(default_factory=list)
segments: list[CubeJSMetaSegment] = Field(default_factory=list)
Comment thread
rafaelleinio marked this conversation as resolved.

class Config: # noqa: D106
populate_by_name = True


class CubeJSMetaMeasure(BaseModel):
"""CubeJS measure metadata."""

name: str
title: str
type: str
short_title: str | None = Field(default=None, alias="shortTitle")
alias_name: str | None = Field(default=None, alias="aliasName")
agg_type: str | None = Field(default=None, alias="aggType")
drill_members: list[str] = Field(default_factory=list, alias="drillMembers")

class Config: # noqa: D106
populate_by_name = True


class CubeJSMetaDimension(BaseModel):
"""CubeJS dimension metadata."""

name: str
title: str
type: str
short_title: str | None = Field(default=None, alias="shortTitle")
alias_name: str | None = Field(default=None, alias="aliasName")
suggest_filter_values: bool | None = Field(
default=None, alias="suggestFilterValues"
)

class Config: # noqa: D106
populate_by_name = True


class CubeJSMetaSegment(BaseModel):
"""CubeJS segment metadata."""

name: str
title: str
short_title: str | None = Field(default=None, alias="shortTitle")

class Config: # noqa: D106
populate_by_name = True


class CubeJSMetaResponse(BaseModel):
"""CubeJS metadata response.

Args:
cubes: cubejs metadata entries for cubes and views.

"""

cubes: list[CubeJSMetaCube] = Field(default_factory=list)
Comment on lines +258 to +308
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

awesome!

88 changes: 88 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@

from cubejs import (
CubeJSAuth,
CubeJSMetaResponse,
CubeJSRequest,
CubeJSResponse,
Filter,
TimeDimension,
errors,
get_measures,
list_cubes,
)
from cubejs.client import _error_handler
from cubejs.model import FilterOperators, Granularity, OrderBy
Expand Down Expand Up @@ -88,6 +90,92 @@ async def test_get_metrics(httpx_mock):
)


@pytest.mark.asyncio
async def test_list_cubes(httpx_mock):
# arrange
httpx_mock.add_response(
method="GET",
url="https://host/cubejs-api/v1/meta",
json={
"cubes": [
{
"name": "Users",
"title": "Users",
"meta": {
"someKey": "someValue",
"nested": {"someKey": "someValue"},
},
"connectedComponent": 1,
"measures": [
{
"name": "users.count",
"title": "Users Count",
"shortTitle": "Count",
"aliasName": "users.count",
"type": "number",
"aggType": "count",
"drillMembers": [
"users.id",
"users.city",
"users.createdAt",
],
}
],
"dimensions": [
{
"name": "users.city",
"title": "Users City",
"type": "string",
"aliasName": "users.city",
"shortTitle": "City",
"suggestFilterValues": True,
}
],
"segments": [],
}
]
},
)

# act
output = await list_cubes(auth=CubeJSAuth(token="token", host="https://host"))

# assert
assert isinstance(output, CubeJSMetaResponse)
assert len(output.cubes) == 1
assert output.cubes[0].name == "Users"
assert output.cubes[0].title == "Users"
assert output.cubes[0].meta["someKey"] == "someValue"
assert output.cubes[0].meta["nested"]["someKey"] == "someValue"
assert output.cubes[0].connected_component == 1
assert output.cubes[0].measures[0].short_title == "Count"
assert output.cubes[0].measures[0].alias_name == "users.count"
assert output.cubes[0].measures[0].agg_type == "count"
assert output.cubes[0].measures[0].drill_members == [
"users.id",
"users.city",
"users.createdAt",
]
assert output.cubes[0].dimensions[0].short_title == "City"
assert output.cubes[0].dimensions[0].suggest_filter_values is True
assert output.cubes[0].segments == []


@pytest.mark.asyncio
async def test_list_cubes_raises_on_request_error(httpx_mock):
# arrange
httpx_mock.add_response(
method="GET",
url="https://host/cubejs-api/v1/meta",
status_code=400,
text="bad request",
)

# act / assert
with pytest.raises(errors.RequestError):
await list_cubes(auth=CubeJSAuth(token="token", host="https://host"))


def test_error_handler():
# act
with pytest.raises(errors.AuthorizationError) as auth_error:
Expand Down
73 changes: 73 additions & 0 deletions tests/test_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from pydantic import ValidationError

from cubejs import (
CubeJSMetaResponse,
CubeJSRequest,
Filter,
FilterOperators,
Expand Down Expand Up @@ -287,3 +288,75 @@ def test_request_serialization(self):
serialized = request.model_dump(by_alias=True)
assert "timeDimensions" in serialized
assert "dateRange" in serialized["timeDimensions"][0]


class TestCubeJSMetaResponse:
"""Test suite for CubeJSMetaResponse model."""

def test_full_meta_schema(self):
"""Test parsing complete metadata schema with aliases."""
response = CubeJSMetaResponse(
cubes=[
{
"name": "Users",
"title": "Users",
"meta": {
"someKey": "someValue",
"nested": {"someKey": "someValue"},
},
"connectedComponent": 1,
"measures": [
{
"name": "users.count",
"title": "Users Count",
"shortTitle": "Count",
"aliasName": "users.count",
"type": "number",
"aggType": "count",
"drillMembers": [
"users.id",
"users.city",
"users.createdAt",
],
}
],
"dimensions": [
{
"name": "users.city",
"title": "Users City",
"type": "string",
"aliasName": "users.city",
"shortTitle": "City",
"suggestFilterValues": True,
}
],
"segments": [],
}
]
)

assert response.cubes[0].connected_component == 1
assert response.cubes[0].measures[0].short_title == "Count"
assert response.cubes[0].measures[0].alias_name == "users.count"
assert response.cubes[0].measures[0].agg_type == "count"
assert response.cubes[0].measures[0].drill_members == [
"users.id",
"users.city",
"users.createdAt",
]
assert response.cubes[0].dimensions[0].short_title == "City"
assert response.cubes[0].dimensions[0].suggest_filter_values is True

serialized = response.model_dump(by_alias=True)
assert serialized["cubes"][0]["connectedComponent"] == 1
assert serialized["cubes"][0]["measures"][0]["shortTitle"] == "Count"
assert serialized["cubes"][0]["measures"][0]["aliasName"] == "users.count"
assert serialized["cubes"][0]["measures"][0]["aggType"] == "count"
assert serialized["cubes"][0]["measures"][0]["drillMembers"] == [
"users.id",
"users.city",
"users.createdAt",
]
assert (
serialized["cubes"][0]["dimensions"][0]["suggestFilterValues"] is True
)
Loading
Loading