Skip to content

Commit 4d0327a

Browse files
authored
✨ add first modules (#1)
1 parent fa425be commit 4d0327a

11 files changed

Lines changed: 1134 additions & 0 deletions

File tree

.github/workflows/main.yaml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
name: "Main workflow"
2+
on:
3+
push:
4+
branches:
5+
- main
6+
pull_request:
7+
8+
jobs:
9+
Pipeline:
10+
runs-on: ubuntu-latest
11+
container: python:3.12
12+
13+
steps:
14+
- name: Check out Git repository
15+
uses: actions/checkout@v4
16+
17+
- name: Install uv
18+
uses: astral-sh/setup-uv@v4
19+
with:
20+
version: "0.5.8"
21+
enable-cache: true
22+
cache-suffix: "optional-suffix"
23+
cache-dependency-glob: "pyproject.toml"
24+
25+
- name: Set up Python
26+
run: uv python install 3.12
27+
28+
- name: Install requirements
29+
run: make requirements
30+
31+
- name: Checks
32+
run: make checks
33+
34+
- name: Tests
35+
run: make test

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,3 +169,6 @@ cython_debug/
169169

170170
# PyPI configuration file
171171
.pypirc
172+
173+
# local scripts
174+
local_scripts

Makefile

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# globals
2+
VERSION := $(shell uvx --from=toml-cli toml get --toml-path=pyproject.toml project.version)
3+
4+
define PRINT_HELP_PYSCRIPT
5+
import re, sys
6+
7+
print("Please use 'make <target>' where <target> is one of\n")
8+
for line in sys.stdin:
9+
match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line)
10+
if match:
11+
target, help = match.groups()
12+
print("%-20s %s" % (target, help))
13+
print("\nCheck the Makefile for more information")
14+
endef
15+
export PRINT_HELP_PYSCRIPT
16+
17+
.PHONY: help
18+
.DEFAULT_GOAL := help
19+
help:
20+
@python3 -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST)
21+
22+
install-uv:
23+
@echo "Installing uv package manager..."
24+
curl -LsSf https://astral.sh/uv/install.sh | sh
25+
26+
requirements: ## install dependencies
27+
@echo "Installing project dependencies..."
28+
uv sync --all-extras --dev
29+
30+
apply-style:
31+
@echo "Applying style..."
32+
uv run ruff check --select I --fix --unsafe-fixes
33+
uv run ruff format
34+
35+
style-check:
36+
@echo "Running checks..."
37+
uv run ruff check --fix
38+
39+
type-check:
40+
@echo "Running type checks..."
41+
uv run mypy cubejs
42+
43+
test:
44+
@echo "Running tests..."
45+
uv run pytest tests/
46+
47+
checks: style-check type-check ## run all code checks
48+
49+
test:
50+
@echo "Running tests..."
51+
uv run pytest tests/
52+
53+
.PHONY: version
54+
version: ## package version
55+
@echo '${VERSION}'

cubejs/__init__.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"""CubeJS client package."""
2+
3+
from cubejs.client import get_measures
4+
from cubejs.errors import ContinueWaitError
5+
from cubejs.model import (
6+
CubeJSAuth,
7+
CubeJSRequest,
8+
CubeJSResponse,
9+
Filter,
10+
TimeDimension,
11+
)
12+
13+
__all__ = [
14+
"get_measures",
15+
"ContinueWaitError",
16+
"CubeJSAuth",
17+
"CubeJSRequest",
18+
"CubeJSResponse",
19+
"TimeDimension",
20+
"Filter",
21+
]

