squirrels 0.1.0__py3-none-any.whl → 0.6.0.post0__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.
Files changed (127) hide show
  1. dateutils/__init__.py +6 -0
  2. dateutils/_enums.py +25 -0
  3. squirrels/dateutils.py → dateutils/_implementation.py +409 -380
  4. dateutils/types.py +6 -0
  5. squirrels/__init__.py +21 -18
  6. squirrels/_api_routes/__init__.py +5 -0
  7. squirrels/_api_routes/auth.py +337 -0
  8. squirrels/_api_routes/base.py +196 -0
  9. squirrels/_api_routes/dashboards.py +156 -0
  10. squirrels/_api_routes/data_management.py +148 -0
  11. squirrels/_api_routes/datasets.py +220 -0
  12. squirrels/_api_routes/project.py +289 -0
  13. squirrels/_api_server.py +552 -134
  14. squirrels/_arguments/__init__.py +0 -0
  15. squirrels/_arguments/init_time_args.py +83 -0
  16. squirrels/_arguments/run_time_args.py +111 -0
  17. squirrels/_auth.py +777 -0
  18. squirrels/_command_line.py +239 -107
  19. squirrels/_compile_prompts.py +147 -0
  20. squirrels/_connection_set.py +94 -0
  21. squirrels/_constants.py +141 -64
  22. squirrels/_dashboards.py +179 -0
  23. squirrels/_data_sources.py +570 -0
  24. squirrels/_dataset_types.py +91 -0
  25. squirrels/_env_vars.py +209 -0
  26. squirrels/_exceptions.py +29 -0
  27. squirrels/_http_error_responses.py +52 -0
  28. squirrels/_initializer.py +319 -110
  29. squirrels/_logging.py +121 -0
  30. squirrels/_manifest.py +357 -187
  31. squirrels/_mcp_server.py +578 -0
  32. squirrels/_model_builder.py +69 -0
  33. squirrels/_model_configs.py +74 -0
  34. squirrels/_model_queries.py +52 -0
  35. squirrels/_models.py +1201 -0
  36. squirrels/_package_data/base_project/.env +7 -0
  37. squirrels/_package_data/base_project/.env.example +44 -0
  38. squirrels/_package_data/base_project/connections.yml +16 -0
  39. squirrels/_package_data/base_project/dashboards/dashboard_example.py +40 -0
  40. squirrels/_package_data/base_project/dashboards/dashboard_example.yml +22 -0
  41. squirrels/_package_data/base_project/docker/.dockerignore +16 -0
  42. squirrels/_package_data/base_project/docker/Dockerfile +16 -0
  43. squirrels/_package_data/base_project/docker/compose.yml +7 -0
  44. squirrels/_package_data/base_project/duckdb_init.sql +10 -0
  45. squirrels/_package_data/base_project/gitignore +13 -0
  46. squirrels/_package_data/base_project/macros/macros_example.sql +17 -0
  47. squirrels/_package_data/base_project/models/builds/build_example.py +26 -0
  48. squirrels/_package_data/base_project/models/builds/build_example.sql +16 -0
  49. squirrels/_package_data/base_project/models/builds/build_example.yml +57 -0
  50. squirrels/_package_data/base_project/models/dbviews/dbview_example.sql +17 -0
  51. squirrels/_package_data/base_project/models/dbviews/dbview_example.yml +32 -0
  52. squirrels/_package_data/base_project/models/federates/federate_example.py +51 -0
  53. squirrels/_package_data/base_project/models/federates/federate_example.sql +21 -0
  54. squirrels/_package_data/base_project/models/federates/federate_example.yml +65 -0
  55. squirrels/_package_data/base_project/models/sources.yml +38 -0
  56. squirrels/_package_data/base_project/parameters.yml +142 -0
  57. squirrels/_package_data/base_project/pyconfigs/connections.py +19 -0
  58. squirrels/_package_data/base_project/pyconfigs/context.py +96 -0
  59. squirrels/_package_data/base_project/pyconfigs/parameters.py +141 -0
  60. squirrels/_package_data/base_project/pyconfigs/user.py +56 -0
  61. squirrels/_package_data/base_project/resources/expenses.db +0 -0
  62. squirrels/_package_data/base_project/resources/public/.gitkeep +0 -0
  63. squirrels/_package_data/base_project/resources/weather.db +0 -0
  64. squirrels/_package_data/base_project/seeds/seed_categories.csv +6 -0
  65. squirrels/_package_data/base_project/seeds/seed_categories.yml +15 -0
  66. squirrels/_package_data/base_project/seeds/seed_subcategories.csv +15 -0
  67. squirrels/_package_data/base_project/seeds/seed_subcategories.yml +21 -0
  68. squirrels/_package_data/base_project/squirrels.yml.j2 +61 -0
  69. squirrels/_package_data/base_project/tmp/.gitignore +2 -0
  70. squirrels/_package_data/templates/login_successful.html +53 -0
  71. squirrels/_package_data/templates/squirrels_studio.html +22 -0
  72. squirrels/_package_loader.py +29 -0
  73. squirrels/_parameter_configs.py +592 -0
  74. squirrels/_parameter_options.py +348 -0
  75. squirrels/_parameter_sets.py +207 -0
  76. squirrels/_parameters.py +1703 -0
  77. squirrels/_project.py +796 -0
  78. squirrels/_py_module.py +122 -0
  79. squirrels/_request_context.py +33 -0
  80. squirrels/_schemas/__init__.py +0 -0
  81. squirrels/_schemas/auth_models.py +83 -0
  82. squirrels/_schemas/query_param_models.py +70 -0
  83. squirrels/_schemas/request_models.py +26 -0
  84. squirrels/_schemas/response_models.py +286 -0
  85. squirrels/_seeds.py +97 -0
  86. squirrels/_sources.py +112 -0
  87. squirrels/_utils.py +540 -149
  88. squirrels/_version.py +1 -3
  89. squirrels/arguments.py +7 -0
  90. squirrels/auth.py +4 -0
  91. squirrels/connections.py +3 -0
  92. squirrels/dashboards.py +3 -0
  93. squirrels/data_sources.py +14 -282
  94. squirrels/parameter_options.py +13 -189
  95. squirrels/parameters.py +14 -801
  96. squirrels/types.py +18 -0
  97. squirrels-0.6.0.post0.dist-info/METADATA +148 -0
  98. squirrels-0.6.0.post0.dist-info/RECORD +101 -0
  99. {squirrels-0.1.0.dist-info → squirrels-0.6.0.post0.dist-info}/WHEEL +1 -2
  100. {squirrels-0.1.0.dist-info → squirrels-0.6.0.post0.dist-info}/entry_points.txt +1 -0
  101. squirrels-0.6.0.post0.dist-info/licenses/LICENSE +201 -0
  102. squirrels/_credentials_manager.py +0 -87
  103. squirrels/_module_loader.py +0 -37
  104. squirrels/_parameter_set.py +0 -151
  105. squirrels/_renderer.py +0 -286
  106. squirrels/_timed_imports.py +0 -37
  107. squirrels/connection_set.py +0 -126
  108. squirrels/package_data/base_project/.gitignore +0 -4
  109. squirrels/package_data/base_project/connections.py +0 -21
  110. squirrels/package_data/base_project/database/sample_database.db +0 -0
  111. squirrels/package_data/base_project/database/seattle_weather.db +0 -0
  112. squirrels/package_data/base_project/datasets/sample_dataset/context.py +0 -8
  113. squirrels/package_data/base_project/datasets/sample_dataset/database_view1.py +0 -23
  114. squirrels/package_data/base_project/datasets/sample_dataset/database_view1.sql.j2 +0 -7
  115. squirrels/package_data/base_project/datasets/sample_dataset/final_view.py +0 -10
  116. squirrels/package_data/base_project/datasets/sample_dataset/final_view.sql.j2 +0 -2
  117. squirrels/package_data/base_project/datasets/sample_dataset/parameters.py +0 -30
  118. squirrels/package_data/base_project/datasets/sample_dataset/selections.cfg +0 -6
  119. squirrels/package_data/base_project/squirrels.yaml +0 -26
  120. squirrels/package_data/static/favicon.ico +0 -0
  121. squirrels/package_data/static/script.js +0 -234
  122. squirrels/package_data/static/style.css +0 -110
  123. squirrels/package_data/templates/index.html +0 -32
  124. squirrels-0.1.0.dist-info/LICENSE +0 -22
  125. squirrels-0.1.0.dist-info/METADATA +0 -67
  126. squirrels-0.1.0.dist-info/RECORD +0 -40
  127. squirrels-0.1.0.dist-info/top_level.txt +0 -1
