squirrels 0.4.0__py3-none-any.whl → 0.5.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 (125) hide show
  1. dateutils/__init__.py +6 -0
  2. dateutils/_enums.py +25 -0
  3. squirrels/dateutils.py → dateutils/_implementation.py +58 -111
  4. dateutils/types.py +6 -0
  5. squirrels/__init__.py +13 -11
  6. squirrels/_api_routes/__init__.py +5 -0
  7. squirrels/_api_routes/auth.py +271 -0
  8. squirrels/_api_routes/base.py +165 -0
  9. squirrels/_api_routes/dashboards.py +150 -0
  10. squirrels/_api_routes/data_management.py +145 -0
  11. squirrels/_api_routes/datasets.py +257 -0
  12. squirrels/_api_routes/oauth2.py +298 -0
  13. squirrels/_api_routes/project.py +252 -0
  14. squirrels/_api_server.py +256 -450
  15. squirrels/_arguments/__init__.py +0 -0
  16. squirrels/_arguments/init_time_args.py +108 -0
  17. squirrels/_arguments/run_time_args.py +147 -0
  18. squirrels/_auth.py +960 -0
  19. squirrels/_command_line.py +126 -45
  20. squirrels/_compile_prompts.py +147 -0
  21. squirrels/_connection_set.py +48 -26
  22. squirrels/_constants.py +68 -38
  23. squirrels/_dashboards.py +160 -0
  24. squirrels/_data_sources.py +570 -0
  25. squirrels/_dataset_types.py +84 -0
  26. squirrels/_exceptions.py +29 -0
  27. squirrels/_initializer.py +177 -80
  28. squirrels/_logging.py +115 -0
  29. squirrels/_manifest.py +208 -79
  30. squirrels/_model_builder.py +69 -0
  31. squirrels/_model_configs.py +74 -0
  32. squirrels/_model_queries.py +52 -0
  33. squirrels/_models.py +926 -367
  34. squirrels/_package_data/base_project/.env +42 -0
  35. squirrels/_package_data/base_project/.env.example +42 -0
  36. squirrels/_package_data/base_project/assets/expenses.db +0 -0
  37. squirrels/_package_data/base_project/connections.yml +16 -0
  38. squirrels/_package_data/base_project/dashboards/dashboard_example.py +34 -0
  39. squirrels/_package_data/base_project/dashboards/dashboard_example.yml +22 -0
  40. squirrels/{package_data → _package_data}/base_project/docker/.dockerignore +5 -2
  41. squirrels/{package_data → _package_data}/base_project/docker/Dockerfile +3 -3
  42. squirrels/{package_data → _package_data}/base_project/docker/compose.yml +1 -1
  43. squirrels/_package_data/base_project/duckdb_init.sql +10 -0
  44. squirrels/{package_data/base_project/.gitignore → _package_data/base_project/gitignore} +3 -2
  45. squirrels/_package_data/base_project/macros/macros_example.sql +17 -0
  46. squirrels/_package_data/base_project/models/builds/build_example.py +26 -0
  47. squirrels/_package_data/base_project/models/builds/build_example.sql +16 -0
  48. squirrels/_package_data/base_project/models/builds/build_example.yml +57 -0
  49. squirrels/_package_data/base_project/models/dbviews/dbview_example.sql +12 -0
  50. squirrels/_package_data/base_project/models/dbviews/dbview_example.yml +26 -0
  51. squirrels/_package_data/base_project/models/federates/federate_example.py +37 -0
  52. squirrels/_package_data/base_project/models/federates/federate_example.sql +19 -0
  53. squirrels/_package_data/base_project/models/federates/federate_example.yml +65 -0
  54. squirrels/_package_data/base_project/models/sources.yml +38 -0
  55. squirrels/{package_data → _package_data}/base_project/parameters.yml +56 -40
  56. squirrels/_package_data/base_project/pyconfigs/connections.py +14 -0
  57. squirrels/{package_data → _package_data}/base_project/pyconfigs/context.py +21 -40
  58. squirrels/_package_data/base_project/pyconfigs/parameters.py +141 -0
  59. squirrels/_package_data/base_project/pyconfigs/user.py +44 -0
  60. squirrels/_package_data/base_project/seeds/seed_categories.yml +15 -0
  61. squirrels/_package_data/base_project/seeds/seed_subcategories.csv +15 -0
  62. squirrels/_package_data/base_project/seeds/seed_subcategories.yml +21 -0
  63. squirrels/_package_data/base_project/squirrels.yml.j2 +61 -0
  64. squirrels/_package_data/templates/dataset_results.html +112 -0
  65. squirrels/_package_data/templates/oauth_login.html +271 -0
  66. squirrels/_package_data/templates/squirrels_studio.html +20 -0
  67. squirrels/_package_loader.py +8 -4
  68. squirrels/_parameter_configs.py +104 -103
  69. squirrels/_parameter_options.py +348 -0
  70. squirrels/_parameter_sets.py +57 -47
  71. squirrels/_parameters.py +1664 -0
  72. squirrels/_project.py +721 -0
  73. squirrels/_py_module.py +7 -5
  74. squirrels/_schemas/__init__.py +0 -0
  75. squirrels/_schemas/auth_models.py +167 -0
  76. squirrels/_schemas/query_param_models.py +75 -0
  77. squirrels/{_api_response_models.py → _schemas/response_models.py} +126 -47
  78. squirrels/_seeds.py +35 -16
  79. squirrels/_sources.py +110 -0
  80. squirrels/_utils.py +248 -73
  81. squirrels/_version.py +1 -1
  82. squirrels/arguments.py +7 -0
  83. squirrels/auth.py +4 -0
  84. squirrels/connections.py +3 -0
  85. squirrels/dashboards.py +2 -81
  86. squirrels/data_sources.py +14 -631
  87. squirrels/parameter_options.py +13 -348
  88. squirrels/parameters.py +14 -1266
  89. squirrels/types.py +16 -0
  90. squirrels-0.5.0.dist-info/METADATA +113 -0
  91. squirrels-0.5.0.dist-info/RECORD +97 -0
  92. {squirrels-0.4.0.dist-info → squirrels-0.5.0.dist-info}/WHEEL +1 -1
  93. squirrels-0.5.0.dist-info/entry_points.txt +3 -0
  94. {squirrels-0.4.0.dist-info → squirrels-0.5.0.dist-info/licenses}/LICENSE +1 -1
  95. squirrels/_authenticator.py +0 -85
  96. squirrels/_dashboards_io.py +0 -61
  97. squirrels/_environcfg.py +0 -84
  98. squirrels/arguments/init_time_args.py +0 -40
  99. squirrels/arguments/run_time_args.py +0 -208
  100. squirrels/package_data/assets/favicon.ico +0 -0
  101. squirrels/package_data/assets/index.css +0 -1
  102. squirrels/package_data/assets/index.js +0 -58
  103. squirrels/package_data/base_project/assets/expenses.db +0 -0
  104. squirrels/package_data/base_project/connections.yml +0 -7
  105. squirrels/package_data/base_project/dashboards/dashboard_example.py +0 -32
  106. squirrels/package_data/base_project/dashboards.yml +0 -10
  107. squirrels/package_data/base_project/env.yml +0 -29
  108. squirrels/package_data/base_project/models/dbviews/dbview_example.py +0 -47
  109. squirrels/package_data/base_project/models/dbviews/dbview_example.sql +0 -22
  110. squirrels/package_data/base_project/models/federates/federate_example.py +0 -21
  111. squirrels/package_data/base_project/models/federates/federate_example.sql +0 -3
  112. squirrels/package_data/base_project/pyconfigs/auth.py +0 -45
  113. squirrels/package_data/base_project/pyconfigs/connections.py +0 -19
  114. squirrels/package_data/base_project/pyconfigs/parameters.py +0 -95
  115. squirrels/package_data/base_project/seeds/seed_subcategories.csv +0 -15
  116. squirrels/package_data/base_project/squirrels.yml.j2 +0 -94
  117. squirrels/package_data/templates/index.html +0 -18
  118. squirrels/project.py +0 -378
  119. squirrels/user_base.py +0 -55
  120. squirrels-0.4.0.dist-info/METADATA +0 -117
  121. squirrels-0.4.0.dist-info/RECORD +0 -60
  122. squirrels-0.4.0.dist-info/entry_points.txt +0 -4
  123. /squirrels/{package_data → _package_data}/base_project/assets/weather.db +0 -0
  124. /squirrels/{package_data → _package_data}/base_project/seeds/seed_categories.csv +0 -0
  125. /squirrels/{package_data → _package_data}/base_project/tmp/.gitignore +0 -0
