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
35 changes: 35 additions & 0 deletions .github/workflows/main.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: "Main workflow"
on:
push:
branches:
- main
pull_request:

jobs:
Pipeline:
runs-on: ubuntu-latest
container: python:3.12

steps:
- name: Check out Git repository
uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v4
with:
version: "0.5.8"
enable-cache: true
cache-suffix: "optional-suffix"
cache-dependency-glob: "pyproject.toml"

- name: Set up Python
run: uv python install 3.12

- name: Install requirements
run: make requirements

- name: Checks
run: make checks

- name: Tests
run: make test
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,6 @@ cython_debug/

# PyPI configuration file
.pypirc

# local scripts
local_scripts
55 changes: 55 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# globals
VERSION := $(shell uvx --from=toml-cli toml get --toml-path=pyproject.toml project.version)

define PRINT_HELP_PYSCRIPT
import re, sys

print("Please use 'make <target>' where <target> is one of\n")
for line in sys.stdin:
match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line)
if match:
target, help = match.groups()
print("%-20s %s" % (target, help))
print("\nCheck the Makefile for more information")
endef
export PRINT_HELP_PYSCRIPT

.PHONY: help
.DEFAULT_GOAL := help
help:
@python3 -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST)

install-uv:
@echo "Installing uv package manager..."
curl -LsSf https://astral.sh/uv/install.sh | sh

requirements: ## install dependencies
@echo "Installing project dependencies..."
uv sync --all-extras --dev

apply-style:
@echo "Applying style..."
uv run ruff check --select I --fix --unsafe-fixes
uv run ruff format

style-check:
@echo "Running checks..."
uv run ruff check --fix

type-check:
@echo "Running type checks..."
uv run mypy cubejs

test:
@echo "Running tests..."
uv run pytest tests/

checks: style-check type-check ## run all code checks

test:
@echo "Running tests..."
uv run pytest tests/

.PHONY: version
version: ## package version
@echo '${VERSION}'
21 changes: 21 additions & 0 deletions cubejs/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""CubeJS client package."""

from cubejs.client import get_measures
from cubejs.errors import ContinueWaitError
from cubejs.model import (
CubeJSAuth,
CubeJSRequest,
CubeJSResponse,
Filter,
TimeDimension,
)

__all__ = [
"get_measures",
"ContinueWaitError",
"CubeJSAuth",
"CubeJSRequest",
"CubeJSResponse",
"TimeDimension",
"Filter",
]
74 changes: 74 additions & 0 deletions cubejs/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""CubeJS client."""

import httpx
import tenacity
from loguru import logger

from cubejs.errors import (
AuthorizationError,
ContinueWaitError,
RequestError,
ServerError,
UnexpectedResponseError,
)
from cubejs.model import CubeJSAuth, CubeJSRequest, CubeJSResponse


def _error_handler(response: httpx.Response) -> None:
"""Handle errors from CubeJS server.

According to CubeJS docs the expected responses are:
200 - success
400 - request error
403 - authorization error
500 - server error

Anything other than 200 is unexpected and will raise an error.

"""
if response.status_code == 403:
raise AuthorizationError(response.text)
if response.status_code == 400:
raise RequestError(response.text)
if "Continue wait" in response.text:
raise ContinueWaitError()
if response.status_code == 500:
raise ServerError(response.text)
if response.status_code != 200:
raise UnexpectedResponseError(response.text)


@tenacity.retry(
retry=tenacity.retry_if_exception_type(ContinueWaitError),
wait=tenacity.wait_exponential(multiplier=2, min=1, max=30),
stop=tenacity.stop_after_attempt(5),
)
async def get_measures(auth: CubeJSAuth, request: CubeJSRequest) -> CubeJSResponse:
"""Get measures from cubejs.

Args:
auth: cubejs auth.
request: definition of measures you want to fetch from the semantic layer.

Returns:
cubejs response with requested measures.

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"Getting measures from {auth.host}")
url = f"{auth.host}/cubejs-api/v1/load"
headers = {"Authorization": auth.token}
request_payload = {"query": request.model_dump(by_alias=True, exclude_none=True)}
logger.debug(f"Query payload: {request_payload}")
async with httpx.AsyncClient(timeout=60) as client:
response = await client.post(url=url, json=request_payload, headers=headers)
_error_handler(response)
cube_js_response = CubeJSResponse(**response.json())
logger.debug("CubeJS response succesfully received!")
return cube_js_response
51 changes: 51 additions & 0 deletions cubejs/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""Errors expected from CubeJS server."""


class ContinueWaitError(Exception):
"""Raised when CubeJS responds with 'Continue wait' message."""

def __init__(self) -> None:
pass

def __str__(self) -> str:
return "CubeJS query is not ready yet, continue waiting..."


class ServerError(Exception):
"""Raised when CubeJS responds with an error."""

def __init__(self, message: str) -> None:
self.message = message

def __str__(self) -> str:
return f"CubeJS server error: {self.message}"


class AuthorizationError(Exception):
"""Raised when CubeJS responds with an authorization error."""

def __init__(self, message: str) -> None:
self.message = message

def __str__(self) -> str:
return f"CubeJS authorization error: {self.message}"


class RequestError(Exception):
"""Raised when CubeJS responds with 400 error."""

def __init__(self, message: str) -> None:
self.message = message

def __str__(self) -> str:
return f"CubeJS 400 request error: {self.message}"


class UnexpectedResponseError(Exception):
"""Raised when CubeJS responds with an unexpected response code."""

def __init__(self, message: str) -> None:
self.message = message

def __str__(self) -> str:
return f"CubeJS unexpected response: {self.message}"
88 changes: 88 additions & 0 deletions cubejs/model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""Data model."""

from pydantic import BaseModel, Field


class TimeDimension(BaseModel):
"""Time dimension section of a cubejs request.

Args:
dimension: column name to use as time reference.
granularity: granularity to transform the timestamp.
date_range: date range to filter the query.

"""

dimension: str
granularity: str | None = None
date_range: list[str] | str | None = Field(
default=None, serialization_alias="dateRange"
)

class Config: # noqa: D106
exclude_none = True
populate_by_name = True


class Filter(BaseModel):
"""Filter section of a cubejs request.

Args:
member: member to filter by.
operator: operator to apply.
values: values to filter by.

"""

member: str
operator: str
values: list[str]


class CubeJSRequest(BaseModel):
"""CubeJS request definition.

Args:
measures: list of measures.
time_dimensions: time dimensions to aggregate measures by.
dimensions: dimensions to group by.
segments: segments to filter by.
filters: other filters to apply.
order: order records in response by.
limit: limit the number of records in response.

"""

measures: list[str]
time_dimensions: list[TimeDimension] | None = Field(
serialization_alias="timeDimensions", default=None
)
dimensions: list[str] | None = None
segments: list[str] | None = None
filters: list[Filter] | None = None
order: dict[str, str] | None = None
limit: int | None = None


class CubeJSAuth(BaseModel):
"""CubeJS auth configuration.

Args:
token: cubejs token.
host: cubejs cloud host.

"""

token: str
host: str


class CubeJSResponse(BaseModel):
"""CubeJS response.

Args:
data: cubejs response data as a list of dictionaries.

"""

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