squirrels 0.3.3__py3-none-any.whl → 0.4.0__py3-none-any.whl
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.
Potentially problematic release.
This version of squirrels might be problematic. Click here for more details.
- squirrels/__init__.py +7 -3
- squirrels/_api_response_models.py +96 -72
- squirrels/_api_server.py +375 -201
- squirrels/_authenticator.py +23 -22
- squirrels/_command_line.py +70 -46
- squirrels/_connection_set.py +23 -25
- squirrels/_constants.py +29 -78
- squirrels/_dashboards_io.py +61 -0
- squirrels/_environcfg.py +53 -50
- squirrels/_initializer.py +184 -141
- squirrels/_manifest.py +168 -195
- squirrels/_models.py +159 -292
- squirrels/_package_loader.py +7 -8
- squirrels/_parameter_configs.py +173 -141
- squirrels/_parameter_sets.py +49 -38
- squirrels/_py_module.py +7 -7
- squirrels/_seeds.py +13 -12
- squirrels/_utils.py +114 -54
- squirrels/_version.py +1 -1
- squirrels/arguments/init_time_args.py +16 -10
- squirrels/arguments/run_time_args.py +89 -24
- squirrels/dashboards.py +82 -0
- squirrels/data_sources.py +212 -232
- squirrels/dateutils.py +29 -26
- squirrels/package_data/assets/index.css +1 -1
- squirrels/package_data/assets/index.js +27 -18
- squirrels/package_data/base_project/.gitignore +2 -2
- squirrels/package_data/base_project/connections.yml +1 -1
- squirrels/package_data/base_project/dashboards/dashboard_example.py +32 -0
- squirrels/package_data/base_project/dashboards.yml +10 -0
- squirrels/package_data/base_project/docker/.dockerignore +9 -4
- squirrels/package_data/base_project/docker/Dockerfile +7 -6
- squirrels/package_data/base_project/docker/compose.yml +1 -1
- squirrels/package_data/base_project/env.yml +2 -2
- squirrels/package_data/base_project/models/dbviews/{database_view1.py → dbview_example.py} +2 -1
- squirrels/package_data/base_project/models/dbviews/{database_view1.sql → dbview_example.sql} +3 -2
- squirrels/package_data/base_project/models/federates/{dataset_example.py → federate_example.py} +6 -6
- squirrels/package_data/base_project/models/federates/{dataset_example.sql → federate_example.sql} +1 -1
- squirrels/package_data/base_project/parameters.yml +6 -4
- squirrels/package_data/base_project/pyconfigs/auth.py +1 -1
- squirrels/package_data/base_project/pyconfigs/connections.py +1 -1
- squirrels/package_data/base_project/pyconfigs/context.py +38 -10
- squirrels/package_data/base_project/pyconfigs/parameters.py +15 -7
- squirrels/package_data/base_project/squirrels.yml.j2 +14 -7
- squirrels/package_data/templates/index.html +3 -3
- squirrels/parameter_options.py +103 -106
- squirrels/parameters.py +347 -195
- squirrels/project.py +378 -0
- squirrels/user_base.py +14 -6
- {squirrels-0.3.3.dist-info → squirrels-0.4.0.dist-info}/METADATA +9 -21
- squirrels-0.4.0.dist-info/RECORD +60 -0
- squirrels/_timer.py +0 -23
- squirrels-0.3.3.dist-info/RECORD +0 -56
- {squirrels-0.3.3.dist-info → squirrels-0.4.0.dist-info}/LICENSE +0 -0
- {squirrels-0.3.3.dist-info → squirrels-0.4.0.dist-info}/WHEEL +0 -0
- {squirrels-0.3.3.dist-info → squirrels-0.4.0.dist-info}/entry_points.txt +0 -0
squirrels/_api_server.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from typing import
|
|
1
|
+
from typing import Coroutine, Mapping, Callable, TypeVar, Annotated, Any
|
|
2
2
|
from dataclasses import make_dataclass, asdict
|
|
3
3
|
from fastapi import Depends, FastAPI, Request, HTTPException, Response, status
|
|
4
4
|
from fastapi.responses import HTMLResponse, JSONResponse
|
|
@@ -8,88 +8,138 @@ from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
|
|
8
8
|
from fastapi.middleware.cors import CORSMiddleware
|
|
9
9
|
from pydantic import create_model, BaseModel
|
|
10
10
|
from cachetools import TTLCache
|
|
11
|
-
from
|
|
12
|
-
import os, mimetypes, traceback,
|
|
11
|
+
from argparse import Namespace
|
|
12
|
+
import os, io, time, mimetypes, traceback, uuid, pandas as pd
|
|
13
13
|
|
|
14
14
|
from . import _constants as c, _utils as u, _api_response_models as arm
|
|
15
15
|
from ._version import sq_major_version
|
|
16
|
-
from .
|
|
17
|
-
from ._parameter_sets import ParameterConfigsSetIO
|
|
18
|
-
from ._authenticator import User, Authenticator
|
|
19
|
-
from ._timer import timer, time
|
|
16
|
+
from ._authenticator import User
|
|
20
17
|
from ._parameter_sets import ParameterSet
|
|
21
|
-
from .
|
|
18
|
+
from .dashboards import Dashboard
|
|
19
|
+
from .project import SquirrelsProject
|
|
22
20
|
|
|
23
21
|
mimetypes.add_type('application/javascript', '.js')
|
|
24
22
|
|
|
25
23
|
|
|
26
|
-
def df_to_api_response0(df: pd.DataFrame, dimensions: list[str] = None) -> arm.DatasetResultModel:
|
|
27
|
-
"""
|
|
28
|
-
Convert a pandas DataFrame to the response format that the dataset result API of Squirrels outputs.
|
|
29
|
-
|
|
30
|
-
Parameters:
|
|
31
|
-
df: The dataframe to convert into an API response
|
|
32
|
-
dimensions: The list of declared dimensions. If None, all non-numeric columns are assumed as dimensions
|
|
33
|
-
|
|
34
|
-
Returns:
|
|
35
|
-
The response of a Squirrels dataset result API
|
|
36
|
-
"""
|
|
37
|
-
in_df_json = json.loads(df.to_json(orient='table', index=False))
|
|
38
|
-
out_fields = []
|
|
39
|
-
non_numeric_fields = []
|
|
40
|
-
for in_column in in_df_json["schema"]["fields"]:
|
|
41
|
-
col_name: str = in_column["name"]
|
|
42
|
-
out_column = arm.ColumnModel(name=col_name, type=in_column["type"])
|
|
43
|
-
out_fields.append(out_column)
|
|
44
|
-
|
|
45
|
-
if not pd_types.is_numeric_dtype(df[col_name].dtype):
|
|
46
|
-
non_numeric_fields.append(col_name)
|
|
47
|
-
|
|
48
|
-
out_dimensions = non_numeric_fields if dimensions is None else dimensions
|
|
49
|
-
out_schema = arm.SchemaModel(fields=out_fields, dimensions=out_dimensions)
|
|
50
|
-
return arm.DatasetResultModel(schema=out_schema, data=in_df_json["data"])
|
|
51
|
-
|
|
52
|
-
|
|
53
24
|
class ApiServer:
|
|
54
|
-
def __init__(self, no_cache: bool) -> None:
|
|
25
|
+
def __init__(self, no_cache: bool, project: SquirrelsProject) -> None:
|
|
55
26
|
"""
|
|
56
27
|
Constructor for ApiServer
|
|
57
28
|
|
|
58
|
-
|
|
29
|
+
Arguments:
|
|
59
30
|
no_cache (bool): Whether to disable caching
|
|
60
31
|
"""
|
|
61
32
|
self.no_cache = no_cache
|
|
62
|
-
self.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
self.
|
|
33
|
+
self.project = project
|
|
34
|
+
self.logger = project._logger
|
|
35
|
+
|
|
36
|
+
self.j2_env = project._j2_env
|
|
37
|
+
self.env_cfg = project._env_cfg
|
|
38
|
+
self.manifest_cfg = project._manifest_cfg
|
|
39
|
+
self.seeds = project._seeds
|
|
40
|
+
self.conn_args = project._conn_args
|
|
41
|
+
self.conn_set = project._conn_set
|
|
42
|
+
self.authenticator = project._authenticator
|
|
43
|
+
self.param_args = project._param_args
|
|
44
|
+
self.param_cfg_set = project._param_cfg_set
|
|
45
|
+
self.context_func = project._context_func
|
|
46
|
+
self.model_files = project._model_files
|
|
47
|
+
self.dashboards = project._dashboards
|
|
66
48
|
|
|
67
|
-
def run(self, uvicorn_args:
|
|
49
|
+
def run(self, uvicorn_args: Namespace) -> None:
|
|
68
50
|
"""
|
|
69
51
|
Runs the API server with uvicorn for CLI "squirrels run"
|
|
70
52
|
|
|
71
|
-
|
|
53
|
+
Arguments:
|
|
72
54
|
uvicorn_args: List of arguments to pass to uvicorn.run. Currently only supports "host" and "port"
|
|
73
55
|
"""
|
|
74
56
|
start = time.time()
|
|
75
|
-
|
|
57
|
+
|
|
58
|
+
tags_metadata = [
|
|
59
|
+
{
|
|
60
|
+
"name": "Project Metadata",
|
|
61
|
+
"description": "Get information on project such as name, version, and other API endpoints",
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"name": "Login",
|
|
65
|
+
"description": "Submit username and password, and get token for authentication",
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"name": "Catalogs",
|
|
69
|
+
"description": "Get catalog of datasets and dashboards with endpoints for their parameters and results",
|
|
70
|
+
}
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
for dataset_name in self.manifest_cfg.datasets:
|
|
74
|
+
tags_metadata.append({
|
|
75
|
+
"name": f"Dataset '{dataset_name}'",
|
|
76
|
+
"description": f"Get parameters or results for dataset '{dataset_name}'",
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
for dashboard_name in self.manifest_cfg.dashboards:
|
|
80
|
+
tags_metadata.append({
|
|
81
|
+
"name": f"Dashboard '{dashboard_name}'",
|
|
82
|
+
"description": f"Get parameters or results for dashboard '{dashboard_name}'",
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
app = FastAPI(
|
|
86
|
+
title=f"Squirrels APIs for '{self.manifest_cfg.project_variables.label}'", openapi_tags=tags_metadata,
|
|
87
|
+
description="For specifying parameter selections to dataset APIs, you can choose between using query parameters with the GET method or using request body with the POST method"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
async def _log_request_run(request: Request) -> None:
|
|
91
|
+
headers = dict(request.scope["headers"])
|
|
92
|
+
request_id = uuid.uuid4().hex
|
|
93
|
+
headers[b"x-request-id"] = request_id.encode()
|
|
94
|
+
request.scope["headers"] = list(headers.items())
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
body = await request.json()
|
|
98
|
+
except Exception:
|
|
99
|
+
body = None
|
|
100
|
+
|
|
101
|
+
headers_dict = dict(request.headers)
|
|
102
|
+
path, params = request.url.path, dict(request.query_params)
|
|
103
|
+
path_with_params = f"{path}?{request.query_params}" if len(params) > 0 else path
|
|
104
|
+
data = {"request_method": request.method, "request_path": path, "request_params": params, "request_headers": headers_dict, "request_body": body}
|
|
105
|
+
info = {"request_id": request_id}
|
|
106
|
+
self.logger.info(f'Running request: {request.method} {path_with_params}', extra={"data": data, "info": info})
|
|
107
|
+
|
|
108
|
+
def _get_request_id(request: Request) -> str:
|
|
109
|
+
return request.headers.get("x-request-id", "")
|
|
76
110
|
|
|
77
111
|
@app.middleware("http")
|
|
78
112
|
async def catch_exceptions_middleware(request: Request, call_next):
|
|
113
|
+
buffer = io.StringIO()
|
|
79
114
|
try:
|
|
115
|
+
await _log_request_run(request)
|
|
80
116
|
return await call_next(request)
|
|
81
117
|
except u.InvalidInputError as exc:
|
|
82
|
-
traceback.print_exc()
|
|
83
|
-
|
|
84
|
-
|
|
118
|
+
traceback.print_exc(file=buffer)
|
|
119
|
+
response = JSONResponse(
|
|
120
|
+
status_code=status.HTTP_400_BAD_REQUEST, content={"message": str(exc), "blame": "API client"}
|
|
121
|
+
)
|
|
122
|
+
except u.FileExecutionError as exc:
|
|
123
|
+
traceback.print_exception(exc.error, file=buffer)
|
|
124
|
+
buffer.write(str(exc))
|
|
125
|
+
response = JSONResponse(
|
|
126
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"message": f"An unexpected error occurred", "blame": "Squirrels project"}
|
|
127
|
+
)
|
|
85
128
|
except u.ConfigurationError as exc:
|
|
86
|
-
traceback.print_exc()
|
|
87
|
-
|
|
88
|
-
|
|
129
|
+
traceback.print_exc(file=buffer)
|
|
130
|
+
response = JSONResponse(
|
|
131
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"message": f"An unexpected error occurred", "blame": "Squirrels project"}
|
|
132
|
+
)
|
|
89
133
|
except Exception as exc:
|
|
90
|
-
traceback.print_exc()
|
|
91
|
-
|
|
92
|
-
|
|
134
|
+
traceback.print_exc(file=buffer)
|
|
135
|
+
response = JSONResponse(
|
|
136
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"message": f"An unexpected error occurred", "blame": "Squirrels framework"}
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
err_msg = buffer.getvalue()
|
|
140
|
+
self.logger.error(err_msg)
|
|
141
|
+
print(err_msg)
|
|
142
|
+
return response
|
|
93
143
|
|
|
94
144
|
app.add_middleware(
|
|
95
145
|
CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"],
|
|
@@ -97,7 +147,7 @@ class ApiServer:
|
|
|
97
147
|
)
|
|
98
148
|
|
|
99
149
|
squirrels_version_path = f'/squirrels-v{sq_major_version}'
|
|
100
|
-
partial_base_path = f'/{
|
|
150
|
+
partial_base_path = f'/{self.manifest_cfg.project_variables.name}/v{self.manifest_cfg.project_variables.major_version}'
|
|
101
151
|
base_path = squirrels_version_path + u.normalize_name_for_api(partial_base_path)
|
|
102
152
|
|
|
103
153
|
# Helpers
|
|
@@ -118,36 +168,19 @@ class ApiServer:
|
|
|
118
168
|
|
|
119
169
|
return result
|
|
120
170
|
|
|
121
|
-
REQUEST_VERSION_REQUEST_HEADER = "squirrels-request-version"
|
|
122
171
|
def get_request_version_header(headers: Mapping):
|
|
172
|
+
REQUEST_VERSION_REQUEST_HEADER = "squirrels-request-version"
|
|
123
173
|
return get_versioning_request_header(headers, REQUEST_VERSION_REQUEST_HEADER)
|
|
124
174
|
|
|
125
|
-
|
|
126
|
-
|
|
175
|
+
def process_based_on_response_version_header(headers: Mapping, processes: dict[int, Callable[[], T]]) -> T:
|
|
176
|
+
RESPONSE_VERSION_REQUEST_HEADER = "squirrels-response-version"
|
|
127
177
|
response_version = get_versioning_request_header(headers, RESPONSE_VERSION_REQUEST_HEADER)
|
|
128
178
|
if response_version is None or response_version >= 0:
|
|
129
179
|
return processes[0]()
|
|
130
180
|
else:
|
|
131
181
|
raise u.InvalidInputError(f'Invalid value for "{RESPONSE_VERSION_REQUEST_HEADER}" header: {response_version}')
|
|
132
182
|
|
|
133
|
-
def
|
|
134
|
-
try:
|
|
135
|
-
dataset_scope = self.dataset_configs[dataset].scope
|
|
136
|
-
except KeyError as e:
|
|
137
|
-
raise u.InvalidInputError(f'Invalid dataset name: "{dataset}"')
|
|
138
|
-
return self.authenticator.can_user_access_scope(user, dataset_scope)
|
|
139
|
-
|
|
140
|
-
async def apply_dataset_api_function(
|
|
141
|
-
api_function: Callable[..., Coroutine[Any, Any, T]], user: Optional[User], dataset: str, headers: Mapping, params: Mapping
|
|
142
|
-
) -> T:
|
|
143
|
-
dataset_normalized = u.normalize_name(dataset)
|
|
144
|
-
if not can_user_access_dataset(user, dataset_normalized):
|
|
145
|
-
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED,
|
|
146
|
-
detail="Could not validate credentials",
|
|
147
|
-
headers={"WWW-Authenticate": "Bearer"})
|
|
148
|
-
|
|
149
|
-
request_version = get_request_version_header(headers)
|
|
150
|
-
|
|
183
|
+
def get_selections_and_request_version(params: Mapping, headers: Mapping) -> tuple[frozenset[tuple[str, Any]], int | None]:
|
|
151
184
|
# Changing selections into a cachable "frozenset" that will later be converted to dictionary
|
|
152
185
|
selections = set()
|
|
153
186
|
for key, val in params.items():
|
|
@@ -161,7 +194,8 @@ class ApiServer:
|
|
|
161
194
|
selections.add((u.normalize_name(key), val))
|
|
162
195
|
selections = frozenset(selections)
|
|
163
196
|
|
|
164
|
-
|
|
197
|
+
request_version = get_request_version_header(headers)
|
|
198
|
+
return selections, request_version
|
|
165
199
|
|
|
166
200
|
async def do_cachable_action(cache: TTLCache, action: Callable[..., Coroutine[Any, Any, T]], *args) -> T:
|
|
167
201
|
cache_key = tuple(args)
|
|
@@ -171,208 +205,348 @@ class ApiServer:
|
|
|
171
205
|
cache[cache_key] = result
|
|
172
206
|
return result
|
|
173
207
|
|
|
174
|
-
def
|
|
208
|
+
def get_query_models_from_widget_params(parameters: list):
|
|
209
|
+
QueryModelForGetRaw = make_dataclass("QueryParams", [
|
|
210
|
+
param_fields[param].as_query_info() for param in parameters
|
|
211
|
+
])
|
|
212
|
+
QueryModelForGet = Annotated[QueryModelForGetRaw, Depends()]
|
|
213
|
+
|
|
214
|
+
QueryModelForPost = create_model("RequestBodyParams", **{
|
|
215
|
+
param: param_fields[param].as_body_info() for param in parameters
|
|
216
|
+
}) # type: ignore
|
|
217
|
+
return QueryModelForGet, QueryModelForPost
|
|
218
|
+
|
|
219
|
+
def _get_section_from_request_path(request: Request, section: int) -> str:
|
|
175
220
|
url_path: str = request.scope['route'].path
|
|
176
221
|
return url_path.split('/')[section]
|
|
222
|
+
|
|
223
|
+
def get_dataset_name(request: Request, section: int) -> str:
|
|
224
|
+
dataset_raw = _get_section_from_request_path(request, section)
|
|
225
|
+
return u.normalize_name(dataset_raw)
|
|
226
|
+
|
|
227
|
+
def get_dashboard_name(request: Request, section: int) -> str:
|
|
228
|
+
dashboard_raw = _get_section_from_request_path(request, section)
|
|
229
|
+
return u.normalize_name(dashboard_raw)
|
|
177
230
|
|
|
178
|
-
# Login
|
|
231
|
+
# Login & Authorization
|
|
179
232
|
token_path = base_path + '/token'
|
|
180
233
|
|
|
181
234
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=token_path, auto_error=False)
|
|
182
235
|
|
|
183
|
-
@app.post(token_path)
|
|
236
|
+
@app.post(token_path, tags=["Login"])
|
|
184
237
|
async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()) -> arm.LoginReponse:
|
|
185
|
-
user:
|
|
238
|
+
user: User | None = self.authenticator.authenticate_user(form_data.username, form_data.password)
|
|
186
239
|
if not user:
|
|
187
|
-
raise HTTPException(
|
|
188
|
-
|
|
189
|
-
|
|
240
|
+
raise HTTPException(
|
|
241
|
+
status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect username or password", headers={"WWW-Authenticate": "Bearer"}
|
|
242
|
+
)
|
|
190
243
|
access_token, expiry = self.authenticator.create_access_token(user)
|
|
191
244
|
return arm.LoginReponse(access_token=access_token, token_type="bearer", username=user.username, expiry_time=expiry)
|
|
192
245
|
|
|
193
|
-
async def get_current_user(response: Response, token: str = Depends(oauth2_scheme)) ->
|
|
246
|
+
async def get_current_user(response: Response, token: str = Depends(oauth2_scheme)) -> User | None:
|
|
194
247
|
user = self.authenticator.get_user_from_token(token)
|
|
195
248
|
username = "" if user is None else user.username
|
|
196
249
|
response.headers["Applied-Username"] = username
|
|
197
250
|
return user
|
|
198
251
|
|
|
199
|
-
#
|
|
200
|
-
|
|
252
|
+
# Datasets / Dashboards Catalog API
|
|
253
|
+
data_catalog_path = base_path + '/data-catalog'
|
|
254
|
+
dataset_results_path = base_path + '/dataset/{dataset}'
|
|
255
|
+
dataset_parameters_path = dataset_results_path + '/parameters'
|
|
256
|
+
dashboard_results_path = base_path + '/dashboard/{dashboard}'
|
|
257
|
+
dashboard_parameters_path = dashboard_results_path + '/parameters'
|
|
201
258
|
|
|
202
|
-
def
|
|
203
|
-
|
|
259
|
+
def get_data_catalog0(user: User | None) -> arm.CatalogModel:
|
|
260
|
+
dataset_items: list[arm.DatasetItemModel] = []
|
|
261
|
+
for name, config in self.manifest_cfg.datasets.items():
|
|
262
|
+
if self.authenticator.can_user_access_scope(user, config.scope):
|
|
263
|
+
name_normalized = u.normalize_name_for_api(name)
|
|
264
|
+
dataset_items.append(arm.DatasetItemModel(
|
|
265
|
+
name=name, label=config.label, description=config.description,
|
|
266
|
+
parameters_path=dataset_parameters_path.format(dataset=name_normalized),
|
|
267
|
+
result_path=dataset_results_path.format(dataset=name_normalized)
|
|
268
|
+
))
|
|
269
|
+
|
|
270
|
+
dashboard_items: list[arm.DashboardItemModel] = []
|
|
271
|
+
for name, config in self.manifest_cfg.dashboards.items():
|
|
272
|
+
if self.authenticator.can_user_access_scope(user, config.scope):
|
|
273
|
+
name_normalized = u.normalize_name_for_api(name)
|
|
274
|
+
|
|
275
|
+
try:
|
|
276
|
+
dashboard_format = self.dashboards[name].get_dashboard_format()
|
|
277
|
+
except KeyError:
|
|
278
|
+
raise u.ConfigurationError(f"No dashboard file found for: {name}")
|
|
279
|
+
|
|
280
|
+
dashboard_items.append(arm.DashboardItemModel(
|
|
281
|
+
name=name, label=config.label, description=config.description, result_format=dashboard_format,
|
|
282
|
+
parameters_path=dashboard_parameters_path.format(dashboard=name_normalized),
|
|
283
|
+
result_path=dashboard_results_path.format(dashboard=name_normalized)
|
|
284
|
+
))
|
|
285
|
+
|
|
286
|
+
return arm.CatalogModel(datasets=dataset_items, dashboards=dashboard_items)
|
|
287
|
+
|
|
288
|
+
@app.get(data_catalog_path, tags=["Catalogs"], summary="Get list of datasets and dashboards available for user")
|
|
289
|
+
def get_catalog_of_datasets_and_dashboards(request: Request, user: User | None = Depends(get_current_user)) -> arm.CatalogModel:
|
|
290
|
+
return process_based_on_response_version_header(request.headers, {
|
|
291
|
+
0: lambda: get_data_catalog0(user)
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
# Parameters API Helpers
|
|
295
|
+
parameters_description = "Selections of one parameter may cascade the available options in another parameter. " \
|
|
296
|
+
"For example, if the dataset has parameters for 'country' and 'city', available options for 'city' would " \
|
|
297
|
+
"depend on the selected option 'country'. If a parameter has 'trigger_refresh' as true, provide the parameter " \
|
|
298
|
+
"selection to this endpoint whenever it changes to refresh the parameter options of children parameters."
|
|
204
299
|
|
|
205
|
-
parameters_cache_size = ManifestIO.obj.settings.get(c.PARAMETERS_CACHE_SIZE_SETTING, 1024)
|
|
206
|
-
parameters_cache_ttl = ManifestIO.obj.settings.get(c.PARAMETERS_CACHE_TTL_SETTING, 60)
|
|
207
|
-
|
|
208
300
|
async def get_parameters_helper(
|
|
209
|
-
|
|
301
|
+
parameters_tuple: tuple[str, ...], user: User | None, selections: frozenset[tuple[str, Any]], request_version: int | None
|
|
210
302
|
) -> ParameterSet:
|
|
211
303
|
if len(selections) > 1:
|
|
212
304
|
raise u.InvalidInputError(f"The /parameters endpoint takes at most 1 query parameter. Got {dict(selections)}")
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
305
|
+
|
|
306
|
+
param_set = self.param_cfg_set.apply_selections(
|
|
307
|
+
parameters_tuple, dict(selections), user, updates_only=True, request_version=request_version
|
|
308
|
+
)
|
|
309
|
+
return param_set
|
|
310
|
+
|
|
311
|
+
settings = self.manifest_cfg.settings
|
|
312
|
+
parameters_cache_size = settings.get(c.PARAMETERS_CACHE_SIZE_SETTING, 1024)
|
|
313
|
+
parameters_cache_ttl = settings.get(c.PARAMETERS_CACHE_TTL_SETTING, 60)
|
|
217
314
|
params_cache = TTLCache(maxsize=parameters_cache_size, ttl=parameters_cache_ttl*60)
|
|
218
315
|
|
|
219
|
-
async def get_parameters_cachable(
|
|
220
|
-
|
|
316
|
+
async def get_parameters_cachable(
|
|
317
|
+
parameters_tuple: tuple[str, ...], user: User | None, selections: frozenset[tuple[str, Any]], request_version: int | None
|
|
318
|
+
) -> ParameterSet:
|
|
319
|
+
return await do_cachable_action(params_cache, get_parameters_helper, parameters_tuple, user, selections, request_version)
|
|
221
320
|
|
|
222
|
-
async def get_parameters_definition(
|
|
223
|
-
|
|
224
|
-
|
|
321
|
+
async def get_parameters_definition(
|
|
322
|
+
parameters_list: list[str], user: User | None, headers: Mapping, params: Mapping
|
|
323
|
+
) -> arm.ParametersModel:
|
|
324
|
+
get_parameters_function = get_parameters_helper if self.no_cache else get_parameters_cachable
|
|
325
|
+
selections, request_version = get_selections_and_request_version(params, headers)
|
|
326
|
+
result = await get_parameters_function(tuple(parameters_list), user, selections, request_version)
|
|
225
327
|
return process_based_on_response_version_header(headers, {
|
|
226
328
|
0: result.to_api_response_model0
|
|
227
329
|
})
|
|
228
330
|
|
|
229
|
-
|
|
230
|
-
results_path = base_path + '/dataset/{dataset}'
|
|
231
|
-
|
|
232
|
-
def get_dataset_for_results_request(request: Request) -> str:
|
|
233
|
-
return get_dataset_from_request_path(request, -1)
|
|
331
|
+
param_fields = self.param_cfg_set.get_all_api_field_info()
|
|
234
332
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
333
|
+
def validate_parameters_list(parameters: list[str], entity_type: str) -> None:
|
|
334
|
+
for param in parameters:
|
|
335
|
+
if param not in param_fields:
|
|
336
|
+
all_params = list(param_fields.keys())
|
|
337
|
+
raise u.ConfigurationError(f"{entity_type} '{dataset_name}' use parameter '{param}' which doesn't exist. Available parameters are:"
|
|
338
|
+
f"\n {all_params}")
|
|
339
|
+
|
|
340
|
+
# Dataset Results API Helpers
|
|
341
|
+
async def get_dataset_results_helper(
|
|
342
|
+
dataset: str, user: User | None, selections: frozenset[tuple[str, Any]], request_version: int | None
|
|
240
343
|
) -> pd.DataFrame:
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
344
|
+
try:
|
|
345
|
+
return await self.project.dataset(dataset, selections=dict(selections), user=user)
|
|
346
|
+
except PermissionError as e:
|
|
347
|
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(e), headers={"WWW-Authenticate": "Bearer"}) from e
|
|
244
348
|
|
|
245
|
-
|
|
349
|
+
settings = self.manifest_cfg.settings
|
|
350
|
+
dataset_results_cache_size = settings.get(c.DATASETS_CACHE_SIZE_SETTING, settings.get(c.RESULTS_CACHE_SIZE_SETTING, 128))
|
|
351
|
+
dataset_results_cache_ttl = settings.get(c.DATASETS_CACHE_TTL_SETTING, settings.get(c.RESULTS_CACHE_TTL_SETTING, 60))
|
|
352
|
+
dataset_results_cache = TTLCache(maxsize=dataset_results_cache_size, ttl=dataset_results_cache_ttl*60)
|
|
246
353
|
|
|
247
|
-
async def
|
|
248
|
-
|
|
354
|
+
async def get_dataset_results_cachable(
|
|
355
|
+
dataset: str, user: User | None, selections: frozenset[tuple[str, Any]], request_version: int | None
|
|
356
|
+
) -> pd.DataFrame:
|
|
357
|
+
return await do_cachable_action(dataset_results_cache, get_dataset_results_helper, dataset, user, selections, request_version)
|
|
249
358
|
|
|
250
|
-
async def
|
|
251
|
-
|
|
252
|
-
|
|
359
|
+
async def get_dataset_results_definition(
|
|
360
|
+
dataset_name: str, user: User | None, headers: Mapping, params: Mapping
|
|
361
|
+
) -> arm.DatasetResultModel:
|
|
362
|
+
get_dataset_function = get_dataset_results_helper if self.no_cache else get_dataset_results_cachable
|
|
363
|
+
selections, request_version = get_selections_and_request_version(params, headers)
|
|
364
|
+
result = await get_dataset_function(dataset_name, user, selections, request_version)
|
|
253
365
|
return process_based_on_response_version_header(headers, {
|
|
254
|
-
0: lambda:
|
|
366
|
+
0: lambda: arm.DatasetResultModel(**u.df_to_json0(result))
|
|
255
367
|
})
|
|
256
368
|
|
|
257
|
-
|
|
369
|
+
# Dashboard Results API Helpers
|
|
370
|
+
async def get_dashboard_results_helper(
|
|
371
|
+
dashboard: str, user: User | None, selections: frozenset[tuple[str, Any]], request_version: int | None
|
|
372
|
+
) -> Dashboard:
|
|
373
|
+
try:
|
|
374
|
+
return await self.project.dashboard(dashboard, selections=dict(selections), user=user)
|
|
375
|
+
except PermissionError as e:
|
|
376
|
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(e), headers={"WWW-Authenticate": "Bearer"}) from e
|
|
377
|
+
|
|
378
|
+
settings = self.manifest_cfg.settings
|
|
379
|
+
dashboard_results_cache_size = settings.get(c.DASHBOARDS_CACHE_SIZE_SETTING, 128)
|
|
380
|
+
dashboard_results_cache_ttl = settings.get(c.DASHBOARDS_CACHE_TTL_SETTING, 60)
|
|
381
|
+
dashboard_results_cache = TTLCache(maxsize=dashboard_results_cache_size, ttl=dashboard_results_cache_ttl*60)
|
|
382
|
+
|
|
383
|
+
async def get_dashboard_results_cachable(
|
|
384
|
+
dashboard: str, user: User | None, selections: frozenset[tuple[str, Any]], request_version: int | None
|
|
385
|
+
) -> Dashboard:
|
|
386
|
+
return await do_cachable_action(dashboard_results_cache, get_dashboard_results_helper, dashboard, user, selections, request_version)
|
|
387
|
+
|
|
388
|
+
async def get_dashboard_results_definition(
|
|
389
|
+
dashboard_name: str, user: User | None, headers: Mapping, params: Mapping
|
|
390
|
+
) -> Response:
|
|
391
|
+
get_dashboard_function = get_dashboard_results_helper if self.no_cache else get_dashboard_results_cachable
|
|
392
|
+
selections, request_version = get_selections_and_request_version(params, headers)
|
|
393
|
+
dashboard_obj = await get_dashboard_function(dashboard_name, user, selections, request_version)
|
|
394
|
+
if dashboard_obj._format == c.PNG:
|
|
395
|
+
assert isinstance(dashboard_obj._content, bytes)
|
|
396
|
+
result = Response(dashboard_obj._content, media_type="image/png")
|
|
397
|
+
elif dashboard_obj._format == c.HTML:
|
|
398
|
+
result = HTMLResponse(dashboard_obj._content)
|
|
399
|
+
else:
|
|
400
|
+
raise NotImplementedError()
|
|
401
|
+
return result
|
|
258
402
|
|
|
259
403
|
# Dataset Parameters and Results APIs
|
|
260
|
-
for dataset_name,
|
|
404
|
+
for dataset_name, dataset_config in self.manifest_cfg.datasets.items():
|
|
261
405
|
dataset_normalized = u.normalize_name_for_api(dataset_name)
|
|
262
|
-
curr_parameters_path =
|
|
263
|
-
curr_results_path =
|
|
406
|
+
curr_parameters_path = dataset_parameters_path.format(dataset=dataset_normalized)
|
|
407
|
+
curr_results_path = dataset_results_path.format(dataset=dataset_normalized)
|
|
264
408
|
|
|
265
|
-
|
|
266
|
-
if param not in param_fields:
|
|
267
|
-
all_params = list(param_fields.keys())
|
|
268
|
-
raise u.ConfigurationError(f"Dataset '{dataset_name}' use parameter '{param}' which doesn't exist. Available parameters are:"
|
|
269
|
-
f"\n {all_params}")
|
|
409
|
+
validate_parameters_list(dataset_config.parameters, "Dataset")
|
|
270
410
|
|
|
271
|
-
|
|
272
|
-
param_fields[param].as_query_info() for param in dataset_cfg.parameters
|
|
273
|
-
])
|
|
274
|
-
AnnotatedQueryModel = Annotated[QueryModelGet, Depends()]
|
|
411
|
+
QueryModelForGet, QueryModelForPost = get_query_models_from_widget_params(dataset_config.parameters)
|
|
275
412
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
request: Request, params: AnnotatedQueryModel, user: Optional[User] = Depends(get_current_user) # type: ignore
|
|
413
|
+
@app.get(
|
|
414
|
+
curr_parameters_path, tags=[f"Dataset '{dataset_name}'"], openapi_extra={"dataset": dataset_name},
|
|
415
|
+
description=parameters_description, response_class=JSONResponse
|
|
416
|
+
)
|
|
417
|
+
async def get_dataset_parameters(
|
|
418
|
+
request: Request, params: QueryModelForGet, user: User | None = Depends(get_current_user) # type: ignore
|
|
283
419
|
) -> arm.ParametersModel:
|
|
284
420
|
start = time.time()
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
421
|
+
curr_dataset_name = get_dataset_name(request, -2)
|
|
422
|
+
parameters_list = self.manifest_cfg.datasets[curr_dataset_name].parameters
|
|
423
|
+
result = await get_parameters_definition(parameters_list, user, request.headers, asdict(params))
|
|
424
|
+
self.logger.log_activity_time("GET REQUEST for PARAMETERS", start, request_id=_get_request_id(request))
|
|
288
425
|
return result
|
|
289
426
|
|
|
290
|
-
@app.post(
|
|
291
|
-
|
|
292
|
-
|
|
427
|
+
@app.post(
|
|
428
|
+
curr_parameters_path, tags=[f"Dataset '{dataset_name}'"], openapi_extra={"dataset": dataset_name},
|
|
429
|
+
description=parameters_description, response_class=JSONResponse
|
|
430
|
+
)
|
|
431
|
+
async def get_dataset_parameters_with_post(
|
|
432
|
+
request: Request, params: QueryModelForPost, user: User | None = Depends(get_current_user) # type: ignore
|
|
293
433
|
) -> arm.ParametersModel:
|
|
294
434
|
start = time.time()
|
|
295
|
-
|
|
435
|
+
curr_dataset_name = get_dataset_name(request, -2)
|
|
436
|
+
parameters_list = self.manifest_cfg.datasets[curr_dataset_name].parameters
|
|
296
437
|
params: BaseModel = params
|
|
297
|
-
result = await get_parameters_definition(
|
|
298
|
-
|
|
438
|
+
result = await get_parameters_definition(parameters_list, user, request.headers, params.model_dump())
|
|
439
|
+
self.logger.log_activity_time("POST REQUEST for PARAMETERS", start, request_id=_get_request_id(request))
|
|
299
440
|
return result
|
|
300
441
|
|
|
301
|
-
@app.get(curr_results_path, response_class=JSONResponse)
|
|
302
|
-
async def
|
|
303
|
-
request: Request, params:
|
|
442
|
+
@app.get(curr_results_path, tags=[f"Dataset '{dataset_name}'"], description=dataset_config.description, response_class=JSONResponse)
|
|
443
|
+
async def get_dataset_results(
|
|
444
|
+
request: Request, params: QueryModelForGet, user: User | None = Depends(get_current_user) # type: ignore
|
|
304
445
|
) -> arm.DatasetResultModel:
|
|
305
446
|
start = time.time()
|
|
306
|
-
|
|
307
|
-
result = await
|
|
308
|
-
|
|
447
|
+
curr_dataset_name = get_dataset_name(request, -1)
|
|
448
|
+
result = await get_dataset_results_definition(curr_dataset_name, user, request.headers, asdict(params))
|
|
449
|
+
self.logger.log_activity_time("GET REQUEST for DATASET RESULTS", start, request_id=_get_request_id(request))
|
|
309
450
|
return result
|
|
310
451
|
|
|
311
|
-
@app.post(curr_results_path, response_class=JSONResponse)
|
|
312
|
-
async def
|
|
313
|
-
request: Request, params:
|
|
452
|
+
@app.post(curr_results_path, tags=[f"Dataset '{dataset_name}'"], description=dataset_config.description, response_class=JSONResponse)
|
|
453
|
+
async def get_dataset_results_with_post(
|
|
454
|
+
request: Request, params: QueryModelForPost, user: User | None = Depends(get_current_user) # type: ignore
|
|
314
455
|
) -> arm.DatasetResultModel:
|
|
315
456
|
start = time.time()
|
|
316
|
-
|
|
457
|
+
curr_dataset_name = get_dataset_name(request, -1)
|
|
317
458
|
params: BaseModel = params
|
|
318
|
-
result = await
|
|
319
|
-
|
|
459
|
+
result = await get_dataset_results_definition(curr_dataset_name, user, request.headers, params.model_dump())
|
|
460
|
+
self.logger.log_activity_time("POST REQUEST for DATASET RESULTS", start, request_id=_get_request_id(request))
|
|
320
461
|
return result
|
|
321
462
|
|
|
322
|
-
#
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
463
|
+
# Dashboard Parameters and Results APIs
|
|
464
|
+
for dashboard_name, dashboard_config in self.manifest_cfg.dashboards.items():
|
|
465
|
+
dashboard_normalized = u.normalize_name_for_api(dashboard_name)
|
|
466
|
+
curr_parameters_path = dashboard_parameters_path.format(dashboard=dashboard_normalized)
|
|
467
|
+
curr_results_path = dashboard_results_path.format(dashboard=dashboard_normalized)
|
|
468
|
+
|
|
469
|
+
validate_parameters_list(dashboard_config.parameters, "Dashboard")
|
|
470
|
+
|
|
471
|
+
QueryModelForGet, QueryModelForPost = get_query_models_from_widget_params(dashboard_config.parameters)
|
|
472
|
+
|
|
473
|
+
@app.get(curr_parameters_path, tags=[f"Dashboard '{dashboard_name}'"], description=parameters_description, response_class=JSONResponse)
|
|
474
|
+
async def get_dashboard_parameters(
|
|
475
|
+
request: Request, params: QueryModelForGet, user: User | None = Depends(get_current_user) # type: ignore
|
|
476
|
+
) -> arm.ParametersModel:
|
|
477
|
+
start = time.time()
|
|
478
|
+
curr_dashboard_name = get_dashboard_name(request, -2)
|
|
479
|
+
parameters_list = self.manifest_cfg.dashboards[curr_dashboard_name].parameters
|
|
480
|
+
result = await get_parameters_definition(parameters_list, user, request.headers, asdict(params))
|
|
481
|
+
self.logger.log_activity_time("GET REQUEST for PARAMETERS", start, request_id=_get_request_id(request))
|
|
482
|
+
return result
|
|
483
|
+
|
|
484
|
+
@app.post(curr_parameters_path, tags=[f"Dashboard '{dashboard_name}'"], description=parameters_description, response_class=JSONResponse)
|
|
485
|
+
async def get_dashboard_parameters_with_post(
|
|
486
|
+
request: Request, params: QueryModelForPost, user: User | None = Depends(get_current_user) # type: ignore
|
|
487
|
+
) -> arm.ParametersModel:
|
|
488
|
+
start = time.time()
|
|
489
|
+
curr_dashboard_name = get_dashboard_name(request, -2)
|
|
490
|
+
parameters_list = self.manifest_cfg.dashboards[curr_dashboard_name].parameters
|
|
491
|
+
params: BaseModel = params
|
|
492
|
+
result = await get_parameters_definition(parameters_list, user, request.headers, params.model_dump())
|
|
493
|
+
self.logger.log_activity_time("POST REQUEST for PARAMETERS", start, request_id=_get_request_id(request))
|
|
494
|
+
return result
|
|
495
|
+
|
|
496
|
+
@app.get(curr_results_path, tags=[f"Dashboard '{dashboard_name}'"], description=dashboard_config.description, response_class=Response)
|
|
497
|
+
async def get_dashboard_results(
|
|
498
|
+
request: Request, params: QueryModelForGet, user: User | None = Depends(get_current_user) # type: ignore
|
|
499
|
+
) -> Response:
|
|
500
|
+
start = time.time()
|
|
501
|
+
curr_dashboard_name = get_dashboard_name(request, -1)
|
|
502
|
+
result = await get_dashboard_results_definition(curr_dashboard_name, user, request.headers, asdict(params))
|
|
503
|
+
self.logger.log_activity_time("GET REQUEST for DASHBOARD RESULTS", start, request_id=_get_request_id(request))
|
|
504
|
+
return result
|
|
505
|
+
|
|
506
|
+
@app.post(curr_results_path, tags=[f"Dashboard '{dashboard_name}'"], description=dashboard_config.description, response_class=Response)
|
|
507
|
+
async def get_dashboard_results_with_post(
|
|
508
|
+
request: Request, params: QueryModelForPost, user: User | None = Depends(get_current_user) # type: ignore
|
|
509
|
+
) -> Response:
|
|
510
|
+
start = time.time()
|
|
511
|
+
curr_dashboard_name = get_dashboard_name(request, -1)
|
|
512
|
+
params: BaseModel = params
|
|
513
|
+
result = await get_dashboard_results_definition(curr_dashboard_name, user, request.headers, params.model_dump())
|
|
514
|
+
self.logger.log_activity_time("POST REQUEST for DASHBOARD RESULTS", start, request_id=_get_request_id(request))
|
|
515
|
+
return result
|
|
516
|
+
|
|
517
|
+
# Project Metadata API
|
|
518
|
+
def get_project_metadata0() -> arm.ProjectModel:
|
|
519
|
+
return arm.ProjectModel(
|
|
520
|
+
name=self.manifest_cfg.project_variables.name,
|
|
521
|
+
label=self.manifest_cfg.project_variables.label,
|
|
348
522
|
versions=[arm.ProjectVersionModel(
|
|
349
|
-
major_version=
|
|
523
|
+
major_version=self.manifest_cfg.project_variables.major_version,
|
|
350
524
|
minor_versions=[0],
|
|
351
525
|
token_path=token_path,
|
|
352
|
-
|
|
526
|
+
data_catalog_path=data_catalog_path
|
|
353
527
|
)]
|
|
354
|
-
)
|
|
528
|
+
)
|
|
355
529
|
|
|
356
|
-
@app.get(squirrels_version_path, response_class=JSONResponse)
|
|
357
|
-
async def
|
|
530
|
+
@app.get(squirrels_version_path, tags=["Project Metadata"], response_class=JSONResponse)
|
|
531
|
+
async def get_project_metadata(request: Request) -> arm.ProjectModel:
|
|
358
532
|
return process_based_on_response_version_header(request.headers, {
|
|
359
|
-
0: lambda:
|
|
533
|
+
0: lambda: get_project_metadata0()
|
|
360
534
|
})
|
|
361
535
|
|
|
362
|
-
# Squirrels UI
|
|
363
|
-
static_dir = u.
|
|
536
|
+
# Squirrels Testing UI
|
|
537
|
+
static_dir = u.Path(os.path.dirname(__file__), c.PACKAGE_DATA_FOLDER, c.ASSETS_FOLDER)
|
|
364
538
|
app.mount('/'+c.ASSETS_FOLDER, StaticFiles(directory=static_dir), name=c.ASSETS_FOLDER)
|
|
365
539
|
|
|
366
|
-
templates_dir = u.
|
|
540
|
+
templates_dir = u.Path(os.path.dirname(__file__), c.PACKAGE_DATA_FOLDER, c.TEMPLATES_FOLDER)
|
|
367
541
|
templates = Jinja2Templates(directory=templates_dir)
|
|
368
542
|
|
|
369
|
-
@app.get('/', response_class=HTMLResponse)
|
|
370
|
-
async def
|
|
543
|
+
@app.get('/', summary="Get the Squirrels Testing UI", response_class=HTMLResponse)
|
|
544
|
+
async def get_testing_ui(request: Request):
|
|
371
545
|
return templates.TemplateResponse('index.html', {
|
|
372
|
-
'request': request, '
|
|
546
|
+
'request': request, 'project_metadata_path': squirrels_version_path, 'token_path': token_path
|
|
373
547
|
})
|
|
374
548
|
|
|
375
549
|
# Run API server
|
|
376
550
|
import uvicorn
|
|
377
|
-
|
|
551
|
+
self.logger.log_activity_time("creating app server", start)
|
|
378
552
|
uvicorn.run(app, host=uvicorn_args.host, port=uvicorn_args.port)
|