squirrels/_api_server.py CHANGED
@@ -1,26 +1,82 @@
1
- from typing import Coroutine, Mapping, Callable, TypeVar, Annotated, Any
2
- from dataclasses import make_dataclass, asdict
3
- from fastapi import Depends, FastAPI, Request, HTTPException, Response, status
4
- from fastapi.responses import HTMLResponse, JSONResponse
1
+ from fastapi import FastAPI, Request, status
2
+ from fastapi.responses import JSONResponse, HTMLResponse, RedirectResponse
3
+ from fastapi.security import HTTPBearer
5
4
  from fastapi.templating import Jinja2Templates
6
5
  from fastapi.staticfiles import StaticFiles
7
- from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
8
- from fastapi.middleware.cors import CORSMiddleware
9
- from pydantic import create_model, BaseModel
10
- from cachetools import TTLCache
6
+ from starlette.middleware.base import BaseHTTPMiddleware
7
+ from starlette.responses import Response as StarletteResponse
8
+ from contextlib import asynccontextmanager
11
9
  from argparse import Namespace
12
- import os, io, time, mimetypes, traceback, uuid, pandas as pd
13
-
14
- from . import _constants as c, _utils as u, _api_response_models as arm
15
- from ._version import sq_major_version
16
- from ._authenticator import User
17
- from ._parameter_sets import ParameterSet
18
- from .dashboards import Dashboard
19
- from .project import SquirrelsProject
10
+ from pathlib import Path
11
+ from starlette.middleware.sessions import SessionMiddleware
12
+ from mcp.server.fastmcp import FastMCP
13
+ import io, time, mimetypes, traceback, uuid, asyncio, contextlib
14
+
15
+ from . import _constants as c, _utils as u, _parameter_sets as ps
16
+ from ._exceptions import InvalidInputError, ConfigurationError, FileExecutionError
17
+ from ._version import __version__, sq_major_version
18
+ from ._project import SquirrelsProject
19
+
20
+ # Import route modules
21
+ from ._api_routes.auth import AuthRoutes
22
+ from ._api_routes.project import ProjectRoutes
23
+ from ._api_routes.datasets import DatasetRoutes
24
+ from ._api_routes.dashboards import DashboardRoutes
25
+ from ._api_routes.data_management import DataManagementRoutes
26
+
27
+ # # Disabled for now, a 'bring your own OAuth2 server' approach will be provided in the future
28
+ # from ._api_routes.oauth2 import OAuth2Routes
20
29
 
