relay-metis 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- relay_metis-0.1.0/.github/workflows/ci.yaml +41 -0
- relay_metis-0.1.0/.github/workflows/publish.yaml +43 -0
- relay_metis-0.1.0/.gitignore +5 -0
- relay_metis-0.1.0/LICENSE +21 -0
- relay_metis-0.1.0/PKG-INFO +108 -0
- relay_metis-0.1.0/README.md +97 -0
- relay_metis-0.1.0/pyproject.toml +29 -0
- relay_metis-0.1.0/src/metis/__init__.py +5 -0
- relay_metis-0.1.0/src/metis/client.py +502 -0
- relay_metis-0.1.0/tests/test_client.py +117 -0
- relay_metis-0.1.0/uv.lock +566 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
timeout-minutes: 10
|
|
12
|
+
strategy:
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.11", "3.12", "3.13"]
|
|
15
|
+
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
|
|
19
|
+
- name: Install uv
|
|
20
|
+
uses: astral-sh/setup-uv@v5
|
|
21
|
+
with:
|
|
22
|
+
enable-cache: true
|
|
23
|
+
cache-dependency-glob: "uv.lock"
|
|
24
|
+
|
|
25
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
26
|
+
run: uv python install ${{ matrix.python-version }}
|
|
27
|
+
|
|
28
|
+
- name: Install dependencies
|
|
29
|
+
run: uv sync --python ${{ matrix.python-version }}
|
|
30
|
+
|
|
31
|
+
- name: Ruff format
|
|
32
|
+
run: uv run ruff format --check --diff .
|
|
33
|
+
|
|
34
|
+
- name: Ruff check
|
|
35
|
+
run: uv run ruff check .
|
|
36
|
+
|
|
37
|
+
- name: Tests
|
|
38
|
+
run: uv run pytest tests/ -v
|
|
39
|
+
|
|
40
|
+
- name: Build sanity check
|
|
41
|
+
run: uv build
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Publishes relay-metis to PyPI via trusted publishing (OIDC) — no stored token.
|
|
2
|
+
#
|
|
3
|
+
# One-off setup on PyPI before the first release: add a "pending trusted
|
|
4
|
+
# publisher" for project `relay-metis` with owner `relaytech-co`, repository
|
|
5
|
+
# `metis-python-client`, workflow `publish.yaml`, environment `pypi`. The
|
|
6
|
+
# first successful publish claims the project name atomically.
|
|
7
|
+
#
|
|
8
|
+
# Releasing = bump `version` in pyproject.toml, merge, then create a GitHub
|
|
9
|
+
# Release (tag `v<version>`); this workflow does the rest.
|
|
10
|
+
name: Publish to PyPI
|
|
11
|
+
|
|
12
|
+
on:
|
|
13
|
+
release:
|
|
14
|
+
types: [published]
|
|
15
|
+
|
|
16
|
+
jobs:
|
|
17
|
+
publish:
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
timeout-minutes: 10
|
|
20
|
+
environment: pypi
|
|
21
|
+
permissions:
|
|
22
|
+
id-token: write
|
|
23
|
+
contents: read
|
|
24
|
+
|
|
25
|
+
steps:
|
|
26
|
+
- uses: actions/checkout@v4
|
|
27
|
+
|
|
28
|
+
- name: Install uv
|
|
29
|
+
uses: astral-sh/setup-uv@v5
|
|
30
|
+
|
|
31
|
+
- name: Build wheel and sdist
|
|
32
|
+
run: uv build
|
|
33
|
+
|
|
34
|
+
- name: Check tag matches package version
|
|
35
|
+
run: |
|
|
36
|
+
version=$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
|
|
37
|
+
if [ "v${version}" != "${GITHUB_REF_NAME}" ]; then
|
|
38
|
+
echo "Release tag ${GITHUB_REF_NAME} does not match pyproject version v${version}" >&2
|
|
39
|
+
exit 1
|
|
40
|
+
fi
|
|
41
|
+
|
|
42
|
+
- name: Publish
|
|
43
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Relay Technologies
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: relay-metis
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight Python client for Metis, Relay's Cube-backed metrics layer.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: pandas>=2.0
|
|
9
|
+
Requires-Dist: requests>=2.31
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# metis
|
|
13
|
+
|
|
14
|
+
Lightweight Python client for Metis, Relay's Cube-backed metrics layer. Queries a Metis
|
|
15
|
+
view and returns a typed, annotated pandas DataFrame — the metrics you see in dashboards,
|
|
16
|
+
in a notebook, computed the same way.
|
|
17
|
+
|
|
18
|
+
## Setup
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
export METIS_API_URL="https://<deployment>.cubecloudapp.dev" # /cubejs-api/v1 optional
|
|
22
|
+
export METIS_API_KEY="<pre-minted Cube API token>"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Both can also be passed explicitly: `MetisClient(url=..., api_key=...)` (constructor
|
|
26
|
+
arguments win over the environment).
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from datetime import date
|
|
32
|
+
from metis import MetisClient
|
|
33
|
+
|
|
34
|
+
client = MetisClient()
|
|
35
|
+
|
|
36
|
+
df = client.query(
|
|
37
|
+
"orders",
|
|
38
|
+
measures=["order_count", "revenue_sum"],
|
|
39
|
+
dimensions=["status"],
|
|
40
|
+
time_dimension="created_at",
|
|
41
|
+
granularity="day",
|
|
42
|
+
date_range=(date(2026, 8, 1), date(2026, 8, 31)),
|
|
43
|
+
filters={"is_priority": True},
|
|
44
|
+
order={"created_at": "asc"},
|
|
45
|
+
)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Member names are unprefixed and scoped to the view; result columns come back
|
|
49
|
+
prefix-stripped. Discovery:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
client.meta() # one row per view/member: kind, type, title, description
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Unknown views/members fail before the query is sent, with did-you-mean suggestions.
|
|
56
|
+
|
|
57
|
+
### Filters
|
|
58
|
+
|
|
59
|
+
The mapping form is sugar for equals/in (`None` means "is not set"):
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
filters = {"status": ["shipped", "delivered"], "is_priority": True}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Anything richer takes raw Cube filter dicts (member names still unprefixed):
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
filters = [{"member": "revenue_sum", "operator": "gt", "values": ["1000"]}]
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Dtypes
|
|
72
|
+
|
|
73
|
+
Model-driven, never inferred from whichever values a result happens to contain:
|
|
74
|
+
|
|
75
|
+
| Member | dtype |
|
|
76
|
+
| --- | --- |
|
|
77
|
+
| count / count_distinct measures | `Int64` (nullable) |
|
|
78
|
+
| all other numeric measures | `float64` |
|
|
79
|
+
| numeric dimensions, integer-typed at source | `Int64` (nullable) |
|
|
80
|
+
| numeric dimensions, decimal-typed at source | `float64` |
|
|
81
|
+
| booleans | `boolean` (nullable) |
|
|
82
|
+
| time members | `datetime64` |
|
|
83
|
+
|
|
84
|
+
Numeric dimensions are classified from the raw serialisation: integer columns always
|
|
85
|
+
arrive as bare digit strings and classify correctly. Known caveat: a decimal-typed
|
|
86
|
+
dimension (FLOAT64 or NUMERIC) whose result values are all whole also arrives as bare
|
|
87
|
+
digits and reads as `Int64` until a decimal value appears. Override any column
|
|
88
|
+
per-call: `dtypes={"weight_grams": "float64"}`.
|
|
89
|
+
|
|
90
|
+
Query provenance (payload, Cube annotation, request time) is attached under
|
|
91
|
+
`df.attrs["metis"]`.
|
|
92
|
+
|
|
93
|
+
### Escape hatch
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
client.load(
|
|
97
|
+
{"query": {...}, "cache": "must-revalidate"}
|
|
98
|
+
) # raw Cube REST payload -> raw dict
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Behaviour notes
|
|
102
|
+
|
|
103
|
+
- Cache mode is hard-coded to `must-revalidate`: serves cached data while current,
|
|
104
|
+
never stale data.
|
|
105
|
+
- Long-running queries are polled (Cube "Continue wait") within a wall-clock budget of
|
|
106
|
+
120s by default — raise `total_wait_budget_seconds` for heavy queries.
|
|
107
|
+
- Transient errors retry up to 3 times with backoff; auth failures and query errors
|
|
108
|
+
fail immediately and loudly.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# metis
|
|
2
|
+
|
|
3
|
+
Lightweight Python client for Metis, Relay's Cube-backed metrics layer. Queries a Metis
|
|
4
|
+
view and returns a typed, annotated pandas DataFrame — the metrics you see in dashboards,
|
|
5
|
+
in a notebook, computed the same way.
|
|
6
|
+
|
|
7
|
+
## Setup
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
export METIS_API_URL="https://<deployment>.cubecloudapp.dev" # /cubejs-api/v1 optional
|
|
11
|
+
export METIS_API_KEY="<pre-minted Cube API token>"
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Both can also be passed explicitly: `MetisClient(url=..., api_key=...)` (constructor
|
|
15
|
+
arguments win over the environment).
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from datetime import date
|
|
21
|
+
from metis import MetisClient
|
|
22
|
+
|
|
23
|
+
client = MetisClient()
|
|
24
|
+
|
|
25
|
+
df = client.query(
|
|
26
|
+
"orders",
|
|
27
|
+
measures=["order_count", "revenue_sum"],
|
|
28
|
+
dimensions=["status"],
|
|
29
|
+
time_dimension="created_at",
|
|
30
|
+
granularity="day",
|
|
31
|
+
date_range=(date(2026, 8, 1), date(2026, 8, 31)),
|
|
32
|
+
filters={"is_priority": True},
|
|
33
|
+
order={"created_at": "asc"},
|
|
34
|
+
)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Member names are unprefixed and scoped to the view; result columns come back
|
|
38
|
+
prefix-stripped. Discovery:
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
client.meta() # one row per view/member: kind, type, title, description
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Unknown views/members fail before the query is sent, with did-you-mean suggestions.
|
|
45
|
+
|
|
46
|
+
### Filters
|
|
47
|
+
|
|
48
|
+
The mapping form is sugar for equals/in (`None` means "is not set"):
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
filters = {"status": ["shipped", "delivered"], "is_priority": True}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Anything richer takes raw Cube filter dicts (member names still unprefixed):
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
filters = [{"member": "revenue_sum", "operator": "gt", "values": ["1000"]}]
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Dtypes
|
|
61
|
+
|
|
62
|
+
Model-driven, never inferred from whichever values a result happens to contain:
|
|
63
|
+
|
|
64
|
+
| Member | dtype |
|
|
65
|
+
| --- | --- |
|
|
66
|
+
| count / count_distinct measures | `Int64` (nullable) |
|
|
67
|
+
| all other numeric measures | `float64` |
|
|
68
|
+
| numeric dimensions, integer-typed at source | `Int64` (nullable) |
|
|
69
|
+
| numeric dimensions, decimal-typed at source | `float64` |
|
|
70
|
+
| booleans | `boolean` (nullable) |
|
|
71
|
+
| time members | `datetime64` |
|
|
72
|
+
|
|
73
|
+
Numeric dimensions are classified from the raw serialisation: integer columns always
|
|
74
|
+
arrive as bare digit strings and classify correctly. Known caveat: a decimal-typed
|
|
75
|
+
dimension (FLOAT64 or NUMERIC) whose result values are all whole also arrives as bare
|
|
76
|
+
digits and reads as `Int64` until a decimal value appears. Override any column
|
|
77
|
+
per-call: `dtypes={"weight_grams": "float64"}`.
|
|
78
|
+
|
|
79
|
+
Query provenance (payload, Cube annotation, request time) is attached under
|
|
80
|
+
`df.attrs["metis"]`.
|
|
81
|
+
|
|
82
|
+
### Escape hatch
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
client.load(
|
|
86
|
+
{"query": {...}, "cache": "must-revalidate"}
|
|
87
|
+
) # raw Cube REST payload -> raw dict
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Behaviour notes
|
|
91
|
+
|
|
92
|
+
- Cache mode is hard-coded to `must-revalidate`: serves cached data while current,
|
|
93
|
+
never stale data.
|
|
94
|
+
- Long-running queries are polled (Cube "Continue wait") within a wall-clock budget of
|
|
95
|
+
120s by default — raise `total_wait_budget_seconds` for heavy queries.
|
|
96
|
+
- Transient errors retry up to 3 times with backoff; auth failures and query errors
|
|
97
|
+
fail immediately and loudly.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "relay-metis"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Lightweight Python client for Metis, Relay's Cube-backed metrics layer."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.11"
|
|
9
|
+
dependencies = [
|
|
10
|
+
"pandas>=2.0",
|
|
11
|
+
"requests>=2.31",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[build-system]
|
|
15
|
+
requires = ["hatchling"]
|
|
16
|
+
build-backend = "hatchling.build"
|
|
17
|
+
|
|
18
|
+
[tool.hatch.build.targets.wheel]
|
|
19
|
+
packages = ["src/metis"]
|
|
20
|
+
|
|
21
|
+
[dependency-groups]
|
|
22
|
+
dev = [
|
|
23
|
+
"pytest>=8.0",
|
|
24
|
+
"ruff>=0.16.6",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[tool.ruff]
|
|
28
|
+
target-version = "py311"
|
|
29
|
+
src = ["src", "tests"]
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
from metis.client import MetisClient as MetisClient
|
|
2
|
+
from metis.client import MetisConfigError as MetisConfigError
|
|
3
|
+
from metis.client import MetisError as MetisError
|
|
4
|
+
from metis.client import MetisQueryError as MetisQueryError
|
|
5
|
+
from metis.client import MetisTimeoutError as MetisTimeoutError
|