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.
Files changed (93) hide show
  1. squirrels/__init__.py +4 -0
  2. squirrels/_api_routes/__init__.py +5 -0
  3. squirrels/_api_routes/auth.py +337 -0
  4. squirrels/_api_routes/base.py +196 -0
  5. squirrels/_api_routes/dashboards.py +156 -0
  6. squirrels/_api_routes/data_management.py +148 -0
  7. squirrels/_api_routes/datasets.py +220 -0
  8. squirrels/_api_routes/project.py +289 -0
  9. squirrels/_api_server.py +440 -792
  10. squirrels/_arguments/__init__.py +0 -0
  11. squirrels/_arguments/{_init_time_args.py → init_time_args.py} +23 -43
  12. squirrels/_arguments/{_run_time_args.py → run_time_args.py} +32 -68
  13. squirrels/_auth.py +590 -264
  14. squirrels/_command_line.py +130 -58
  15. squirrels/_compile_prompts.py +147 -0
  16. squirrels/_connection_set.py +16 -15
  17. squirrels/_constants.py +36 -11
  18. squirrels/_dashboards.py +179 -0
  19. squirrels/_data_sources.py +40 -34
  20. squirrels/_dataset_types.py +16 -11
  21. squirrels/_env_vars.py +209 -0
  22. squirrels/_exceptions.py +9 -37
  23. squirrels/_http_error_responses.py +52 -0
  24. squirrels/_initializer.py +7 -6
  25. squirrels/_logging.py +121 -0
  26. squirrels/_manifest.py +155 -77
  27. squirrels/_mcp_server.py +578 -0
  28. squirrels/_model_builder.py +11 -55
  29. squirrels/_model_configs.py +5 -5
  30. squirrels/_model_queries.py +1 -1
  31. squirrels/_models.py +276 -143
  32. squirrels/_package_data/base_project/.env +1 -24
  33. squirrels/_package_data/base_project/.env.example +31 -17
  34. squirrels/_package_data/base_project/connections.yml +4 -3
  35. squirrels/_package_data/base_project/dashboards/dashboard_example.py +13 -7
  36. squirrels/_package_data/base_project/dashboards/dashboard_example.yml +6 -6
  37. squirrels/_package_data/base_project/docker/Dockerfile +2 -2
  38. squirrels/_package_data/base_project/docker/compose.yml +1 -1
  39. squirrels/_package_data/base_project/duckdb_init.sql +1 -0
  40. squirrels/_package_data/base_project/models/builds/build_example.py +2 -2
  41. squirrels/_package_data/base_project/models/dbviews/dbview_example.sql +7 -2
  42. squirrels/_package_data/base_project/models/dbviews/dbview_example.yml +16 -10
  43. squirrels/_package_data/base_project/models/federates/federate_example.py +27 -17
  44. squirrels/_package_data/base_project/models/federates/federate_example.sql +3 -7
  45. squirrels/_package_data/base_project/models/federates/federate_example.yml +7 -7
  46. squirrels/_package_data/base_project/models/sources.yml +5 -6
  47. squirrels/_package_data/base_project/parameters.yml +24 -38
  48. squirrels/_package_data/base_project/pyconfigs/connections.py +8 -3
  49. squirrels/_package_data/base_project/pyconfigs/context.py +26 -14
  50. squirrels/_package_data/base_project/pyconfigs/parameters.py +124 -81
  51. squirrels/_package_data/base_project/pyconfigs/user.py +48 -15
  52. squirrels/_package_data/base_project/resources/public/.gitkeep +0 -0
  53. squirrels/_package_data/base_project/seeds/seed_categories.yml +1 -1
  54. squirrels/_package_data/base_project/seeds/seed_subcategories.yml +1 -1
  55. squirrels/_package_data/base_project/squirrels.yml.j2 +21 -31
  56. squirrels/_package_data/templates/login_successful.html +53 -0
  57. squirrels/_package_data/templates/squirrels_studio.html +22 -0
  58. squirrels/_parameter_configs.py +43 -22
  59. squirrels/_parameter_options.py +1 -1
  60. squirrels/_parameter_sets.py +41 -30
  61. squirrels/_parameters.py +560 -123
  62. squirrels/_project.py +487 -277
  63. squirrels/_py_module.py +71 -10
  64. squirrels/_request_context.py +33 -0
  65. squirrels/_schemas/__init__.py +0 -0
  66. squirrels/_schemas/auth_models.py +83 -0
  67. squirrels/_schemas/query_param_models.py +70 -0
  68. squirrels/_schemas/request_models.py +26 -0
  69. squirrels/_schemas/response_models.py +286 -0
  70. squirrels/_seeds.py +52 -13
  71. squirrels/_sources.py +29 -23
  72. squirrels/_utils.py +221 -42
  73. squirrels/_version.py +1 -3
  74. squirrels/arguments.py +7 -2
  75. squirrels/auth.py +4 -0
  76. squirrels/connections.py +2 -0
  77. squirrels/dashboards.py +3 -1
  78. squirrels/data_sources.py +6 -0
  79. squirrels/parameter_options.py +5 -0
  80. squirrels/parameters.py +5 -0
  81. squirrels/types.py +10 -3
  82. squirrels-0.6.0.post0.dist-info/METADATA +148 -0
  83. squirrels-0.6.0.post0.dist-info/RECORD +101 -0
  84. {squirrels-0.5.0b3.dist-info → squirrels-0.6.0.post0.dist-info}/WHEEL +1 -1
  85. squirrels/_api_response_models.py +0 -190
  86. squirrels/_dashboard_types.py +0 -82
  87. squirrels/_dashboards_io.py +0 -79
  88. squirrels-0.5.0b3.dist-info/METADATA +0 -110
  89. squirrels-0.5.0b3.dist-info/RECORD +0 -80
  90. /squirrels/_package_data/base_project/{assets → resources}/expenses.db +0 -0
  91. /squirrels/_package_data/base_project/{assets → resources}/weather.db +0 -0
  92. {squirrels-0.5.0b3.dist-info → squirrels-0.6.0.post0.dist-info}/entry_points.txt +0 -0
  93. {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
- Specific error code ranges are reserved for specific categories of errors.
6
- 0-19: 401 unauthorized errors
7
- 20-39: 403 forbidden errors
8
- 40-59: 404 not found errors
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, error_code: int, message: str, *args) -> None:
41
- self.error_code = error_code
42
- super().__init__(message, *args)
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 _copy_database_file(self, filepath: str):
71
- self._copy_file(Path(c.DATABASE_FOLDER, filepath))
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 args.curr_dir:
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._copy_database_file(c.EXPENSES_DB)
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 in current directory!\n")
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._copy_database_file(args.file_name)
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