cubejs/client.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""CubeJS client."""
2+
3+
import httpx
4+
import tenacity
5+
from loguru import logger
6+
7+
from cubejs.errors import (
8+
AuthorizationError,
9+
ContinueWaitError,
10+
RequestError,
11+
ServerError,
12+
UnexpectedResponseError,
13+
)
14+
from cubejs.model import CubeJSAuth, CubeJSRequest, CubeJSResponse
15+
16+
17+
def _error_handler(response: httpx.Response) -> None:
18+
"""Handle errors from CubeJS server.
19+
20+
According to CubeJS docs the expected responses are:
21+
200 - success
22+
400 - request error
23+
403 - authorization error
24+
500 - server error
25+
26+
Anything other than 200 is unexpected and will raise an error.
27+
28+
"""
29+
if response.status_code == 403:
30+
raise AuthorizationError(response.text)
31+
if response.status_code == 400:
32+
raise RequestError(response.text)
33+
if "Continue wait" in response.text:
34+
raise ContinueWaitError()
35+
if response.status_code == 500:
36+
raise ServerError(response.text)
37+
if response.status_code != 200:
38+
raise UnexpectedResponseError(response.text)
39+
40+
41+
@tenacity.retry(
42+
retry=tenacity.retry_if_exception_type(ContinueWaitError),
43+
wait=tenacity.wait_exponential(multiplier=2, min=1, max=30),
44+
stop=tenacity.stop_after_attempt(5),
45+
)
46+
async def get_measures(auth: CubeJSAuth, request: CubeJSRequest) -> CubeJSResponse:
47+
"""Get measures from cubejs.
48+
49+
Args:
50+
auth: cubejs auth.
51+
request: definition of measures you want to fetch from the semantic layer.
52+
53+
Returns:
54+
cubejs response with requested measures.
55+
56+
Raises:
57+
AuthorizationError: if the request is not authorized.
58+
RequestError: if the request is invalid.
59+
ContinueWaitError: if the request is not ready yet.
60+
ServerError: if the server is not available.
61+
UnexpectedResponseError: if the response is unexpected.
62+
63+
"""
64+
logger.debug(f"Getting measures from {auth.host}")
65+
url = f"{auth.host}/cubejs-api/v1/load"
66+
headers = {"Authorization": auth.token}
67+
request_payload = {"query": request.model_dump(by_alias=True, exclude_none=True)}
68+
logger.debug(f"Query payload: {request_payload}")
69+
async with httpx.AsyncClient(timeout=60) as client:
70+
response = await client.post(url=url, json=request_payload, headers=headers)
71+
_error_handler(response)
72+
cube_js_response = CubeJSResponse(**response.json())
73+
logger.debug("CubeJS response succesfully received!")
74+
return cube_js_response

cubejs/errors.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Errors expected from CubeJS server."""
2+
3+
4+
class ContinueWaitError(Exception):
5+
"""Raised when CubeJS responds with 'Continue wait' message."""
6+
7+
def __init__(self) -> None:
8+
pass
9+
10+
def __str__(self) -> str:
11+
return "CubeJS query is not ready yet, continue waiting..."
12+
13+
14+
class ServerError(Exception):
15+
"""Raised when CubeJS responds with an error."""
16+
17+
def __init__(self, message: str) -> None:
18+
self.message = message
19+
20+
def __str__(self) -> str:
21+
return f"CubeJS server error: {self.message}"
22+
23+
24+
class AuthorizationError(Exception):
25+
"""Raised when CubeJS responds with an authorization error."""
26+
27+
def __init__(self, message: str) -> None:
28+
self.message = message
29+
30+
def __str__(self) -> str:
31+
return f"CubeJS authorization error: {self.message}"
32+
33+
34+
class RequestError(Exception):
35+
"""Raised when CubeJS responds with 400 error."""
36+
37+
def __init__(self, message: str) -> None:
38+
self.message = message
39+
40+
def __str__(self) -> str:
41+
return f"CubeJS 400 request error: {self.message}"
42+
43+
44+
class UnexpectedResponseError(Exception):
45+
"""Raised when CubeJS responds with an unexpected response code."""
46+
47+
def __init__(self, message: str) -> None:
48+
self.message = message
49+
50+
def __str__(self) -> str:
51+
return f"CubeJS unexpected response: {self.message}"

cubejs/model.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""Data model."""
2+
3+
from pydantic import BaseModel, Field
4+
5+
6+
class TimeDimension(BaseModel):
7+
"""Time dimension section of a cubejs request.
8+
9+
Args:
10+
dimension: column name to use as time reference.
11+
granularity: granularity to transform the timestamp.
12+
date_range: date range to filter the query.
13+
14+
"""
15+
16+
dimension: str
17+
granularity: str | None = None
18+
date_range: list[str] | str | None = Field(
19+
default=None, serialization_alias="dateRange"
20+
)
21+
22+
class Config: # noqa: D106
23+
exclude_none = True
24+
populate_by_name = True
25+
26+
27+
class Filter(BaseModel):
28+
"""Filter section of a cubejs request.
29+
30+
Args:
31+
member: member to filter by.
32+
operator: operator to apply.
33+
values: values to filter by.
34+
35+
"""
36+
37+
member: str
38+
operator: str
39+
values: list[str]
40+
41+
42+
class CubeJSRequest(BaseModel):
43+
"""CubeJS request definition.
44+
45+
Args:
46+
measures: list of measures.
47+
time_dimensions: time dimensions to aggregate measures by.
48+
dimensions: dimensions to group by.
49+
segments: segments to filter by.
50+
filters: other filters to apply.
51+
order: order records in response by.
52+
limit: limit the number of records in response.
53+
54+
"""
55+
56+
measures: list[str]
57+
time_dimensions: list[TimeDimension] | None = Field(
58+
serialization_alias="timeDimensions", default=None
59+
)
60+
dimensions: list[str] | None = None
61+
segments: list[str] | None = None
62+
filters: list[Filter] | None = None
63+
order: dict[str, str] | None = None
64+
limit: int | None = None
65+
66+
67+
class CubeJSAuth(BaseModel):
68+
"""CubeJS auth configuration.
69+
70+
Args:
71+
token: cubejs token.
72+
host: cubejs cloud host.
73+
74+
"""
75+
76+
token: str
77+
host: str
78+
79+
80+
class CubeJSResponse(BaseModel):
81+
"""CubeJS response.
82+
83+
Args:
84+
data: cubejs response data as a list of dictionaries.
85+
86+
"""
87+
88+
data: list[dict[str, str | int | float | None]]

0 commit comments

Comments
 (0)