21
30
  mimetypes.add_type('application/javascript', '.js')
22
31
 
23
32
 
33
+ class SmartCORSMiddleware(BaseHTTPMiddleware):
34
+ """
35
+ Custom CORS middleware that allows specific origins to use credentials
36
+ while still allowing all other origins without credentials.
37
+ """
38
+
39
+ def __init__(self, app, allowed_credential_origins: list[str], configurables_as_headers: list[str]):
40
+ super().__init__(app)
41
+
42
+ allowed_predefined_headers = ["Authorization", "Content-Type", "x-api-key", "x-orientation", "x-verify-params"]
43
+
44
+ self.allowed_credential_origins = allowed_credential_origins
45
+ self.allowed_request_headers = ",".join(allowed_predefined_headers + configurables_as_headers)
46
+
47
+ async def dispatch(self, request: Request, call_next):
48
+ origin = request.headers.get("origin")
49
+
50
+ # Handle preflight requests
51
+ if request.method == "OPTIONS":
52
+ response = StarletteResponse(status_code=200)
53
+ response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
54
+ response.headers["Access-Control-Allow-Headers"] = self.allowed_request_headers
55
+
56
+ else:
57
+ # Call the next middleware/route
58
+ response: StarletteResponse = await call_next(request)
59
+
60
+ # Always expose the Applied-Username header
61
+ response.headers["Access-Control-Expose-Headers"] = "Applied-Username"
62
+
63
+ if origin:
64
+ scheme = u.get_scheme(request.url.hostname)
65
+ request_origin = f"{scheme}://{request.url.netloc}"
66
+ # Check if this origin is in the whitelist or if origin matches the host origin
67
+ if origin == request_origin or origin in self.allowed_credential_origins:
68
+ response.headers["Access-Control-Allow-Origin"] = origin
69
+ response.headers["Access-Control-Allow-Credentials"] = "true"
70
+ else:
71
+ # Allow all other origins but without credentials / cookies
72
+ response.headers["Access-Control-Allow-Origin"] = "*"
73
+ else:
74
+ # No origin header (same-origin request or non-browser)
75
+ response.headers["Access-Control-Allow-Origin"] = "*"
76
+
77
+ return response
78
+
79
+
24
80
  class ApiServer:
25
81
  def __init__(self, no_cache: bool, project: SquirrelsProject) -> None:
