squirrels 0.5.0b3__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.
- squirrels/__init__.py +4 -0
- squirrels/_api_routes/__init__.py +5 -0
- squirrels/_api_routes/auth.py +337 -0
- squirrels/_api_routes/base.py +196 -0
- squirrels/_api_routes/dashboards.py +156 -0
- squirrels/_api_routes/data_management.py +148 -0
- squirrels/_api_routes/datasets.py +220 -0
- squirrels/_api_routes/project.py +289 -0
- squirrels/_api_server.py +440 -792
- squirrels/_arguments/__init__.py +0 -0
- squirrels/_arguments/{_init_time_args.py → init_time_args.py} +23 -43
- squirrels/_arguments/{_run_time_args.py → run_time_args.py} +32 -68
- squirrels/_auth.py +590 -264
- squirrels/_command_line.py +130 -58
- squirrels/_compile_prompts.py +147 -0
- squirrels/_connection_set.py +16 -15
- squirrels/_constants.py +36 -11
- squirrels/_dashboards.py +179 -0
- squirrels/_data_sources.py +40 -34
- squirrels/_dataset_types.py +16 -11
- squirrels/_env_vars.py +209 -0
- squirrels/_exceptions.py +9 -37
- squirrels/_http_error_responses.py +52 -0
- squirrels/_initializer.py +7 -6
- squirrels/_logging.py +121 -0
- squirrels/_manifest.py +155 -77
- squirrels/_mcp_server.py +578 -0
- squirrels/_model_builder.py +11 -55
- squirrels/_model_configs.py +5 -5
- squirrels/_model_queries.py +1 -1
- squirrels/_models.py +276 -143
- squirrels/_package_data/base_project/.env +1 -24
- squirrels/_package_data/base_project/.env.example +31 -17
- squirrels/_package_data/base_project/connections.yml +4 -3
- squirrels/_package_data/base_project/dashboards/dashboard_example.py +13 -7
- squirrels/_package_data/base_project/dashboards/dashboard_example.yml +6 -6
- squirrels/_package_data/base_project/docker/Dockerfile +2 -2
- squirrels/_package_data/base_project/docker/compose.yml +1 -1
- squirrels/_package_data/base_project/duckdb_init.sql +1 -0
- squirrels/_package_data/base_project/models/builds/build_example.py +2 -2
- squirrels/_package_data/base_project/models/dbviews/dbview_example.sql +7 -2
- squirrels/_package_data/base_project/models/dbviews/dbview_example.yml +16 -10
- squirrels/_package_data/base_project/models/federates/federate_example.py +27 -17
- squirrels/_package_data/base_project/models/federates/federate_example.sql +3 -7
- squirrels/_package_data/base_project/models/federates/federate_example.yml +7 -7
- squirrels/_package_data/base_project/models/sources.yml +5 -6
- squirrels/_package_data/base_project/parameters.yml +24 -38
- squirrels/_package_data/base_project/pyconfigs/connections.py +8 -3
- squirrels/_package_data/base_project/pyconfigs/context.py +26 -14
- squirrels/_package_data/base_project/pyconfigs/parameters.py +124 -81
- squirrels/_package_data/base_project/pyconfigs/user.py +48 -15
- squirrels/_package_data/base_project/resources/public/.gitkeep +0 -0
- squirrels/_package_data/base_project/seeds/seed_categories.yml +1 -1
- squirrels/_package_data/base_project/seeds/seed_subcategories.yml +1 -1
- squirrels/_package_data/base_project/squirrels.yml.j2 +21 -31
- squirrels/_package_data/templates/login_successful.html +53 -0
- squirrels/_package_data/templates/squirrels_studio.html +22 -0
- squirrels/_parameter_configs.py +43 -22
- squirrels/_parameter_options.py +1 -1
- squirrels/_parameter_sets.py +41 -30
- squirrels/_parameters.py +560 -123
- squirrels/_project.py +487 -277
- squirrels/_py_module.py +71 -10
- squirrels/_request_context.py +33 -0
- squirrels/_schemas/__init__.py +0 -0
- squirrels/_schemas/auth_models.py +83 -0
- squirrels/_schemas/query_param_models.py +70 -0
- squirrels/_schemas/request_models.py +26 -0
- squirrels/_schemas/response_models.py +286 -0
- squirrels/_seeds.py +52 -13
- squirrels/_sources.py +29 -23
- squirrels/_utils.py +221 -42
- squirrels/_version.py +1 -3
- squirrels/arguments.py +7 -2
- squirrels/auth.py +4 -0
- squirrels/connections.py +2 -0
- squirrels/dashboards.py +3 -1
- squirrels/data_sources.py +6 -0
- squirrels/parameter_options.py +5 -0
- squirrels/parameters.py +5 -0
- squirrels/types.py +10 -3
- squirrels-0.6.0.post0.dist-info/METADATA +148 -0
- squirrels-0.6.0.post0.dist-info/RECORD +101 -0
- {squirrels-0.5.0b3.dist-info → squirrels-0.6.0.post0.dist-info}/WHEEL +1 -1
- squirrels/_api_response_models.py +0 -190
- squirrels/_dashboard_types.py +0 -82
- squirrels/_dashboards_io.py +0 -79
- squirrels-0.5.0b3.dist-info/METADATA +0 -110
- squirrels-0.5.0b3.dist-info/RECORD +0 -80
- /squirrels/_package_data/base_project/{assets → resources}/expenses.db +0 -0
- /squirrels/_package_data/base_project/{assets → resources}/weather.db +0 -0
- {squirrels-0.5.0b3.dist-info → squirrels-0.6.0.post0.dist-info}/entry_points.txt +0 -0
- {squirrels-0.5.0b3.dist-info → squirrels-0.6.0.post0.dist-info}/licenses/LICENSE +0 -0
squirrels/_env_vars.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
from typing import Any, Literal, Optional
|
|
2
|
+
from typing_extensions import Self
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from pydantic import BaseModel, Field, field_validator, model_validator, ConfigDict
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from . import _constants as c
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SquirrelsEnvVars(BaseModel):
|
|
11
|
+
"""
|
|
12
|
+
Pydantic model for managing and validating Squirrels environment variables.
|
|
13
|
+
These variables are typically loaded from .env files or the system environment.
|
|
14
|
+
"""
|
|
15
|
+
model_config = ConfigDict(serialize_by_alias=True)
|
|
16
|
+
project_path: str
|
|
17
|
+
|
|
18
|
+
# Security
|
|
19
|
+
secret_key: Optional[str] = Field(
|
|
20
|
+
None, alias=c.SQRL_SECRET_KEY,
|
|
21
|
+
description="Secret key for JWT encoding/decoding and other security operations"
|
|
22
|
+
)
|
|
23
|
+
secret_admin_password: Optional[str] = Field(
|
|
24
|
+
None, alias=c.SQRL_SECRET_ADMIN_PASSWORD,
|
|
25
|
+
description="Password for the admin user"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Auth
|
|
29
|
+
auth_db_file_path: str = Field(
|
|
30
|
+
"{project_path}/target/auth.sqlite", alias=c.SQRL_AUTH_DB_FILE_PATH,
|
|
31
|
+
description="Path to the SQLite authentication database"
|
|
32
|
+
)
|
|
33
|
+
auth_token_expire_minutes: float = Field(
|
|
34
|
+
30, ge=0, alias=c.SQRL_AUTH_TOKEN_EXPIRE_MINUTES,
|
|
35
|
+
description="Expiration time for access tokens in minutes"
|
|
36
|
+
)
|
|
37
|
+
auth_credential_origins: list[str] = Field(
|
|
38
|
+
["https://squirrels-analytics.github.io"], alias=c.SQRL_AUTH_CREDENTIAL_ORIGINS,
|
|
39
|
+
description="Allowed origins for credentials (cookies)"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Permissions
|
|
43
|
+
elevated_access_level: Literal["admin", "member", "guest"] = Field(
|
|
44
|
+
"admin", alias=c.SQRL_PERMISSIONS_ELEVATED_ACCESS_LEVEL,
|
|
45
|
+
description="Minimum access level to access the studio"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Parameters Cache
|
|
49
|
+
parameters_cache_size: int = Field(
|
|
50
|
+
1024, ge=0, alias=c.SQRL_PARAMETERS_CACHE_SIZE,
|
|
51
|
+
description="Cache size for parameter configs"
|
|
52
|
+
)
|
|
53
|
+
parameters_cache_ttl_minutes: float = Field(
|
|
54
|
+
60, gt=0, alias=c.SQRL_PARAMETERS_CACHE_TTL_MINUTES,
|
|
55
|
+
description="Cache TTL for parameter configs in minutes"
|
|
56
|
+
)
|
|
57
|
+
parameters_datasource_refresh_minutes: float = Field(
|
|
58
|
+
60, alias=c.SQRL_PARAMETERS_DATASOURCE_REFRESH_MINUTES,
|
|
59
|
+
description="Interval in minutes for refreshing data sources. A non-positive value disables auto-refresh"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Datasets Cache
|
|
63
|
+
datasets_cache_size: int = Field(
|
|
64
|
+
128, ge=0, alias=c.SQRL_DATASETS_CACHE_SIZE,
|
|
65
|
+
description="Cache size for dataset results"
|
|
66
|
+
)
|
|
67
|
+
datasets_cache_ttl_minutes: float = Field(
|
|
68
|
+
60, gt=0, alias=c.SQRL_DATASETS_CACHE_TTL_MINUTES,
|
|
69
|
+
description="Cache TTL for dataset results in minutes"
|
|
70
|
+
)
|
|
71
|
+
datasets_max_rows_for_ai: int = Field(
|
|
72
|
+
100, ge=0, alias=c.SQRL_DATASETS_MAX_ROWS_FOR_AI,
|
|
73
|
+
description="Max rows for AI queries"
|
|
74
|
+
)
|
|
75
|
+
datasets_max_rows_output: int = Field(
|
|
76
|
+
100000, ge=0, alias=c.SQRL_DATASETS_MAX_ROWS_OUTPUT,
|
|
77
|
+
description="Max rows for dataset output"
|
|
78
|
+
)
|
|
79
|
+
datasets_sql_timeout_seconds: float = Field(
|
|
80
|
+
2.0, gt=0, alias=c.SQRL_DATASETS_SQL_TIMEOUT_SECONDS,
|
|
81
|
+
description="Timeout for SQL operations in seconds"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Dashboards Cache
|
|
85
|
+
dashboards_cache_size: int = Field(
|
|
86
|
+
128, ge=0, alias=c.SQRL_DASHBOARDS_CACHE_SIZE,
|
|
87
|
+
description="Cache size for dashboards"
|
|
88
|
+
)
|
|
89
|
+
dashboards_cache_ttl_minutes: float = Field(
|
|
90
|
+
60, gt=0, alias=c.SQRL_DASHBOARDS_CACHE_TTL_MINUTES,
|
|
91
|
+
description="Cache TTL for dashboards in minutes"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# Seeds
|
|
95
|
+
seeds_infer_schema: bool = Field(
|
|
96
|
+
True, alias=c.SQRL_SEEDS_INFER_SCHEMA,
|
|
97
|
+
description="Whether to infer schema for seeds"
|
|
98
|
+
)
|
|
99
|
+
seeds_na_values: list[str] = Field(
|
|
100
|
+
["NA"], alias=c.SQRL_SEEDS_NA_VALUES,
|
|
101
|
+
description="List of N/A values for seeds"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# Connections
|
|
105
|
+
connections_default_name_used: str = Field(
|
|
106
|
+
"default", alias=c.SQRL_CONNECTIONS_DEFAULT_NAME_USED,
|
|
107
|
+
description="Default connection name to use"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# VDL
|
|
111
|
+
vdl_catalog_db_path: str = Field(
|
|
112
|
+
"ducklake:{project_path}/target/vdl_catalog.duckdb", alias=c.SQRL_VDL_CATALOG_DB_PATH,
|
|
113
|
+
description="Path to the DuckDB catalog database"
|
|
114
|
+
)
|
|
115
|
+
vdl_data_path: str = Field(
|
|
116
|
+
"{project_path}/target/vdl_data/", alias=c.SQRL_VDL_DATA_PATH,
|
|
117
|
+
description="Path to the VDL data directory"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# Studio
|
|
121
|
+
studio_base_url: str = Field(
|
|
122
|
+
"https://squirrels-analytics.github.io/squirrels-studio-v2", alias=c.SQRL_STUDIO_BASE_URL,
|
|
123
|
+
description="Base URL for Squirrels Studio"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# Logging
|
|
127
|
+
logging_level: str = Field(
|
|
128
|
+
"INFO", alias=c.SQRL_LOGGING_LEVEL,
|
|
129
|
+
description="Logging level"
|
|
130
|
+
)
|
|
131
|
+
logging_format: str = Field(
|
|
132
|
+
"text", alias=c.SQRL_LOGGING_FORMAT,
|
|
133
|
+
description="Logging format"
|
|
134
|
+
)
|
|
135
|
+
logging_to_file: bool | str = Field(
|
|
136
|
+
False, alias=c.SQRL_LOGGING_TO_FILE,
|
|
137
|
+
description="Whether to log to file. Can be set to true to use the default 'logs/' folder, or a folder path to write to a custom folder."
|
|
138
|
+
)
|
|
139
|
+
logging_file_size_mb: float = Field(
|
|
140
|
+
50, gt=0, alias=c.SQRL_LOGGING_FILE_SIZE_MB,
|
|
141
|
+
description="Max log file size in MB"
|
|
142
|
+
)
|
|
143
|
+
logging_file_backup_count: int = Field(
|
|
144
|
+
1, ge=0, alias=c.SQRL_LOGGING_FILE_BACKUP_COUNT,
|
|
145
|
+
description="Number of backup log files to keep"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
@field_validator("project_path")
|
|
149
|
+
@classmethod
|
|
150
|
+
def validate_project_path_exists(cls, v: str) -> str:
|
|
151
|
+
"""Validate that the project_path is a folder that contains a squirrels.yml file."""
|
|
152
|
+
path = Path(v)
|
|
153
|
+
if not path.exists():
|
|
154
|
+
raise ValueError(f"Project path does not exist: {v}")
|
|
155
|
+
if not path.is_dir():
|
|
156
|
+
raise ValueError(f"Project path must be a directory, not a file: {v}")
|
|
157
|
+
# squirrels_yml = path / c.MANIFEST_FILE
|
|
158
|
+
# if not squirrels_yml.exists():
|
|
159
|
+
# raise ValueError(f"Project path must contain a {c.MANIFEST_FILE} file: {v}")
|
|
160
|
+
return v
|
|
161
|
+
|
|
162
|
+
@field_validator("auth_credential_origins", mode="before")
|
|
163
|
+
@classmethod
|
|
164
|
+
def parse_origins(cls, v: Any) -> list[str]:
|
|
165
|
+
if isinstance(v, str):
|
|
166
|
+
res = [x.strip() for x in v.split(",") if x.strip()]
|
|
167
|
+
return res or ["https://squirrels-analytics.github.io"]
|
|
168
|
+
return v
|
|
169
|
+
|
|
170
|
+
@field_validator("seeds_na_values", mode="before")
|
|
171
|
+
@classmethod
|
|
172
|
+
def parse_json_list(cls, v: Any) -> list[str]:
|
|
173
|
+
if isinstance(v, str):
|
|
174
|
+
try:
|
|
175
|
+
parsed = json.loads(v)
|
|
176
|
+
if not isinstance(parsed, list):
|
|
177
|
+
raise ValueError(f"The {c.SQRL_SEEDS_NA_VALUES} environment variable must be a JSON list")
|
|
178
|
+
return parsed
|
|
179
|
+
except json.JSONDecodeError:
|
|
180
|
+
return []
|
|
181
|
+
return v
|
|
182
|
+
|
|
183
|
+
@field_validator("logging_to_file", mode="before")
|
|
184
|
+
@classmethod
|
|
185
|
+
def parse_logging_to_file(cls, v: Any) -> bool | str:
|
|
186
|
+
if isinstance(v, str):
|
|
187
|
+
v_lower = v.lower()
|
|
188
|
+
if v_lower in ("true", "t", "1", "yes", "y", "on"):
|
|
189
|
+
return True
|
|
190
|
+
if v_lower in ("false", "f", "0", "no", "n", "off"):
|
|
191
|
+
return False
|
|
192
|
+
return v
|
|
193
|
+
return bool(v)
|
|
194
|
+
|
|
195
|
+
@field_validator("seeds_infer_schema", mode="before")
|
|
196
|
+
@classmethod
|
|
197
|
+
def parse_bool(cls, v: Any) -> bool:
|
|
198
|
+
if isinstance(v, str):
|
|
199
|
+
return v.lower() in ("true", "t", "1", "yes", "y", "on")
|
|
200
|
+
return bool(v)
|
|
201
|
+
|
|
202
|
+
@model_validator(mode="after")
|
|
203
|
+
def format_paths_with_filepath(self) -> Self:
|
|
204
|
+
"""Format paths containing {filepath} placeholder with the actual filepath value."""
|
|
205
|
+
self.auth_db_file_path = self.auth_db_file_path.format(project_path=self.project_path)
|
|
206
|
+
self.vdl_catalog_db_path = self.vdl_catalog_db_path.format(project_path=self.project_path)
|
|
207
|
+
self.vdl_data_path = self.vdl_data_path.format(project_path=self.project_path)
|
|
208
|
+
return self
|
|
209
|
+
|
squirrels/_exceptions.py
CHANGED
|
@@ -2,44 +2,16 @@ class InvalidInputError(Exception):
|
|
|
2
2
|
"""
|
|
3
3
|
Use this exception when the error is due to providing invalid inputs to the REST API
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
60-69: 409 conflict errors
|
|
10
|
-
70-99: Reserved for future use
|
|
11
|
-
100-199: 400 bad request errors related to authentication
|
|
12
|
-
200-299: 400 bad request errors related to data analytics
|
|
13
|
-
|
|
14
|
-
Error code definitions:
|
|
15
|
-
0 - Incorrect username or password
|
|
16
|
-
1 - Invalid authorization token
|
|
17
|
-
2 - Username not found for password change
|
|
18
|
-
3 - Incorrect password for password change
|
|
19
|
-
20 - Authorized user is forbidden to add or update users
|
|
20
|
-
21 - Authorized user is forbidden to delete users
|
|
21
|
-
22 - Cannot delete your own user
|
|
22
|
-
23 - Cannot delete the admin user
|
|
23
|
-
24 - Setting the admin user to non-admin is not permitted
|
|
24
|
-
25 - User does not have permission to access the dataset / dashboard
|
|
25
|
-
26 - User does not have permission to build the virtual data environment
|
|
26
|
-
27 - User does not have permission to query data models
|
|
27
|
-
40 - No token found for token_id
|
|
28
|
-
41 - No user found for username
|
|
29
|
-
60 - An existing build process is already running and a concurrent build is not allowed
|
|
30
|
-
61 - Model depends on static data models that cannot be found
|
|
31
|
-
100 - Missing required field 'username' or 'password' when adding a new user
|
|
32
|
-
101 - Username already exists when adding a new user
|
|
33
|
-
102 - Invalid user data when adding a new user
|
|
34
|
-
200 - Invalid value for dataset parameter
|
|
35
|
-
201 - Invalid query parameter provided
|
|
36
|
-
202 - Could not determine parent parameter for parameter refresh
|
|
37
|
-
203 - SQL query must be provided
|
|
38
|
-
204 - Failed to run provided SQL query
|
|
5
|
+
Attributes:
|
|
6
|
+
status_code: The HTTP status code to return
|
|
7
|
+
error: A short error message that should never change in the future
|
|
8
|
+
error_description: A detailed error message (that is allowed to change in the future)
|
|
39
9
|
"""
|
|
40
|
-
def __init__(self,
|
|
41
|
-
self.
|
|
42
|
-
|
|
10
|
+
def __init__(self, status_code: int, error: str, error_description: str, *args) -> None:
|
|
11
|
+
self.status_code = status_code
|
|
12
|
+
self.error = error
|
|
13
|
+
self.error_description = error_description
|
|
14
|
+
super().__init__(error_description, *args)
|
|
43
15
|
|
|
44
16
|
|
|
45
17
|
class ConfigurationError(Exception):
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from starlette.requests import Request
|
|
4
|
+
from starlette.responses import JSONResponse
|
|
5
|
+
|
|
6
|
+
from ._exceptions import InvalidInputError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _strip_path_suffix_from_base_url(request: Request, *, strip_path_suffix: str | None) -> str:
|
|
10
|
+
"""
|
|
11
|
+
Return a base URL with a known mount suffix removed.
|
|
12
|
+
|
|
13
|
+
In Squirrels, the main app mounts sub-apps like `/api/0` and `/mcp`. When we're
|
|
14
|
+
handling a request inside those sub-apps, `request.base_url` includes the mount
|
|
15
|
+
path. For `WWW-Authenticate` we want to point to a top-level endpoint on the main
|
|
16
|
+
app (same mount as the main app itself), so we strip only the *sub-app mount*
|
|
17
|
+
suffix (e.g. `/api/0` or `/mcp`), not any outer mount path.
|
|
18
|
+
"""
|
|
19
|
+
base_url = str(request.base_url).rstrip("/")
|
|
20
|
+
if strip_path_suffix:
|
|
21
|
+
suffix = strip_path_suffix.rstrip("/")
|
|
22
|
+
if suffix and base_url.endswith(suffix):
|
|
23
|
+
base_url = base_url[: -len(suffix)]
|
|
24
|
+
return base_url.rstrip("/")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def invalid_input_error_to_json_response(
|
|
28
|
+
request: Request,
|
|
29
|
+
exc: InvalidInputError,
|
|
30
|
+
*,
|
|
31
|
+
oauth_resource_metadata_path: str = "/.well-known/oauth-protected-resource",
|
|
32
|
+
strip_path_suffix: str | None = None,
|
|
33
|
+
is_mcp: bool = False,
|
|
34
|
+
) -> JSONResponse:
|
|
35
|
+
"""
|
|
36
|
+
Convert an InvalidInputError into the standard Squirrels JSON error response.
|
|
37
|
+
|
|
38
|
+
For 401s, also sets `WWW-Authenticate` with a top-level `resource_metadata` URL.
|
|
39
|
+
"""
|
|
40
|
+
response = JSONResponse(
|
|
41
|
+
status_code=exc.status_code,
|
|
42
|
+
content={"error": exc.error, "error_description": exc.error_description},
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
if exc.status_code == 401:
|
|
46
|
+
top_level_base_url = _strip_path_suffix_from_base_url(request, strip_path_suffix=strip_path_suffix)
|
|
47
|
+
resource_metadata_url = f"{top_level_base_url}{oauth_resource_metadata_path}"
|
|
48
|
+
realm = "mcp" if is_mcp else "api"
|
|
49
|
+
response.headers["WWW-Authenticate"] = f'Bearer realm="{realm}", resource_metadata="{resource_metadata_url}"'
|
|
50
|
+
|
|
51
|
+
return response
|
|
52
|
+
|
squirrels/_initializer.py
CHANGED
|
@@ -67,8 +67,8 @@ class Initializer:
|
|
|
67
67
|
def _copy_federate_file(self, filepath: str):
|
|
68
68
|
self._copy_file(Path(c.MODELS_FOLDER, c.FEDERATES_FOLDER, filepath))
|
|
69
69
|
|
|
70
|
-
def
|
|
71
|
-
self._copy_file(Path(c.
|
|
70
|
+
def _copy_resource_file(self, filepath: str):
|
|
71
|
+
self._copy_file(Path(c.RESOURCES_FOLDER, filepath))
|
|
72
72
|
|
|
73
73
|
def _copy_pyconfig_file(self, filepath: str):
|
|
74
74
|
self._copy_file(Path(c.PYCONFIGS_FOLDER, filepath))
|
|
@@ -123,7 +123,7 @@ class Initializer:
|
|
|
123
123
|
CONNECTIONS, PARAMETERS, BUILD, FEDERATE, DASHBOARD, ADMIN_PASSWORD = options
|
|
124
124
|
|
|
125
125
|
# Add project name prompt if not provided
|
|
126
|
-
if self.project_name is None and not
|
|
126
|
+
if self.project_name is None and not self.use_curr_dir:
|
|
127
127
|
questions = [
|
|
128
128
|
inquirer.Text('project_name', message="What is your project folder name? (leave blank to create in current directory)")
|
|
129
129
|
]
|
|
@@ -264,9 +264,10 @@ class Initializer:
|
|
|
264
264
|
self._copy_dashboard_file(c.DASHBOARD_FILE_STEM + ".py")
|
|
265
265
|
self._copy_dashboard_file(c.DASHBOARD_FILE_STEM + ".yml")
|
|
266
266
|
|
|
267
|
-
self.
|
|
267
|
+
self._copy_resource_file(c.EXPENSES_DB)
|
|
268
|
+
self._copy_file(Path(c.RESOURCES_FOLDER, c.PUBLIC_FOLDER, ".gitkeep"))
|
|
268
269
|
|
|
269
|
-
print(f"\nSuccessfully created new Squirrels project
|
|
270
|
+
print(f"\nSuccessfully created new Squirrels project!\n")
|
|
270
271
|
|
|
271
272
|
def get_file(self, args):
|
|
272
273
|
if args.file_name == c.DOTENV_FILE:
|
|
@@ -309,7 +310,7 @@ class Initializer:
|
|
|
309
310
|
self._copy_dashboard_file(args.file_name + ".py")
|
|
310
311
|
self._copy_dashboard_file(args.file_name + ".yml")
|
|
311
312
|
elif args.file_name in (c.EXPENSES_DB, c.WEATHER_DB):
|
|
312
|
-
self.
|
|
313
|
+
self._copy_resource_file(args.file_name)
|
|
313
314
|
elif args.file_name in (c.SEED_CATEGORY_FILE_STEM, c.SEED_SUBCATEGORY_FILE_STEM):
|
|
314
315
|
self._copy_seed_file(args.file_name + ".csv")
|
|
315
316
|
self._copy_seed_file(args.file_name + ".yml")
|
squirrels/_logging.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from logging.handlers import RotatingFileHandler
|
|
3
|
+
from uuid import uuid4
|
|
4
|
+
import logging as l, json
|
|
5
|
+
|
|
6
|
+
from . import _constants as c, _utils as u
|
|
7
|
+
from ._request_context import get_request_id
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _BaseFormatter(l.Formatter):
|
|
11
|
+
def _format_helper(self, level_for_print: str, record: l.LogRecord) -> str:
|
|
12
|
+
# Save original levelname
|
|
13
|
+
original_levelname = record.levelname
|
|
14
|
+
|
|
15
|
+
# Add padding to the levelname for printing
|
|
16
|
+
visible_length = len(record.levelname) + 1
|
|
17
|
+
padding_needed = max(1, 9 - visible_length)
|
|
18
|
+
padded_level = f"{level_for_print}:{' ' * padding_needed}"
|
|
19
|
+
record.levelname = padded_level
|
|
20
|
+
|
|
21
|
+
# Format the message
|
|
22
|
+
formatted = super().format(record)
|
|
23
|
+
|
|
24
|
+
# Append request ID if available
|
|
25
|
+
request_id = get_request_id()
|
|
26
|
+
request_id_str = f" [req_id: {request_id}]" if request_id else ""
|
|
27
|
+
formatted = formatted.replace("{request_id}", request_id_str)
|
|
28
|
+
|
|
29
|
+
# Restore original levelname
|
|
30
|
+
record.levelname = original_levelname
|
|
31
|
+
|
|
32
|
+
return formatted
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _ColoredFormatter(_BaseFormatter):
|
|
36
|
+
"""Custom formatter that adds colors to log levels for terminal output"""
|
|
37
|
+
|
|
38
|
+
# ANSI color codes
|
|
39
|
+
COLORS = {
|
|
40
|
+
'DEBUG': '\033[36m', # Cyan
|
|
41
|
+
'INFO': '\033[32m', # Green
|
|
42
|
+
'WARNING': '\033[33m', # Yellow
|
|
43
|
+
'ERROR': '\033[31m', # Red
|
|
44
|
+
'CRITICAL': '\033[35m', # Magenta
|
|
45
|
+
}
|
|
46
|
+
RESET = '\033[0m'
|
|
47
|
+
BOLD = '\033[1m'
|
|
48
|
+
|
|
49
|
+
def format(self, record: l.LogRecord) -> str:
|
|
50
|
+
# Add color to levelname with colon and padding
|
|
51
|
+
color = self.COLORS.get(record.levelname, '')
|
|
52
|
+
colored_level = f"{color}{record.levelname}{self.RESET}"
|
|
53
|
+
return self._format_helper(colored_level, record)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _PlainFormatter(_BaseFormatter):
|
|
57
|
+
"""Custom formatter that adds colon to log levels for file output"""
|
|
58
|
+
|
|
59
|
+
def format(self, record: l.LogRecord) -> str:
|
|
60
|
+
return self._format_helper(record.levelname, record)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _CustomJsonFormatter(l.Formatter):
|
|
64
|
+
def format(self, record: l.LogRecord) -> str:
|
|
65
|
+
super().format(record)
|
|
66
|
+
request_id = get_request_id()
|
|
67
|
+
info = {
|
|
68
|
+
"timestamp": self.formatTime(record),
|
|
69
|
+
"level": record.levelname,
|
|
70
|
+
"message": record.getMessage(),
|
|
71
|
+
"request_id": request_id,
|
|
72
|
+
}
|
|
73
|
+
output = {
|
|
74
|
+
"data": record.__dict__.get("data", {}),
|
|
75
|
+
"info": info
|
|
76
|
+
}
|
|
77
|
+
return json.dumps(output)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def get_logger(
|
|
81
|
+
project_path: str, log_to_file: bool | str, log_level: str, log_format: str, log_file_size_mb: float, log_file_backup_count: int
|
|
82
|
+
) -> u.Logger:
|
|
83
|
+
logger = u.Logger(name=uuid4().hex, level=log_level.upper())
|
|
84
|
+
|
|
85
|
+
# Determine the formatter based on log_format
|
|
86
|
+
if log_format.lower() == "json":
|
|
87
|
+
stdout_formatter = _CustomJsonFormatter()
|
|
88
|
+
file_formatter = _CustomJsonFormatter()
|
|
89
|
+
elif log_format.lower() == "text":
|
|
90
|
+
# Use colored formatter for stdout, plain formatter with colon for file
|
|
91
|
+
format_string = "%(levelname)s [%(asctime)s]{request_id} %(message)s"
|
|
92
|
+
stdout_formatter = _ColoredFormatter(format_string, datefmt="%Y-%m-%d %H:%M:%S")
|
|
93
|
+
file_formatter = _PlainFormatter(format_string, datefmt="%Y-%m-%d %H:%M:%S")
|
|
94
|
+
else:
|
|
95
|
+
raise ValueError("log_format must be either 'text' or 'json'")
|
|
96
|
+
|
|
97
|
+
if log_to_file:
|
|
98
|
+
if isinstance(log_to_file, str):
|
|
99
|
+
log_file_path = Path(project_path, log_to_file, c.LOGS_FILE)
|
|
100
|
+
else:
|
|
101
|
+
log_file_path = Path(project_path, c.LOGS_FOLDER, c.LOGS_FILE)
|
|
102
|
+
|
|
103
|
+
log_file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
|
|
105
|
+
# Rotating file handler
|
|
106
|
+
file_handler = RotatingFileHandler(
|
|
107
|
+
log_file_path,
|
|
108
|
+
maxBytes=int(log_file_size_mb * 1024 * 1024),
|
|
109
|
+
backupCount=log_file_backup_count
|
|
110
|
+
)
|
|
111
|
+
file_handler.setLevel(log_level.upper())
|
|
112
|
+
file_handler.setFormatter(file_formatter)
|
|
113
|
+
logger.addHandler(file_handler)
|
|
114
|
+
|
|
115
|
+
else:
|
|
116
|
+
stdout_handler = l.StreamHandler()
|
|
117
|
+
stdout_handler.setLevel(log_level.upper())
|
|
118
|
+
stdout_handler.setFormatter(stdout_formatter)
|
|
119
|
+
logger.addHandler(stdout_handler)
|
|
120
|
+
|
|
121
|
+
return logger
|