Skip to content

Commit b8ebf15

Browse files
feat(backend-native): Allow importing modules from python files within cube.py and globals.py (#9490)
* feat(backend-native): Allow importing modules from python files within cube.py * update tests * Allow importing modules from python files within globals.py * tests * another test * Add docs * more tests * extract sys path to fn --------- Co-authored-by: Igor Lukanin <[email protected]>
1 parent 940c30f commit b8ebf15

File tree

9 files changed

+159
-21
lines changed

9 files changed

+159
-21
lines changed

docs/pages/product/configuration.mdx

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,3 @@
1-
---
2-
redirect_from:
3-
- /configuration/overview
4-
---
5-
61
# Overview
72

83
Cube is configured via [environment variables][link-env-vars] and
@@ -103,6 +98,9 @@ module.exports = {
10398

10499
Both ways are equivalent; when in doubt, use Python.
105100

101+
You can read more about [Python][ref-python] and [JavaScript][ref-javascript] support
102+
in the dynamic data modeling section of the documentation.
103+
106104
### Cube Core
107105

108106
When using Docker, ensure that the configuration file and your [data model
@@ -112,7 +110,8 @@ Docker container.
112110
### Cube Cloud
113111

114112
You can edit the configuration file by going into <Btn>Development Mode</Btn>
115-
and navigating to the <Btn>Data Model</Btn> page.
113+
and navigating to <Btn>[Data Model][ref-data-model]</Btn> or <Btn>[Visual
114+
Model][ref-visual-model]</Btn> pages.
116115

117116
## Runtimes and dependencies
118117

@@ -178,4 +177,8 @@ mode does the following:
178177
[link-docker-env-vars]: https://docs.docker.com/compose/environment-variables/set-environment-variables/
179178
[ref-mls]: /product/auth/member-level-security
180179
[link-current-python-version]: https://github.com/cube-js/cube/blob/master/packages/cubejs-docker/latest.Dockerfile#L13
181-
[link-current-nodejs-version]: https://github.com/cube-js/cube/blob/master/packages/cubejs-docker/latest.Dockerfile#L1
180+
[link-current-nodejs-version]: https://github.com/cube-js/cube/blob/master/packages/cubejs-docker/latest.Dockerfile#L1
181+
[ref-data-model]: /product/workspace/data-model
182+
[ref-visual-model]: /product/workspace/visual-model
183+
[ref-python]: /product/data-modeling/dynamic/jinja#python
184+
[ref-javascript]: /product/data-modeling/dynamic/javascript

docs/pages/product/data-modeling/dynamic/jinja.mdx

Lines changed: 57 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -155,13 +155,20 @@ class SafeString(str):
155155

156156
## Python
157157

158-
You can declare and invoke Python functions from within a Jinja template. This
159-
allows the reuse of existing code to generate data models. Cube uses Python 3.9 to execute Python code.
160-
It also installs packages listed in the `requirements.txt` with pip on the startup.
158+
### Template context
161159

162-
These helper functions must be located in `model/globals.py` file or explicitly loaded from the YAML files.
163-
In the following example, we declare a function called `load_data()` which will load data from a remote
164-
API endpoint. We will then use the function to generate a data model in Cube.
160+
You can use Python to declare functions that can be invoked and variables that can be
161+
referenced from within a Jinja template. These functions and variables must be defined
162+
in `model/globals.py` file and registered in the `TemplateContext` instance.
163+
164+
<ReferenceBox>
165+
166+
See the [`TemplateContext` reference][ref-cube-template-context] for more details.
167+
168+
</ReferenceBox>
169+
170+
In the following example, we declare a function called `load_data` that supposedly loads
171+
data from a remote API endpoint. We will then use the function to generate a data model:
165172

166173
```python
167174
from cube import TemplateContext
@@ -237,12 +244,46 @@ cubes:
237244
{%- endfor %}
238245
```
239246

240-
<ReferenceBox>
247+
### Imports
241248

242-
If you'd like to split your Python code into several files, see
243-
[this issue](https://github.com/cube-js/cube/issues/8443#issuecomment-2219804266).
249+
In the `model/globals.py` file (or the `cube.py` configuration file), you can
250+
import modules from the current directory. In the following example, we import a function
251+
from the `utils` module and use it to populate a variable in the template context:
244252

245-
</ReferenceBox>
253+
```python filename="model/utils.py"
254+
def answer_to_main_question() -> str:
255+
return "42"
256+
```
257+
258+
```python filename="model/globals.py"
259+
from cube import TemplateContext
260+
from utils import answer_to_main_question
261+
262+
template = TemplateContext()
263+
264+
answer = answer_to_main_question()
265+
template.add_variable('answer', answer)
266+
```
267+
### Dependencies
268+
269+
If you need to use dependencies in your dynamic data model (or your `cube.py`
270+
configuration file), you can list them in the `requirements.txt` file in the root
271+
directory of your Cube deployment. They will be automatically installed with `pip` on
272+
the startup.
273+
274+
<InfoBox>
275+
276+
[`cube` package][ref-cube-package] is available out of the box, it doesn't need to be
277+
listed in `requirements.txt`.
278+
279+
</InfoBox>
280+
281+
If you use dbt for data transformation, you might find the [`cube_dbt`
282+
package][ref-cube-dbt-package] useful. It provides a set of utilities that simplify
283+
defining the data model in YAML [based on dbt models][ref-cube-with-dbt].
284+
285+
If you need to use dependencies with native extensions, build a [custom Docker
286+
image][ref-docker-image-extension].
246287

247288

248289
[jinja]: https://jinja.palletsprojects.com/
@@ -253,4 +294,9 @@ If you'd like to split your Python code into several files, see
253294
[jinja-docs-autoescaping]: https://jinja.palletsprojects.com/en/3.1.x/api/#autoescaping
254295
[jinja-docs-filters-safe]: https://jinja.palletsprojects.com/en/3.1.x/templates/#jinja-filters.safe
255296
[ref-cube-dbt]: /reference/python/cube_dbt
256-
[ref-visual-model]: /product/workspace/visual-model
297+
[ref-visual-model]: /product/workspace/visual-model
298+
[ref-docker-image-extension]: /product/deployment/core#extend-the-docker-image
299+
[ref-cube-package]: /reference/python/cube
300+
[ref-cube-template-context]: /reference/python/cube#templatecontext-class
301+
[ref-cube-dbt-package]: /reference/python/cube_dbt
302+
[ref-cube-with-dbt]: /guides/dbt

packages/cubejs-backend-native/src/python/entry.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,19 @@ use crate::python::runtime::py_runtime_init;
66
use neon::prelude::*;
77
use pyo3::prelude::*;
88
use pyo3::types::{PyDict, PyFunction, PyList, PyString, PyTuple};
9+
use std::path::Path;
10+
11+
fn extend_sys_path(py: Python, file_name: &String) -> PyResult<()> {
12+
let sys_path = py.import("sys")?.getattr("path")?.downcast::<PyList>()?;
13+
14+
let config_dir = Path::new(&file_name)
15+
.parent()
16+
.unwrap_or_else(|| Path::new("."));
17+
let config_dir_str = config_dir.to_str().unwrap_or(".");
18+
19+
sys_path.insert(0, PyString::new(py, config_dir_str))?;
20+
Ok(())
21+
}
922

1023
fn python_load_config(mut cx: FunctionContext) -> JsResult<JsPromise> {
1124
let file_content_arg = cx.argument::<JsString>(0)?.value(&mut cx);
@@ -20,6 +33,8 @@ fn python_load_config(mut cx: FunctionContext) -> JsResult<JsPromise> {
2033
py_runtime_init(&mut cx, channel.clone())?;
2134

2235
let conf_res = Python::with_gil(|py| -> PyResult<CubeConfigPy> {
36+
extend_sys_path(py, &options_file_name)?;
37+
2338
let cube_code = include_str!(concat!(
2439
env!("CARGO_MANIFEST_DIR"),
2540
"/python/cube/src/__init__.py"
@@ -61,6 +76,8 @@ fn python_load_model(mut cx: FunctionContext) -> JsResult<JsPromise> {
6176
py_runtime_init(&mut cx, channel.clone())?;
6277

6378
let conf_res = Python::with_gil(|py| -> PyResult<CubePythonModel> {
79+
extend_sys_path(py, &model_file_name)?;
80+
6481
let cube_code = include_str!(concat!(
6582
env!("CARGO_MANIFEST_DIR"),
6683
"/python/cube/src/__init__.py"

packages/cubejs-backend-native/test/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from cube import config, file_repository
2+
from utils import test_function
23

34
config.schema_path = "models"
45
config.pg_sql_port = 5555
@@ -7,6 +8,7 @@
78

89
@config
910
def query_rewrite(query, ctx):
11+
query = test_function(query)
1012
print("[python] query_rewrite query=", query, " ctx=", ctx)
1113
return query
1214

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from cube import TemplateContext
2+
import os
3+
from utils import answer_to_main_question
4+
from subdir_for_test.meta import main_question
5+
6+
7+
template = TemplateContext()
8+
9+
value_or_none = os.getenv('MY_ENV_VAR')
10+
template.add_variable('value_or_none', value_or_none)
11+
12+
value_or_default = os.getenv('MY_OTHER_ENV_VAR', 'my_default_value')
13+
template.add_variable('value_or_default', value_or_default)
14+
15+
template.add_variable('main_question', main_question())
16+
template.add_variable('answer_to_main_question', answer_to_main_question())
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from cube import TemplateContext
2+
import sys
3+
import os
4+
5+
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
6+
7+
from utils import answer_to_main_question
8+
9+
template = TemplateContext()
10+
11+
value_or_none = os.getenv('MY_ENV_VAR')
12+
template.add_variable('value_or_none', value_or_none)
13+
14+
value_or_default = os.getenv('MY_OTHER_ENV_VAR', 'my_default_value')
15+
template.add_variable('value_or_default', value_or_default)
16+
17+
template.add_variable('answer_to_main_question', answer_to_main_question())

packages/cubejs-backend-native/test/python.test.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,17 @@ const suite = native.isFallbackBuild() ? xdescribe : describe;
99
const darwinSuite = process.platform === 'darwin' && !native.isFallbackBuild() ? describe : xdescribe;
1010

1111
async function loadConfigurationFile(fileName: string) {
12-
const content = await fs.readFile(path.join(process.cwd(), 'test', fileName), 'utf8');
12+
const fullFileName = path.join(process.cwd(), 'test', fileName);
13+
const content = await fs.readFile(fullFileName, 'utf8');
1314
console.log('content', {
1415
content,
15-
fileName
16+
fileName: fullFileName
1617
});
1718

1819
const config = await native.pythonLoadConfig(
1920
content,
2021
{
21-
fileName
22+
fileName: fullFileName
2223
}
2324
);
2425

@@ -27,6 +28,26 @@ async function loadConfigurationFile(fileName: string) {
2728
return config;
2829
}
2930

31+
const nativeInstance = new native.NativeInstance();
32+
33+
suite('Python Models', () => {
34+
test('models import', async () => {
35+
const fullFileName = path.join(process.cwd(), 'test', 'globals.py');
36+
const content = await fs.readFile(fullFileName, 'utf8');
37+
38+
// Just checking it won't fail
39+
await nativeInstance.loadPythonContext(fullFileName, content);
40+
});
41+
42+
test('models import with sys.path changed', async () => {
43+
const fullFileName = path.join(process.cwd(), 'test', 'globals_w_import_path.py');
44+
const content = await fs.readFile(fullFileName, 'utf8');
45+
46+
// Just checking it won't fail
47+
await nativeInstance.loadPythonContext(fullFileName, content);
48+
});
49+
});
50+
3051
suite('Python Config', () => {
3152
let config: PyConfiguration;
3253

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Separate file module for testing python imports
2+
3+
# Simple test function
4+
def test_meta_function(query: dict) -> dict:
5+
return query
6+
7+
def main_question() -> str:
8+
return "Why?"
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Separate file module for testing python imports
2+
3+
# Simple test function
4+
def test_function(query: dict) -> dict:
5+
return query
6+
7+
def answer_to_main_question() -> str:
8+
return "42"

0 commit comments

Comments
 (0)