26
82
  """
@@ -32,41 +88,101 @@ class ApiServer:
32
88
  self.no_cache = no_cache
33
89
  self.project = project
34
90
  self.logger = project._logger
35
-
91
+ self.env_vars = project._env_vars
36
92
  self.j2_env = project._j2_env
37
- self.env_cfg = project._env_cfg
38
93
  self.manifest_cfg = project._manifest_cfg
39
94
  self.seeds = project._seeds
40
95
  self.conn_args = project._conn_args
41
96
  self.conn_set = project._conn_set
42
- self.authenticator = project._authenticator
97
+ self.authenticator = project._auth
43
98
  self.param_args = project._param_args
44
99
  self.param_cfg_set = project._param_cfg_set
45
100
  self.context_func = project._context_func
46
- self.model_files = project._model_files
47
101
  self.dashboards = project._dashboards
102
+
103
+ self.mcp = FastMCP(
104
+ name="Squirrels",
105
+ stateless_http=True
106
+ )
107
+
108
+ # Initialize route modules
109
+ get_bearer_token = HTTPBearer(auto_error=False)
110
+ # self.oauth2_routes = OAuth2Routes(get_bearer_token, project, no_cache)
111
+ self.auth_routes = AuthRoutes(get_bearer_token, project, no_cache)
112
+ self.project_routes = ProjectRoutes(get_bearer_token, project, no_cache)
113
+ self.dataset_routes = DatasetRoutes(get_bearer_token, project, no_cache)
114
+ self.dashboard_routes = DashboardRoutes(get_bearer_token, project, no_cache)
115
+ self.data_management_routes = DataManagementRoutes(get_bearer_token, project, no_cache)
48
116
 
49
- def run(self, uvicorn_args: Namespace) -> None:
117
+
118
+ async def _refresh_datasource_params(self) -> None:
50
119
  """
