squirrels 0.3.2__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.

Files changed (56) hide show
  1. squirrels/__init__.py +7 -3
  2. squirrels/_api_response_models.py +96 -72
  3. squirrels/_api_server.py +375 -201
  4. squirrels/_authenticator.py +23 -22
  5. squirrels/_command_line.py +70 -46
  6. squirrels/_connection_set.py +23 -25
  7. squirrels/_constants.py +29 -78
  8. squirrels/_dashboards_io.py +61 -0
  9. squirrels/_environcfg.py +53 -50
  10. squirrels/_initializer.py +184 -141
  11. squirrels/_manifest.py +168 -195
  12. squirrels/_models.py +159 -292
  13. squirrels/_package_loader.py +7 -8
  14. squirrels/_parameter_configs.py +173 -141
  15. squirrels/_parameter_sets.py +49 -38
  16. squirrels/_py_module.py +7 -7
  17. squirrels/_seeds.py +13 -12
  18. squirrels/_utils.py +114 -54
  19. squirrels/_version.py +1 -1
  20. squirrels/arguments/init_time_args.py +16 -10
  21. squirrels/arguments/run_time_args.py +89 -24
  22. squirrels/dashboards.py +82 -0
  23. squirrels/data_sources.py +212 -232
  24. squirrels/dateutils.py +29 -26
  25. squirrels/package_data/assets/index.css +1 -1
  26. squirrels/package_data/assets/index.js +27 -18
  27. squirrels/package_data/base_project/.gitignore +2 -2
  28. squirrels/package_data/base_project/connections.yml +1 -1
  29. squirrels/package_data/base_project/dashboards/dashboard_example.py +32 -0
  30. squirrels/package_data/base_project/dashboards.yml +10 -0
  31. squirrels/package_data/base_project/docker/.dockerignore +9 -4
  32. squirrels/package_data/base_project/docker/Dockerfile +7 -6
  33. squirrels/package_data/base_project/docker/compose.yml +1 -1
  34. squirrels/package_data/base_project/env.yml +2 -2
  35. squirrels/package_data/base_project/models/dbviews/{database_view1.py → dbview_example.py} +2 -1
  36. squirrels/package_data/base_project/models/dbviews/{database_view1.sql → dbview_example.sql} +3 -2
  37. squirrels/package_data/base_project/models/federates/{dataset_example.py → federate_example.py} +6 -6
  38. squirrels/package_data/base_project/models/federates/{dataset_example.sql → federate_example.sql} +1 -1
  39. squirrels/package_data/base_project/parameters.yml +6 -4
  40. squirrels/package_data/base_project/pyconfigs/auth.py +1 -1
  41. squirrels/package_data/base_project/pyconfigs/connections.py +1 -1
  42. squirrels/package_data/base_project/pyconfigs/context.py +38 -10
  43. squirrels/package_data/base_project/pyconfigs/parameters.py +15 -7
  44. squirrels/package_data/base_project/squirrels.yml.j2 +14 -7
  45. squirrels/package_data/templates/index.html +3 -3
  46. squirrels/parameter_options.py +103 -106
  47. squirrels/parameters.py +347 -195
  48. squirrels/project.py +378 -0
  49. squirrels/user_base.py +14 -6
  50. {squirrels-0.3.2.dist-info → squirrels-0.4.0.dist-info}/METADATA +12 -23
  51. squirrels-0.4.0.dist-info/RECORD +60 -0
  52. squirrels/_timer.py +0 -23
  53. squirrels-0.3.2.dist-info/RECORD +0 -56
  54. {squirrels-0.3.2.dist-info → squirrels-0.4.0.dist-info}/LICENSE +0 -0
  55. {squirrels-0.3.2.dist-info → squirrels-0.4.0.dist-info}/WHEEL +0 -0
  56. {squirrels-0.3.2.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 Iterable, Optional, Mapping, Callable, Coroutine, TypeVar, Annotated, Any
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 pandas.api import types as pd_types
12
- import os, mimetypes, traceback, json, pandas as pd
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 ._manifest import ManifestIO
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 ._models import ModelsIO
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
- Parameters:
29
+ Arguments:
59
30
  no_cache (bool): Whether to disable caching
60
31
  """
61
32
  self.no_cache = no_cache
62
- self.dataset_configs = ManifestIO.obj.datasets
63
-
64
- token_expiry_minutes = ManifestIO.obj.settings.get(c.AUTH_TOKEN_EXPIRE_SETTING, 30)
65
- self.authenticator = Authenticator(token_expiry_minutes)
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: list[str]) -> None:
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
- Parameters:
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
- app = FastAPI()
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
- return JSONResponse(status_code=status.HTTP_400_BAD_REQUEST,
84
- content={"message": str(exc), "blame": "API client"})
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
- return JSONResponse(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
88
- content={"message": f"An unexpected error occurred", "blame": "Squirrels project"})
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
- return JSONResponse(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
92
- content={"message": f"An unexpected error occurred", "blame": "Squirrels framework"})
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'/{ManifestIO.obj.project_variables.get_name()}/v{ManifestIO.obj.project_variables.get_major_version()}'
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
- RESPONSE_VERSION_REQUEST_HEADER = "squirrels-response-version"
126
- def process_based_on_response_version_header(headers: Mapping, processes: dict[str, Callable[[], T]]) -> T:
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 can_user_access_dataset(user: Optional[User], dataset: str):
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
- return await api_function(user, dataset_normalized, selections, request_version)
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 get_dataset_from_request_path(request: Request, section: int) -> str:
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: Optional[User] = self.authenticator.authenticate_user(form_data.username, form_data.password)
238
+ user: User | None = self.authenticator.authenticate_user(form_data.username, form_data.password)
186
239
  if not user:
187
- raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED,
188
- detail="Incorrect username or password",
189
- headers={"WWW-Authenticate": "Bearer"})
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)) -> Optional[User]:
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
- # Parameters API Helpers
200
- parameters_path = base_path + '/dataset/{dataset}/parameters'
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 get_dataset_for_parameters_request(request: Request) -> str:
203
- return get_dataset_from_request_path(request, -2)
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
- user: Optional[User], dataset: str, selections: Iterable[tuple[str, str]], request_version: Optional[int]
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
- dag = ModelsIO.GenerateDAG(dataset)
214
- dag.apply_selections(user, dict(selections), updates_only=True, request_version=request_version)
215
- return dag.parameter_set
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(*args) -> T:
220
- return await do_cachable_action(params_cache, get_parameters_helper, *args)
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(dataset: str, user: Optional[User], headers: Mapping, params: Mapping) -> arm.ParametersModel:
223
- api_function = get_parameters_helper if self.no_cache else get_parameters_cachable
224
- result = await apply_dataset_api_function(api_function, user, dataset, headers, params)
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
- # Results API Helpers
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
- results_cache_size = ManifestIO.obj.settings.get(c.RESULTS_CACHE_SIZE_SETTING, 128)
236
- results_cache_ttl = ManifestIO.obj.settings.get(c.RESULTS_CACHE_TTL_SETTING, 60)
237
-
238
- async def get_results_helper(
239
- user: Optional[User], dataset: str, selections: Iterable[tuple[str, str]], request_version: Optional[int]
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
- dag = ModelsIO.GenerateDAG(dataset)
242
- await dag.execute(ModelsIO.context_func, user, dict(selections), request_version=request_version)
243
- return dag.target_model.result
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
- results_cache = TTLCache(maxsize=results_cache_size, ttl=results_cache_ttl*60)
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 get_results_cachable(*args) -> pd.DataFrame:
248
- return await do_cachable_action(results_cache, get_results_helper, *args)
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 get_results_definition(dataset: str, user: Optional[User], headers: Mapping, params: Mapping) -> arm.DatasetResultModel:
251
- api_function = get_results_helper if self.no_cache else get_results_cachable
252
- result = await apply_dataset_api_function(api_function, user, dataset, headers, params)
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: df_to_api_response0(result)
366
+ 0: lambda: arm.DatasetResultModel(**u.df_to_json0(result))
255
367
  })
256
368
 
257
- param_fields = ParameterConfigsSetIO.obj.get_all_api_field_info()
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, dataset_cfg in self.dataset_configs.items():
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 = parameters_path.format(dataset=dataset_normalized)
263
- curr_results_path = results_path.format(dataset=dataset_normalized)
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
- for param in dataset_cfg.parameters:
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
- QueryModelGet = make_dataclass("QueryParams", [
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
- QueryModelPost = create_model("RequestBodyParams", **{
277
- param: param_fields[param].as_body_info() for param in dataset_cfg.parameters
278
- })
279
-
280
- @app.get(curr_parameters_path, response_class=JSONResponse)
281
- async def get_parameters(
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
- dataset = get_dataset_for_parameters_request(request)
286
- result = await get_parameters_definition(dataset, user, request.headers, asdict(params))
287
- timer.add_activity_time("GET REQUEST total time for PARAMETERS endpoint", start)
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(curr_parameters_path, response_class=JSONResponse)
291
- async def get_parameters_with_post(
292
- request: Request, params: QueryModelPost, user: Optional[User] = Depends(get_current_user) # type: ignore
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
- dataset = get_dataset_for_parameters_request(request)
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(dataset, user, request.headers, params.model_dump())
298
- timer.add_activity_time("POST REQUEST total time for PARAMETERS endpoint", start)
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 get_results(
303
- request: Request, params: AnnotatedQueryModel, user: Optional[User] = Depends(get_current_user) # type: ignore
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
- dataset = get_dataset_for_results_request(request)
307
- result = await get_results_definition(dataset, user, request.headers, asdict(params))
308
- timer.add_activity_time("GET REQUEST total time for DATASET endpoint", start)
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 get_results_with_post(
313
- request: Request, params: QueryModelPost, user: Optional[User] = Depends(get_current_user) # type: ignore
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
- dataset = get_dataset_for_results_request(request)
457
+ curr_dataset_name = get_dataset_name(request, -1)
317
458
  params: BaseModel = params
318
- result = await get_results_definition(dataset, user, request.headers, params.model_dump())
319
- timer.add_activity_time("POST REQUEST total time for DATASET endpoint", start)
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
- # Datasets Catalog API
323
- datasets_path = base_path + '/datasets'
324
-
325
- def get_datasets0(user: Optional[User]) -> arm.DatasetsCatalogModel:
326
- datasets_info = []
327
- for dataset_name, dataset_config in self.dataset_configs.items():
328
- if can_user_access_dataset(user, dataset_name):
329
- dataset_normalized = u.normalize_name_for_api(dataset_name)
330
- datasets_info.append(arm.DatasetInfoModel(
331
- name=dataset_name, label=dataset_config.label,
332
- parameters_path=parameters_path.format(dataset=dataset_normalized),
333
- result_path=results_path.format(dataset=dataset_normalized)
334
- ))
335
- return arm.DatasetsCatalogModel(datasets=datasets_info)
336
-
337
- @app.get(datasets_path)
338
- def get_datasets(request: Request, user: Optional[User] = Depends(get_current_user)) -> arm.DatasetsCatalogModel:
339
- return process_based_on_response_version_header(request.headers, {
340
- 0: lambda: get_datasets0(user)
341
- })
342
-
343
- # Projects Catalog API
344
- def get_catalog0() -> arm.CatalogModel:
345
- return arm.CatalogModel(projects=[arm.ProjectModel(
346
- name=ManifestIO.obj.project_variables.get_name(),
347
- label=ManifestIO.obj.project_variables.get_label(),
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=ManifestIO.obj.project_variables.get_major_version(),
523
+ major_version=self.manifest_cfg.project_variables.major_version,
350
524
  minor_versions=[0],
351
525
  token_path=token_path,
352
- datasets_path=datasets_path
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 get_catalog(request: Request) -> arm.CatalogModel:
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: get_catalog0()
533
+ 0: lambda: get_project_metadata0()
360
534
  })
361
535
 
362
- # Squirrels UI
363
- static_dir = u.join_paths(os.path.dirname(__file__), c.PACKAGE_DATA_FOLDER, c.ASSETS_FOLDER)
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.join_paths(os.path.dirname(__file__), c.PACKAGE_DATA_FOLDER, c.TEMPLATES_FOLDER)
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 get_ui(request: Request):
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, 'catalog_path': squirrels_version_path, 'token_path': token_path
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
- timer.add_activity_time("creating app for api server", start)
551
+ self.logger.log_activity_time("creating app server", start)
378
552
  uvicorn.run(app, host=uvicorn_args.host, port=uvicorn_args.port)