squirrels/_api_server.py CHANGED
@@ -1,134 +1,552 @@
1
- from typing import Dict, List, Tuple, Set
2
- from fastapi import FastAPI, Request, HTTPException
3
- from fastapi.datastructures import QueryParams
4
- from fastapi.responses import HTMLResponse, JSONResponse
5
- from fastapi.templating import Jinja2Templates
6
- from fastapi.staticfiles import StaticFiles
7
- from cachetools.func import ttl_cache
8
- import os, traceback
9
-
10
- from squirrels import _constants as c, _utils
11
- from squirrels._version import major_version
12
- from squirrels._manifest import Manifest
13
- from squirrels.connection_set import ConnectionSet
14
- from squirrels._renderer import RendererIOWrapper, Renderer
15
-
16
-
17
- class ApiServer:
18
- def __init__(self, manifest: Manifest, conn_set: ConnectionSet, no_cache: bool, debug: bool) -> None:
19
- """
20
- Constructor for ApiServer
21
-
22
- Parameters:
23
- manifest (Manifest): Manifest object produced from squirrels.yaml
24
- conn_set (ConnectionSet): Set of all connection pools defined in connections.py
25
- no_cache (bool): Whether to disable caching
26
- debug (bool): Set to True to show "hidden" parameters in the /parameters endpoint response
27
- """
28
- self.manifest = manifest
29
- self.conn_set = conn_set
30
- self.no_cache = no_cache
31
- self.debug = debug
32
-
33
- self.datasets = manifest.get_all_dataset_names()
34
- self.renderers: Dict[str, Renderer] = {}
35
- for dataset in self.datasets:
36
- rendererIO = RendererIOWrapper(dataset, manifest, conn_set)
37
- self.renderers[dataset] = rendererIO.renderer
38
-
39
- def _get_parameters_helper(self, dataset: str, query_params: Set[Tuple[str, str]]) -> Dict:
40
- if len(query_params) > 1:
41
- raise _utils.InvalidInputError("The /parameters endpoint takes at most 1 query parameter")
42
- renderer = self.renderers[dataset]
43
- parameters = renderer.apply_selections(dict(query_params), updates_only = True)
44
- return parameters.to_json_dict(self.debug)
45
-
46
- def _get_results_helper(self, dataset: str, query_params: Set[Tuple[str, str]]) -> Dict:
47
- renderer = self.renderers[dataset]
48
- _, _, _, _, df = renderer.load_results(dict(query_params))
49
- return _utils.df_to_json(df)
50
-
51
- def _apply_api_function(self, api_function):
52
- try:
53
- return api_function()
54
- except _utils.InvalidInputError as e:
55
- traceback.print_exc()
56
- raise HTTPException(status_code=400, detail="Invalid User Input: "+str(e)) from e
57
- except _utils.ConfigurationError as e:
58
- traceback.print_exc()
59
- raise HTTPException(status_code=500, detail="Squirrels Configuration Error: "+str(e)) from e
60
- except Exception as e:
61
- traceback.print_exc()
62
- raise HTTPException(status_code=500, detail="Squirrels Framework Error: "+str(e)) from e
63
-
64
- def _apply_dataset_api_function(self, api_function, dataset: str, raw_query_params: QueryParams):
65
- dataset = _utils.normalize_name(dataset)
66
- query_params = set()
67
- for key, val in raw_query_params.items():
68
- query_params.add((_utils.normalize_name(key), val))
69
- query_params = frozenset(query_params)
70
- return self._apply_api_function(lambda: api_function(dataset, query_params))
71
-
72
- def run(self, uvicorn_args: List[str]) -> None:
73
- """
74
- Runs the API server with uvicorn for CLI "squirrels run"
75
-
76
- Parameters:
77
- uvicorn_args (List[str]): List of arguments to pass to uvicorn.run. Currently only supports "host" and "port"
78
- """
79
- app = FastAPI()
80
-
81
- squirrels_version_path = f'/squirrels{major_version}'
82
- config_base_path = _utils.normalize_name_for_api(self.manifest.get_base_path())
83
- base_path = squirrels_version_path + config_base_path
84
-
85
- static_dir = _utils.join_paths(os.path.dirname(__file__), 'package_data', 'static')
86
- app.mount('/static', StaticFiles(directory=static_dir), name='static')
87
-
88
- templates_dir = _utils.join_paths(os.path.dirname(__file__), 'package_data', 'templates')
89
- templates = Jinja2Templates(directory=templates_dir)
90
-
91
- # Parameters API
92
- parameters_path = base_path + '/{dataset}/parameters'
93
-
94
- parameters_cache_size = self.manifest.get_setting(c.PARAMETERS_CACHE_SIZE_SETTING, 1024)
95
- parameters_cache_ttl = self.manifest.get_setting(c.PARAMETERS_CACHE_TTL_SETTING, 24*60*60)
96
-
97
- @ttl_cache(maxsize=parameters_cache_size, ttl=parameters_cache_ttl)
98
- def get_parameters_cachable(*args):
99
- return self._get_parameters_helper(*args)
100
-
101
- @app.get(parameters_path, response_class=JSONResponse)
102
- async def get_parameters(dataset: str, request: Request):
103
- api_function = self._get_parameters_helper if self.no_cache else get_parameters_cachable
104
- return self._apply_dataset_api_function(api_function, dataset, request.query_params)
105
-
106
- # Results API
107
- results_path = base_path + '/{dataset}'
108
-
109
- results_cache_size = self.manifest.get_setting(c.RESULTS_CACHE_SIZE_SETTING, 128)
110
- results_cache_ttl = self.manifest.get_setting(c.RESULTS_CACHE_TTL_SETTING, 60*60)
111
-
112
- @ttl_cache(maxsize=results_cache_size, ttl=results_cache_ttl)
113
- def get_results_cachable(*args):
114
- return self._get_results_helper(*args)
115
-
116
- @app.get(results_path, response_class=JSONResponse)
117
- async def get_results(dataset: str, request: Request):
118
- api_function = self._get_results_helper if self.no_cache else get_results_cachable
119
- return self._apply_dataset_api_function(api_function, dataset, request.query_params)
120
-
121
- # Catalog API
122
- @app.get(squirrels_version_path, response_class=JSONResponse)
123
- async def get_catalog():
124
- api_function = lambda: self.manifest.get_catalog(parameters_path, results_path)
125
- return self._apply_api_function(api_function)
126
-
127
- # Squirrels UI
128
- @app.get('/', response_class=HTMLResponse)
129
- async def get_ui(request: Request):
130
- return templates.TemplateResponse('index.html', {'request': request, 'catalog_path': squirrels_version_path})
131
-
132
- # Run API server
133
- import uvicorn
134
- uvicorn.run(app, host=uvicorn_args.host, port=uvicorn_args.port)
1
+ from typing import TYPE_CHECKING
2
+ from dataclasses import dataclass
3
+ from fastapi import FastAPI, Request, status
4
+ from fastapi.responses import JSONResponse, HTMLResponse, RedirectResponse, PlainTextResponse
5
+ from fastapi.security import HTTPBearer
6
+ from fastapi.staticfiles import StaticFiles
7
+ from fastapi.templating import Jinja2Templates
8
+ from starlette.middleware.base import BaseHTTPMiddleware
9
+ from starlette.responses import Response as StarletteResponse
10
+ from starlette.types import ASGIApp
11
+ from contextlib import asynccontextmanager
12
+ from argparse import Namespace
13
+ from pathlib import Path
14
+ from starlette.middleware.sessions import SessionMiddleware
15
+ import io, time, mimetypes, traceback, asyncio
16
+
17
+ from . import _constants as c, _utils as u, _parameter_sets as ps
18
+ from ._schemas import response_models as rm
19
+ from ._exceptions import InvalidInputError, ConfigurationError, FileExecutionError
20
+ from ._http_error_responses import invalid_input_error_to_json_response
21
+ from ._manifest import AuthStrategy, AuthType
22
+ from ._request_context import set_request_id
23
+ from ._mcp_server import McpServerBuilder
24
+
25
+ # Import route modules
26
+ from ._api_routes.base import RouteBase
27
+ from ._api_routes.auth import AuthRoutes
28
+ from ._api_routes.project import ProjectRoutes
29
+ from ._api_routes.datasets import DatasetRoutes
30
+ from ._api_routes.dashboards import DashboardRoutes
31
+ from ._api_routes.data_management import DataManagementRoutes
32
+
33
+ if TYPE_CHECKING:
34
+ from contextlib import _AsyncGeneratorContextManager
35
+ from ._project import SquirrelsProject
36
+
37
+
38
+ mimetypes.add_type('application/javascript', '.js')
39
+
40
+
41
+ class SmartCORSMiddleware(BaseHTTPMiddleware):
42
+ """
43
+ Custom CORS middleware that allows specific origins to use credentials
44
+ while still allowing all other origins without credentials.
45
+ """
46
+
47
+ def __init__(self, app, allowed_credential_origins: list[str], configurables_as_headers: list[str]):
48
+ super().__init__(app)
49
+
50
+ allowed_predefined_headers = ["Authorization", "Content-Type", "x-api-key"]
51
+
52
+ self.allowed_credential_origins = allowed_credential_origins
53
+ self.allowed_request_headers = ",".join(allowed_predefined_headers + configurables_as_headers)
54
+
55
+ async def dispatch(self, request: Request, call_next):
56
+ origin = request.headers.get("origin")
57
+
58
+ # Handle preflight requests
59
+ if request.method == "OPTIONS":
60
+ response = StarletteResponse(status_code=200)
61
+ response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
62
+ response.headers["Access-Control-Allow-Headers"] = self.allowed_request_headers
63
+
64
+ else:
65
+ # Call the next middleware/route
66
+ response: StarletteResponse = await call_next(request)
67
+
68
+ # Always expose the Applied-Username header
69
+ response.headers["Access-Control-Expose-Headers"] = "Applied-Username"
70
+
71
+ if origin:
72
+ request_origin = f"{request.url.scheme}://{request.url.netloc}"
73
+ # Check if this origin is in the whitelist or if origin matches the host origin
74
+ if origin == request_origin or origin in self.allowed_credential_origins:
75
+ response.headers["Access-Control-Allow-Origin"] = origin
76
+ response.headers["Access-Control-Allow-Credentials"] = "true"
77
+ else:
78
+ # Allow all other origins but without credentials / cookies
79
+ response.headers["Access-Control-Allow-Origin"] = "*"
80
+ else:
81
+ # No origin header (probably a non-browser request)
82
+ response.headers["Access-Control-Allow-Origin"] = "*"
83
+
84
+ return response
85
+
86
+
87
+ @dataclass
88
+ class FastAPIComponents:
89
+ """
90
+ HTTP server components to mount the Squirrels project into an existing FastAPI application.
91
+
92
+ Properties:
93
+ mount_path: The mount path for the Squirrels project.
94
+ lifespan: The lifespan context manager for the Squirrels project.
95
+ fastapi_app: The FastAPI app for the Squirrels project.
96
+ """
97
+ mount_path: str
98
+ lifespan: "_AsyncGeneratorContextManager"
99
+ fastapi_app: "FastAPI"
100
+
101
+
102
+ class ApiServer:
103
+ def __init__(self, no_cache: bool, project: "SquirrelsProject") -> None:
104
+ """
105
+ Constructor for ApiServer
106
+
107
+ Arguments:
108
+ no_cache (bool): Whether to disable caching
109
+ """
110
+ self.project = project
111
+ self.logger = project._logger
112
+ self.env_vars = project._env_vars
113
+ self.manifest_cfg = project._manifest_cfg
114
+ self.seeds = project._seeds
115
+ self.conn_set = project._conn_set
116
+ self.param_cfg_set = project._param_cfg_set
117
+ self.dashboards = project._dashboards
118
+
119
+ # Initialize route modules
120
+ get_bearer_token = HTTPBearer(auto_error=False)
121
+ # self.oauth2_routes = OAuth2Routes(get_bearer_token, project, no_cache)
122
+ self.auth_routes = AuthRoutes(get_bearer_token, project, no_cache)
123
+ self.project_routes = ProjectRoutes(get_bearer_token, project, no_cache)
124
+ self.dataset_routes = DatasetRoutes(get_bearer_token, project, no_cache)
125
+ self.dashboard_routes = DashboardRoutes(get_bearer_token, project, no_cache)
126
+ self.data_management_routes = DataManagementRoutes(get_bearer_token, project, no_cache)
127
+
128
+ self._mcp_builder: McpServerBuilder | None = None
129
+ self._mcp_app: ASGIApp | None = None
130
+
131
+
132
+ async def _refresh_datasource_params(self) -> None:
133
+ """
134
+ Background task to periodically refresh datasource parameter options.
135
+ Runs every N minutes as configured by SQRL_PARAMETERS__DATASOURCE_REFRESH_MINUTES (default: 60).
136
+ """
137
+ refresh_minutes = self.env_vars.parameters_datasource_refresh_minutes
138
+ if refresh_minutes <= 0:
139
+ self.logger.info(f"The value of {c.SQRL_PARAMETERS_DATASOURCE_REFRESH_MINUTES} is: {refresh_minutes} minutes")
140
+ self.logger.info(f"Datasource parameter refresh is disabled since the refresh interval is not positive.")
141
+ return
142
+
143
+ refresh_seconds = refresh_minutes * 60
144
+ self.logger.info(f"Starting datasource parameter refresh background task (every {refresh_minutes} minutes)")
145
+
146
+ default_conn_name = self.env_vars.connections_default_name_used
147
+ while True:
148
+ try:
149
+ await asyncio.sleep(refresh_seconds)
150
+ self.logger.info("Refreshing datasource parameter options...")
151
+
152
+ # Fetch fresh dataframes from datasources in a thread pool to avoid blocking
153
+ loop = asyncio.get_running_loop()
154
+ df_dict = await loop.run_in_executor(
155
+ None,
156
+ ps.ParameterConfigsSetIO._get_df_dict_from_data_sources,
157
+ self.param_cfg_set,
158
+ default_conn_name,
159
+ self.seeds,
160
+ self.conn_set,
161
+ self.project._vdl_catalog_db_path
162
+ )
163
+
164
+ # Re-convert datasource parameters with fresh data
165
+ self.param_cfg_set._post_process_params(df_dict)
166
+
167
+ self.logger.info("Successfully refreshed datasource parameter options")
168
+ except asyncio.CancelledError:
169
+ self.logger.info("Datasource parameter refresh task cancelled")
170
+ break
171
+ except Exception as e:
172
+ self.logger.error(f"Error refreshing datasource parameter options: {e}", exc_info=True)
173
+ # Continue the loop even if there's an error
174
+
175
+
176
+ def _get_tags_metadata(self) -> list[dict]:
177
+ tags_metadata = [
178
+ {
179
+ "name": "Project Metadata",
180
+ "description": "Get information on project such as name, version, and other API endpoints",
181
+ },
182
+ {
183
+ "name": "Data Management",
184
+ "description": "Actions to update the data components of the project",
185
+ }
186
+ ]
187
+
188
+ for dataset_name in self.manifest_cfg.datasets:
189
+ tags_metadata.append({
190
+ "name": f"Dataset '{dataset_name}'",
191
+ "description": f"Get parameters or results for dataset '{dataset_name}'",
192
+ })
193
+
194
+ for dashboard_name in self.dashboards:
195
+ tags_metadata.append({
196
+ "name": f"Dashboard '{dashboard_name}'",
197
+ "description": f"Get parameters or results for dashboard '{dashboard_name}'",
198
+ })
199
+
200
+ tags_metadata.extend([
201
+ {
202
+ "name": "Authentication",
203
+ "description": "Submit authentication credentials and authorize with a session cookie",
204
+ },
205
+ {
206
+ "name": "User Management",
207
+ "description": "Manage users and their attributes",
208
+ }
209
+ ])
210
+ return tags_metadata
211
+
212
+
213
+ def _print_banner(self, mount_path: str, host: str | None, port: int | None, is_standalone_mode: bool) -> None:
214
+ """
215
+ Print the welcome banner with information about the running server.
216
+ """
217
+ full_hostname = f"http://{host}:{port}" if host and port else ""
218
+ mount_path_stripped = mount_path.rstrip("/")
219
+ show_multiple_options = is_standalone_mode and mount_path_stripped != ""
220
+
221
+ banner_width = 80
222
+
223
+ print()
224
+ print("═" * banner_width)
225
+ print("👋 WELCOME TO SQUIRRELS!".center(banner_width))
226
+ print("═" * banner_width)
227
+ print()
228
+ print(" 🖥️ Application UI")
229
+ print(f" └─ Squirrels Studio: {full_hostname}{mount_path_stripped}/studio")
230
+ if show_multiple_options:
231
+ print(f" ├─ The root path also redirects to Squirrels Studio: {full_hostname}/")
232
+ print( " ├─ This requires an internet connection to load the JS and CSS files")
233
+ print( f" └─ Automatically uses mount path: {mount_path_stripped}")
234
+ print()
235
+ print(" 🔌 MCP Server URLs")
236
+ if show_multiple_options:
237
+ print(f" ├─ Option 1: {full_hostname}{mount_path_stripped}/mcp")
238
+ print(f" └─ Option 2: {full_hostname}/mcp")
239
+ else:
240
+ print(f" └─ Project MCP: {full_hostname}{mount_path_stripped}/mcp")
241
+ print()
242
+ print(" 📖 API Documentation (for the latest version of API contract)")
243
+ print(f" ├─ Swagger UI: {full_hostname}{mount_path_stripped}{c.LATEST_API_VERSION_MOUNT_PATH}/docs")
244
+ print(f" ├─ ReDoc UI: {full_hostname}{mount_path_stripped}{c.LATEST_API_VERSION_MOUNT_PATH}/redoc")
245
+ print(f" └─ OpenAPI Spec: {full_hostname}{mount_path_stripped}{c.LATEST_API_VERSION_MOUNT_PATH}/openapi.json")
246
+ print()
247
+ print(f" To explore all HTTP endpoints, see: {full_hostname}{mount_path_stripped}/docs")
248
+ print()
249
+ print("─" * banner_width)
250
+ print("✨ Server is running! Press CTRL+C to stop.".center(banner_width))
251
+ print("─" * banner_width)
252
+ print()
253
+
254
+
255
+ def get_lifespan(
256
+ self, mount_path: str, host: str | None, port: int | None, is_standalone_mode: bool
257
+ ) -> "_AsyncGeneratorContextManager":
258
+ """
259
+ Get the lifespan context manager for the Squirrels project.
260
+ """
261
+ @asynccontextmanager
262
+ async def lifespan(app: FastAPI | None = None):
263
+ """App lifespan that includes MCP server lifecycle and background tasks."""
264
+ self._print_banner(mount_path, host, port, is_standalone_mode)
265
+
266
+ refresh_datasource_task = asyncio.create_task(self._refresh_datasource_params())
267
+
268
+ if self._mcp_builder:
269
+ async with self._mcp_builder.lifespan():
270
+ yield
271
+ else:
272
+ yield
273
+
274
+ refresh_datasource_task.cancel()
275
+
276
+ return lifespan
277
+
278
+
279
+ def create_app(
280
+ self,
281
+ lifespan: "_AsyncGeneratorContextManager",
282
+ *,
283
+ mount_path: str = ""
284
+ ) -> FastAPI:
285
+ """
286
+ Create the FastAPI app for the Squirrels project.
287
+ """
288
+ start = time.time()
289
+
290
+ project_name = self.manifest_cfg.project_variables.name
291
+ project_label = self.manifest_cfg.project_variables.label
292
+
293
+ param_fields = self.param_cfg_set.get_all_api_field_info()
294
+ tags_metadata = self._get_tags_metadata()
295
+
296
+ mount_path_stripped = mount_path.rstrip("/")
297
+ api_v0_mount_path = "/api/0"
298
+
299
+ app = FastAPI(
300
+ title=f"Squirrels for '{project_label}'",
301
+ lifespan=lifespan
302
+ )
303
+
304
+ api_v0_app = FastAPI(
305
+ title=f"Squirrels APIs for '{project_label}'", openapi_tags=tags_metadata,
306
+ 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",
307
+ openapi_url="/openapi.json",
308
+ docs_url="/docs",
309
+ redoc_url="/redoc"
310
+ )
311
+
312
+ api_v0_app.add_middleware(SessionMiddleware, secret_key=self.env_vars.secret_key, max_age=None, same_site="none", https_only=True)
313
+
314
+ async def _log_request_run(request: Request) -> None:
315
+ try:
316
+ body = await request.json()
317
+ except Exception:
318
+ body = None # Non-JSON payloads may contain sensitive information, so we don't log them
319
+
320
+ partial_headers: dict[str, str] = {}
321
+ for header in request.headers.keys():
322
+ if header.startswith("x-") and header not in ["x-api-key"]:
323
+ partial_headers[header] = request.headers[header]
324
+
325
+ path, params = request.url.path, dict(request.query_params)
326
+ path_with_params = f"{path}?{request.query_params}" if len(params) > 0 else path
327
+ data = {"request_method": request.method, "request_path": path, "request_params": params, "request_body": body, "partial_headers": partial_headers}
328
+ self.logger.info(f'Running request: {request.method} {path_with_params}', data=data)
329
+
330
+ @api_v0_app.middleware("http")
331
+ async def catch_exceptions_middleware(request: Request, call_next):
332
+ # Generate and set request ID for this request
333
+ request_id = set_request_id()
334
+
335
+ buffer = io.StringIO()
336
+ try:
337
+ await _log_request_run(request)
338
+ response = await call_next(request)
339
+ except InvalidInputError as exc:
340
+ message = str(exc)
341
+ self.logger.error(message)
342
+ strip_path_suffix = f"{mount_path_stripped}{api_v0_mount_path}"
343
+ response = invalid_input_error_to_json_response(
344
+ request,
345
+ exc,
346
+ oauth_resource_metadata_path="/.well-known/oauth-protected-resource",
347
+ strip_path_suffix=strip_path_suffix,
348
+ )
349
+ except FileExecutionError as exc:
350
+ traceback.print_exception(exc.error, file=buffer)
351
+ buffer.write(str(exc))
352
+ response = JSONResponse(
353
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"message": f"An unexpected server error occurred", "blame": "Squirrels project"}
354
+ )
355
+ except ConfigurationError as exc:
356
+ traceback.print_exc(file=buffer)
357
+ response = JSONResponse(
358
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"message": f"An unexpected server error occurred", "blame": "Squirrels project"}
359
+ )
360
+ except Exception as exc:
361
+ traceback.print_exc(file=buffer)
362
+ response = JSONResponse(
363
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"message": f"An unexpected server error occurred", "blame": "Squirrels framework"}
364
+ )
365
+
366
+ err_msg = buffer.getvalue()
367
+ if err_msg:
368
+ self.logger.error(err_msg)
369
+
370
+ # Add request ID to response header
371
+ response.headers["X-Request-ID"] = request_id
372
+
373
+ return response
374
+
375
+ # Configure CORS with smart credential handling
376
+ allowed_credential_origins = self.env_vars.auth_credential_origins
377
+
378
+ configurables_as_headers = []
379
+ for name in self.manifest_cfg.configurables.keys():
380
+ configurables_as_headers.append(f"x-config-{name}") # underscore version
381
+ configurables_as_headers.append(f"x-config-{u.normalize_name_for_api(name)}") # dash version
382
+
383
+ api_v0_app.add_middleware(SmartCORSMiddleware, allowed_credential_origins=allowed_credential_origins, configurables_as_headers=configurables_as_headers)
384
+
385
+ # Setup route modules for the v0 API
386
+ get_parameters_definition = self.project_routes.setup_routes(api_v0_app, param_fields)
387
+ self.data_management_routes.setup_routes(api_v0_app, param_fields)
388
+ self.dataset_routes.setup_routes(api_v0_app, param_fields, get_parameters_definition)
389
+ self.dashboard_routes.setup_routes(api_v0_app, param_fields, get_parameters_definition)
390
+ # self.oauth2_routes.setup_routes(api_v0_app)
391
+ self.auth_routes.setup_routes(api_v0_app)
392
+
393
+ app.mount(api_v0_mount_path, api_v0_app)
394
+
395
+ @app.get("/health", summary="Health check endpoint")
396
+ async def health() -> PlainTextResponse:
397
+ return PlainTextResponse(status_code=200, content="OK")
398
+
399
+ # Mount static files from the public directories if they exist
400
+ # This allows users to serve public-facing static assets (images, CSS, JS, etc.) with HTTP requests
401
+ public_dirs = ["public"]
402
+ for public_dir in public_dirs:
403
+ static_dir = Path(self.project._project_path) / "resources" / public_dir
404
+ if static_dir.exists() and static_dir.is_dir():
405
+ app.mount(f"/{public_dir}", StaticFiles(directory=str(static_dir)), name=public_dir)
406
+ self.logger.info(f"Mounted static files from: {str(static_dir)}")
407
+
408
+ # Build the MCP server after routes are set up
409
+ enforce_mcp_oauth = (
410
+ self.manifest_cfg.project_variables.auth_strategy == AuthStrategy.EXTERNAL
411
+ and self.manifest_cfg.project_variables.auth_type == AuthType.REQUIRED
412
+ )
413
+ self._mcp_builder = McpServerBuilder(
414
+ project_name=project_name,
415
+ project_label=project_label,
416
+ max_rows_for_ai=self.env_vars.datasets_max_rows_for_ai,
417
+ get_user_from_headers=self.project_routes.get_user_from_headers,
418
+ get_data_catalog_for_mcp=self.project_routes._get_data_catalog_for_mcp,
419
+ get_dataset_parameters_for_mcp=self.dataset_routes._get_dataset_parameters_for_mcp,
420
+ get_dataset_results_for_mcp=self.dataset_routes._get_dataset_results_for_mcp,
421
+ enforce_oauth_bearer=enforce_mcp_oauth,
422
+ oauth_resource_metadata_path="/.well-known/oauth-protected-resource",
423
+ www_authenticate_strip_path_suffix=f"{mount_path_stripped}/mcp",
424
+ )
425
+ self._mcp_app = self._mcp_builder.get_asgi_app()
426
+
427
+ # Mount MCP server
428
+ app.add_route("/mcp", self._mcp_app, methods=["GET", "POST"])
429
+
430
+ # Get API versions and other endpoints
431
+ @app.get("/", summary="Explore all HTTP endpoints")
432
+ async def explore_http_endpoints(request: Request) -> rm.ExploreEndpointsModel:
433
+ _, root_path = RouteBase._get_base_url_for_current_app(request)
434
+ return rm.ExploreEndpointsModel(
435
+ health_url=root_path + "/health",
436
+ api_versions={
437
+ "0": rm.APIVersionMetadataModel(
438
+ project_metadata_url=root_path + api_v0_mount_path + "/",
439
+ documentation_routes=rm.DocumentationRoutesModel(
440
+ swagger_url=root_path + api_v0_mount_path + "/docs",
441
+ redoc_url=root_path + api_v0_mount_path + "/redoc",
442
+ openapi_url=root_path + api_v0_mount_path + "/openapi.json"
443
+ )
444
+ )
445
+ },
446
+ documentation_routes=rm.DocumentationRoutesModel(
447
+ swagger_url=root_path + "/docs",
448
+ redoc_url=root_path + "/redoc",
449
+ openapi_url=root_path + "/openapi.json"
450
+ ),
451
+ mcp_server_url=root_path + "/mcp",
452
+ studio_url=root_path + "/studio",
453
+ )
454
+
455
+ # Add Squirrels Studio
456
+ templates = Jinja2Templates(directory=str(Path(__file__).parent / "_package_data" / "templates"))
457
+
458
+ @app.get("/studio", include_in_schema=False)
459
+ async def squirrels_studio(request: Request):
460
+ sqrl_studio_base_url = self.env_vars.studio_base_url
461
+
462
+ # IMPORTANT: avoid `request.url_for("explore_http_endpoints")` here.
463
+ # When multiple Squirrels FastAPI apps are mounted into a root app, that route name
464
+ # can become ambiguous and resolve to the wrong mounted app. `request.base_url`
465
+ # is derived from the current request scope (including `root_path`), so it always
466
+ # points at the correct mounted Squirrels server instance.
467
+ _, mount_path = RouteBase._get_base_url_for_current_app(request)
468
+
469
+ context = {
470
+ "sqrl_studio_base_url": sqrl_studio_base_url,
471
+ "mount_path": mount_path,
472
+ }
473
+ template = templates.get_template("squirrels_studio.html")
474
+ return HTMLResponse(content=template.render(context))
475
+
476
+ self.logger.log_activity_time("creating app server", start)
477
+ return app
478
+
479
+ def get_fastapi_components(
480
+ self, host: str, port: int, *,
481
+ mount_path_format: str = "/analytics/{project_name}/v{project_version}",
482
+ is_standalone_mode: bool = False
483
+ ) -> FastAPIComponents:
484
+ """
485
+ Get the FastAPI components for the Squirrels project including mount path, lifespan, and FastAPI app.
486
+ """
487
+ project_name = u.normalize_name_for_api(self.manifest_cfg.project_variables.name)
488
+ project_version = self.manifest_cfg.project_variables.major_version
489
+ mount_path = mount_path_format.format(project_name=project_name, project_version=project_version)
490
+
491
+ lifespan = self.get_lifespan(mount_path, host, port, is_standalone_mode)
492
+ fastapi_app = self.create_app(lifespan, mount_path=mount_path)
493
+ return FastAPIComponents(mount_path=mount_path, lifespan=lifespan, fastapi_app=fastapi_app)
494
+
495
+ def run(self, uvicorn_args: Namespace) -> None:
496
+ """
497
+ Runs the API server with uvicorn for CLI "squirrels run"
498
+
499
+ Arguments:
500
+ uvicorn_args: List of arguments to pass to uvicorn.run. Supports "host", "port", and "forwarded_allow_ips"
501
+ """
502
+ host = uvicorn_args.host
503
+ port = uvicorn_args.port
504
+ forwarded_allow_ips = uvicorn_args.forwarded_allow_ips
505
+
506
+ server = self.get_fastapi_components(host=host, port=port, is_standalone_mode=True)
507
+
508
+ root_app = FastAPI(lifespan=server.lifespan)
509
+ root_app.mount(server.mount_path, server.fastapi_app)
510
+
511
+ # Enable CORS handling on the root app so preflight requests (OPTIONS)
512
+ # to top-level endpoints like `/.well-known/oauth-protected-resource` do not 405.
513
+ allowed_credential_origins = self.env_vars.auth_credential_origins
514
+ configurables_as_headers: list[str] = []
515
+ for name in self.manifest_cfg.configurables.keys():
516
+ configurables_as_headers.append(f"x-config-{name}") # underscore version
517
+ configurables_as_headers.append(f"x-config-{u.normalize_name_for_api(name)}") # dash version
518
+
519
+ root_app.add_middleware(
520
+ SmartCORSMiddleware,
521
+ allowed_credential_origins=allowed_credential_origins,
522
+ configurables_as_headers=configurables_as_headers,
523
+ )
524
+
525
+ @root_app.get("/.well-known/oauth-protected-resource", tags=["Authentication"])
526
+ async def oauth_protected_resource(request: Request) -> rm.OAuthProtectedResourceMetadata:
527
+ resource = str(request.base_url).rstrip("/")
528
+
529
+ auth_servers: list[str] = []
530
+ for provider in self.project._auth.auth_providers:
531
+ auth_servers.append(provider.provider_configs.server_url)
532
+
533
+ return rm.OAuthProtectedResourceMetadata(
534
+ resource=resource,
535
+ authorization_servers=list(set(auth_servers)),
536
+ scopes_supported=["email", "profile"],
537
+ )
538
+
539
+ mount_path_stripped = server.mount_path.rstrip("/")
540
+ if mount_path_stripped != "":
541
+ root_app.add_route("/mcp", self._mcp_app, methods=["GET", "POST"])
542
+
543
+ @root_app.get("/", include_in_schema=False)
544
+ async def redirect_to_studio():
545
+ return RedirectResponse(url=f"{mount_path_stripped}/studio")
546
+
547
+ # Run the API Server
548
+ import uvicorn
549
+ uvicorn.run(
550
+ root_app, host=host, port=port, proxy_headers=True, forwarded_allow_ips=forwarded_allow_ips
551
+ )
552
+