51
- Runs the API server with uvicorn for CLI "squirrels run"
52
-
53
- Arguments:
54
- uvicorn_args: List of arguments to pass to uvicorn.run. Currently only supports "host" and "port"
120
+ Background task to periodically refresh datasource parameter options.
121
+ Runs every N minutes as configured by SQRL_PARAMETERS__DATASOURCE_REFRESH_MINUTES (default: 60).
55
122
  """
56
- start = time.time()
123
+ refresh_minutes_str = self.env_vars.get(c.SQRL_PARAMETERS_DATASOURCE_REFRESH_MINUTES, "60")
124
+ try:
125
+ refresh_minutes = int(refresh_minutes_str)
126
+ if refresh_minutes <= 0:
127
+ self.logger.info(f"The value of {c.SQRL_PARAMETERS_DATASOURCE_REFRESH_MINUTES} is: {refresh_minutes_str} minutes")
128
+ self.logger.info(f"Datasource parameter refresh is disabled since the refresh interval is not positive.")
129
+ return
130
+ except ValueError:
131
+ self.logger.warning(f"Invalid value for {c.SQRL_PARAMETERS_DATASOURCE_REFRESH_MINUTES}: {refresh_minutes_str}. Must be an integer. Disabling datasource parameter refresh.")
132
+ return
133
+
134
+ refresh_seconds = refresh_minutes * 60
135
+ self.logger.info(f"Starting datasource parameter refresh background task (every {refresh_minutes} minutes)")
57
136
 
137
+ while True:
138
+ try:
139
+ await asyncio.sleep(refresh_seconds)
140
+ self.logger.info("Refreshing datasource parameter options...")
141
+
142
+ # Fetch fresh dataframes from datasources in a thread pool to avoid blocking
143
+ loop = asyncio.get_running_loop()
144
+ default_conn_name = self.manifest_cfg.env_vars.get(c.SQRL_CONNECTIONS_DEFAULT_NAME_USED, "default")
145
+ df_dict = await loop.run_in_executor(
146
+ None,
147
+ ps.ParameterConfigsSetIO._get_df_dict_from_data_sources,
148
+ self.param_cfg_set,
149
+ default_conn_name,
150
+ self.seeds,
151
+ self.conn_set,
152
+ self.project._datalake_db_path
153
+ )
154
+
155
+ # Re-convert datasource parameters with fresh data
156
+ self.param_cfg_set._post_process_params(df_dict)
157
+
158
+ self.logger.info("Successfully refreshed datasource parameter options")
159
+ except asyncio.CancelledError:
160
+ self.logger.info("Datasource parameter refresh task cancelled")
161
+ break
162
+ except Exception as e:
163
+ self.logger.error(f"Error refreshing datasource parameter options: {e}", exc_info=True)
164
+ # Continue the loop even if there's an error
165
+
166
+ @asynccontextmanager
167
+ async def _run_background_tasks(self, app: FastAPI):
168
+ refresh_datasource_task = asyncio.create_task(self._refresh_datasource_params())
169
+
170
+ async with contextlib.AsyncExitStack() as stack:
171
+ await stack.enter_async_context(self.mcp.session_manager.run())
172
+ yield
173
+
174
+ refresh_datasource_task.cancel()
175
+
176
+
177
+ def _get_tags_metadata(self) -> list[dict]:
58
178
  tags_metadata = [
59
179
  {
60
180
  "name": "Project Metadata",
61
181
  "description": "Get information on project such as name, version, and other API endpoints",
62
182
  },
63
183
  {
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",
184
+ "name": "Data Management",
185
+ "description": "Actions to update the data components of the project",
70
186
  }
71
187
  ]
72
188
 
@@ -76,17 +192,60 @@ class ApiServer:
76
192
  "description": f"Get parameters or results for dataset '{dataset_name}'",
77
193
  })
78
194
 
79
- for dashboard_name in self.manifest_cfg.dashboards:
195
+ for dashboard_name in self.dashboards:
80
196
  tags_metadata.append({
81
197
  "name": f"Dashboard '{dashboard_name}'",
82
198
  "description": f"Get parameters or results for dashboard '{dashboard_name}'",
83
199
  })
84
200
 
201
+ tags_metadata.extend([
202
+ {
203
+ "name": "Authentication",
204
+ "description": "Submit authentication credentials and authorize with a session cookie",
205
+ },
206
+ {
207
+ "name": "User Management",
208
+ "description": "Manage users and their attributes",
209
+ },
210
+ # {
211
+ # "name": "OAuth2",
212
+ # "description": "Authorize and get token using the OAuth2 protocol",
213
+ # },
214
+ ])
215
+ return tags_metadata
216
+
217
+
218
+ def run(self, uvicorn_args: Namespace) -> None:
219
+ """
220
+ Runs the API server with uvicorn for CLI "squirrels run"
221
+
222
+ Arguments:
223
+ uvicorn_args: List of arguments to pass to uvicorn.run. Currently only supports "host" and "port"
224
+ """
225
+ start = time.time()
226
+
227
+ squirrels_version_path = f'/api/squirrels/v{sq_major_version}'
228
+ project_name = self.manifest_cfg.project_variables.name
229
+ project_name_for_api = u.normalize_name_for_api(project_name)
230
+ project_label = self.manifest_cfg.project_variables.label
231
+ project_version = f"v{self.manifest_cfg.project_variables.major_version}"
232
+ project_metadata_path = squirrels_version_path + f"/project/{project_name_for_api}/{project_version}"
233
+
234
+ param_fields = self.param_cfg_set.get_all_api_field_info()
235
+
236
+ tags_metadata = self._get_tags_metadata()
237
+
85
238
  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"
239
+ title=f"Squirrels APIs for '{project_label}'", openapi_tags=tags_metadata,
240
+ 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",
241
+ lifespan=self._run_background_tasks,
242
+ openapi_url=project_metadata_path+"/openapi.json",
243
+ docs_url=project_metadata_path+"/docs",
244
+ redoc_url=project_metadata_path+"/redoc"
88
245
  )
89
246
 
247
+ app.add_middleware(SessionMiddleware, secret_key=self.env_vars.get(c.SQRL_SECRET_KEY, ""), max_age=None, same_site="none", https_only=True)
248
+
90
249
  async def _log_request_run(request: Request) -> None:
91
250
  headers = dict(request.scope["headers"])
92
251
  request_id = uuid.uuid4().hex
@@ -104,9 +263,6 @@ class ApiServer:
104
263
  data = {"request_method": request.method, "request_path": path, "request_params": params, "request_headers": headers_dict, "request_body": body}
105
264
  info = {"request_id": request_id}
106
265
  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", "")
110
266
 
111
267
  @app.middleware("http")
112
268
  async def catch_exceptions_middleware(request: Request, call_next):
@@ -114,439 +270,89 @@ class ApiServer:
114
270
  try:
115
271
  await _log_request_run(request)
116
272
  return await call_next(request)
117
- except u.InvalidInputError as exc:
118
- traceback.print_exc(file=buffer)
273
+ except InvalidInputError as exc:
274
+ message = str(exc)
275
+ self.logger.error(message)
119
276
  response = JSONResponse(
120
- status_code=status.HTTP_400_BAD_REQUEST, content={"message": str(exc), "blame": "API client"}
277
+ status_code=exc.status_code, content={"error": exc.error, "error_description": exc.error_description}
121
278
  )
122
- except u.FileExecutionError as exc:
279
+ except FileExecutionError as exc:
123
280
  traceback.print_exception(exc.error, file=buffer)
124
281
  buffer.write(str(exc))
125
282
  response = JSONResponse(
126
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"message": f"An unexpected error occurred", "blame": "Squirrels project"}
283
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"message": f"An unexpected server error occurred", "blame": "Squirrels project"}
127
284
  )
128
- except u.ConfigurationError as exc:
285
+ except ConfigurationError as exc:
129
286
  traceback.print_exc(file=buffer)
130
287
  response = JSONResponse(
131
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"message": f"An unexpected error occurred", "blame": "Squirrels project"}
288
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"message": f"An unexpected server error occurred", "blame": "Squirrels project"}
132
289
  )
133
290
  except Exception as exc:
134
291
  traceback.print_exc(file=buffer)
135
292
  response = JSONResponse(
136
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"message": f"An unexpected error occurred", "blame": "Squirrels framework"}
293
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"message": f"An unexpected server error occurred", "blame": "Squirrels framework"}
137
294
  )
138
295
 
139
296
  err_msg = buffer.getvalue()
140
- self.logger.error(err_msg)
141
- print(err_msg)
297
+ if err_msg:
298
+ self.logger.error(err_msg)
299
+ # print(err_msg)
142
300
  return response
143
301
 
144
- app.add_middleware(
145
- CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"],
146
- expose_headers=["Applied-Username"]
147
- )
148
-
149
- squirrels_version_path = f'/squirrels-v{sq_major_version}'
150
- partial_base_path = f'/{self.manifest_cfg.project_variables.name}/v{self.manifest_cfg.project_variables.major_version}'
151
- base_path = squirrels_version_path + u.normalize_name_for_api(partial_base_path)
302
+ # Configure CORS with smart credential handling
303
+ # Get allowed origins for credentials from environment variable
304
+ credential_origins_env = self.env_vars.get(c.SQRL_AUTH_CREDENTIAL_ORIGINS, "https://squirrels-analytics.github.io")
305
+ allowed_credential_origins = [origin.strip() for origin in credential_origins_env.split(",") if origin.strip()]
306
+ configurables_as_headers = [f"x-config-{u.normalize_name_for_api(name)}" for name in self.manifest_cfg.configurables.keys()]
152
307
 
153
- # Helpers
154
- T = TypeVar('T')
155
-
156
- def get_versioning_request_header(headers: Mapping, header_key: str):
157
- header_value = headers.get(header_key)
158
- if header_value is None:
159
- return None
160
-
161
- try:
162
- result = int(header_value)
163
- except ValueError:
164
- raise u.InvalidInputError(f"Request header '{header_key}' must be an integer. Got '{header_value}'")
165
-
166
- if result < 0 or result > int(sq_major_version):
167
- raise u.InvalidInputError(f"Request header '{header_key}' not in valid range. Got '{result}'")
168
-
169
- return result
170
-
171
- def get_request_version_header(headers: Mapping):
172
- REQUEST_VERSION_REQUEST_HEADER = "squirrels-request-version"
173
- return get_versioning_request_header(headers, REQUEST_VERSION_REQUEST_HEADER)
174
-
175
- def process_based_on_response_version_header(headers: Mapping, processes: dict[int, Callable[[], T]]) -> T:
176
- RESPONSE_VERSION_REQUEST_HEADER = "squirrels-response-version"
177
- response_version = get_versioning_request_header(headers, RESPONSE_VERSION_REQUEST_HEADER)
178
- if response_version is None or response_version >= 0:
179
- return processes[0]()
180
- else:
181
- raise u.InvalidInputError(f'Invalid value for "{RESPONSE_VERSION_REQUEST_HEADER}" header: {response_version}')
308
+ app.add_middleware(SmartCORSMiddleware, allowed_credential_origins=allowed_credential_origins, configurables_as_headers=configurables_as_headers)
182
309
 
183
- def get_selections_and_request_version(params: Mapping, headers: Mapping) -> tuple[frozenset[tuple[str, Any]], int | None]:
184
- # Changing selections into a cachable "frozenset" that will later be converted to dictionary
185
- selections = set()
186
- for key, val in params.items():
187
- if val is None:
188
- continue
189
- if isinstance(val, (list, tuple)):
190
- if len(val) == 1: # for backward compatibility
191
- val = val[0]
192
- else:
193
- val = tuple(val)
194
- selections.add((u.normalize_name(key), val))
195
- selections = frozenset(selections)
196
-
197
- request_version = get_request_version_header(headers)
198
- return selections, request_version
199
-
200
- async def do_cachable_action(cache: TTLCache, action: Callable[..., Coroutine[Any, Any, T]], *args) -> T:
201
- cache_key = tuple(args)
202
- result = cache.get(cache_key)
203
- if result is None:
204
- result = await action(*args)
205
- cache[cache_key] = result
206
- return result
207
-
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:
220
- url_path: str = request.scope['route'].path
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)
230
-
231
- # Login & Authorization
232
- token_path = base_path + '/token'
233
-
234
- oauth2_scheme = OAuth2PasswordBearer(tokenUrl=token_path, auto_error=False)
235
-
236
- @app.post(token_path, tags=["Login"])
237
- async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()) -> arm.LoginReponse:
238
- user: User | None = self.authenticator.authenticate_user(form_data.username, form_data.password)
239
- if not user:
240
- raise HTTPException(
241
- status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect username or password", headers={"WWW-Authenticate": "Bearer"}
242
- )
243
- access_token, expiry = self.authenticator.create_access_token(user)
244
- return arm.LoginReponse(access_token=access_token, token_type="bearer", username=user.username, expiry_time=expiry)
245
-
246
- async def get_current_user(response: Response, token: str = Depends(oauth2_scheme)) -> User | None:
247
- user = self.authenticator.get_user_from_token(token)
248
- username = "" if user is None else user.username
249
- response.headers["Applied-Username"] = username
250
- return user
251
-
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'
258
-
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."
299
-
300
- async def get_parameters_helper(
301
- parameters_tuple: tuple[str, ...], user: User | None, selections: frozenset[tuple[str, Any]], request_version: int | None
302
- ) -> ParameterSet:
303
- if len(selections) > 1:
304
- raise u.InvalidInputError(f"The /parameters endpoint takes at most 1 query parameter. Got {dict(selections)}")
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)
314
- params_cache = TTLCache(maxsize=parameters_cache_size, ttl=parameters_cache_ttl*60)
315
-
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)
320
-
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)
327
- return process_based_on_response_version_header(headers, {
328
- 0: result.to_api_response_model0
329
- })
330
-
331
- param_fields = self.param_cfg_set.get_all_api_field_info()
332
-
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
343
- ) -> pd.DataFrame:
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
348
-
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)
353
-
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)
358
-
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)
365
- return process_based_on_response_version_header(headers, {
366
- 0: lambda: arm.DatasetResultModel(**u.df_to_json0(result))
367
- })
368
-
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
402
-
403
- # Dataset Parameters and Results APIs
404
- for dataset_name, dataset_config in self.manifest_cfg.datasets.items():
405
- dataset_normalized = u.normalize_name_for_api(dataset_name)
406
- curr_parameters_path = dataset_parameters_path.format(dataset=dataset_normalized)
407
- curr_results_path = dataset_results_path.format(dataset=dataset_normalized)
408
-
409
- validate_parameters_list(dataset_config.parameters, "Dataset")
410
-
411
- QueryModelForGet, QueryModelForPost = get_query_models_from_widget_params(dataset_config.parameters)
412
-
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
419
- ) -> arm.ParametersModel:
420
- start = time.time()
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))
425
- return result
426
-
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
433
- ) -> arm.ParametersModel:
434
- start = time.time()
435
- curr_dataset_name = get_dataset_name(request, -2)
436
- parameters_list = self.manifest_cfg.datasets[curr_dataset_name].parameters
437
- params: BaseModel = params
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))
440
- return result
441
-
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
445
- ) -> arm.DatasetResultModel:
446
- start = time.time()
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))
450
- return result
451
-
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
455
- ) -> arm.DatasetResultModel:
456
- start = time.time()
457
- curr_dataset_name = get_dataset_name(request, -1)
458
- params: BaseModel = params
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))
461
- return result
310
+ # Setup route modules
311
+ # self.oauth2_routes.setup_routes(app, squirrels_version_path)
312
+ self.auth_routes.setup_routes(app, squirrels_version_path)
313
+ get_parameters_definition = self.project_routes.setup_routes(app, self.mcp, project_metadata_path, project_name, project_version, project_label, param_fields)
314
+ self.data_management_routes.setup_routes(app, project_metadata_path, param_fields)
315
+ self.dataset_routes.setup_routes(app, self.mcp, project_metadata_path, project_name, project_label, param_fields, get_parameters_definition)
316
+ self.dashboard_routes.setup_routes(app, project_metadata_path, param_fields, get_parameters_definition)
317
+ app.mount(project_metadata_path, self.mcp.streamable_http_app())
318
+
319
+ # Mount static files from public directory if it exists
320
+ # This allows users to serve static assets (images, CSS, JS, etc.) from {project_path}/public/
321
+ public_dir = Path(self.project._filepath) / c.PUBLIC_FOLDER
322
+ if public_dir.exists() and public_dir.is_dir():
323
+ app.mount("/public", StaticFiles(directory=str(public_dir)), name="public")
324
+ self.logger.info(f"Mounted static files from: {public_dir}")
325
+
326
+ # Add Root Path Redirection to Squirrels Studio
327
+ full_hostname = f"http://{uvicorn_args.host}:{uvicorn_args.port}"
328
+ squirrels_studio_path = f"/project/{project_name_for_api}/{project_version}/studio"
329
+ templates = Jinja2Templates(directory=str(Path(__file__).parent / "_package_data" / "templates"))
330
+
331
+ @app.get(squirrels_studio_path, include_in_schema=False)
332
+ async def squirrels_studio():
333
+ default_studio_path = "https://squirrels-analytics.github.io/squirrels-studio-v1"
334
+ sqrl_studio_base_url = self.env_vars.get(c.SQRL_STUDIO_BASE_URL, default_studio_path)
335
+ context = {
336
+ "sqrl_studio_base_url": sqrl_studio_base_url,
337
+ "project_name": project_name_for_api,
338
+ "project_version": project_version,
339
+ }
340
+ return HTMLResponse(content=templates.get_template("squirrels_studio.html").render(context))
462
341
 
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)
342
+ @app.get("/", include_in_schema=False)
343
+ async def redirect_to_studio():
344
+ return RedirectResponse(url=squirrels_studio_path)
472
345
 
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
346
+ self.logger.log_activity_time("creating app server", start)
516
347
 
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,
522
- versions=[arm.ProjectVersionModel(
523
- major_version=self.manifest_cfg.project_variables.major_version,
524
- minor_versions=[0],
525
- token_path=token_path,
526
- data_catalog_path=data_catalog_path
527
- )]
528
- )
529
-
530
- @app.get(squirrels_version_path, tags=["Project Metadata"], response_class=JSONResponse)
531
- async def get_project_metadata(request: Request) -> arm.ProjectModel:
532
- return process_based_on_response_version_header(request.headers, {
533
- 0: lambda: get_project_metadata0()
534
- })
348
+ # Run the API Server
349
+ import uvicorn
535
350
 
536
- # Squirrels Testing UI
537
- static_dir = u.Path(os.path.dirname(__file__), c.PACKAGE_DATA_FOLDER, c.ASSETS_FOLDER)
538
- app.mount('/'+c.ASSETS_FOLDER, StaticFiles(directory=static_dir), name=c.ASSETS_FOLDER)
539
-
540
- templates_dir = u.Path(os.path.dirname(__file__), c.PACKAGE_DATA_FOLDER, c.TEMPLATES_FOLDER)
541
- templates = Jinja2Templates(directory=templates_dir)
542
-
543
- @app.get('/', summary="Get the Squirrels Testing UI", response_class=HTMLResponse)
544
- async def get_testing_ui(request: Request):
545
- return templates.TemplateResponse('index.html', {
546
- 'request': request, 'project_metadata_path': squirrels_version_path, 'token_path': token_path
547
- })
351
+ print("\nWelcome to the Squirrels Data Application!\n")
352
+ print(f"- Application UI: {full_hostname}{squirrels_studio_path}")
353
+ print(f"- API Docs (with ReDoc): {full_hostname}{project_metadata_path}/redoc")
354
+ print(f"- API Docs (with Swagger UI): {full_hostname}{project_metadata_path}/docs")
355
+ print(f"- MCP Server URL: {full_hostname}{project_metadata_path}/mcp")
356
+ print()
548
357
 
549
- # Run API server
550
- import uvicorn
551
- self.logger.log_activity_time("creating app server", start)
552
- uvicorn.run(app, host=uvicorn_args.host, port=uvicorn_args.port)
358
+ uvicorn.run(app, host=uvicorn_args.host, port=uvicorn_args.port, proxy_headers=True, forwarded_allow_ips="*")