squirrels 0.5.0rc0__py3-none-any.whl → 0.5.1__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.
- dateutils/__init__.py +6 -0
- dateutils/_enums.py +25 -0
- squirrels/dateutils.py → dateutils/_implementation.py +58 -111
- dateutils/types.py +6 -0
- squirrels/__init__.py +10 -12
- squirrels/_api_routes/__init__.py +5 -0
- squirrels/_api_routes/auth.py +271 -0
- squirrels/_api_routes/base.py +171 -0
- squirrels/_api_routes/dashboards.py +158 -0
- squirrels/_api_routes/data_management.py +148 -0
- squirrels/_api_routes/datasets.py +265 -0
- squirrels/_api_routes/oauth2.py +298 -0
- squirrels/_api_routes/project.py +252 -0
- squirrels/_api_server.py +245 -781
- squirrels/_arguments/__init__.py +0 -0
- squirrels/{arguments → _arguments}/init_time_args.py +7 -2
- squirrels/{arguments → _arguments}/run_time_args.py +13 -35
- squirrels/_auth.py +720 -212
- squirrels/_command_line.py +81 -41
- squirrels/_compile_prompts.py +147 -0
- squirrels/_connection_set.py +16 -7
- squirrels/_constants.py +29 -9
- squirrels/{_dashboards_io.py → _dashboards.py} +87 -6
- squirrels/_data_sources.py +570 -0
- squirrels/{dataset_result.py → _dataset_types.py} +2 -4
- squirrels/_exceptions.py +9 -37
- squirrels/_initializer.py +83 -59
- squirrels/_logging.py +117 -0
- squirrels/_manifest.py +129 -62
- squirrels/_model_builder.py +10 -52
- squirrels/_model_configs.py +3 -3
- squirrels/_model_queries.py +1 -1
- squirrels/_models.py +249 -118
- squirrels/{package_data → _package_data}/base_project/.env +16 -4
- squirrels/{package_data → _package_data}/base_project/.env.example +15 -3
- squirrels/{package_data → _package_data}/base_project/connections.yml +4 -3
- squirrels/{package_data → _package_data}/base_project/dashboards/dashboard_example.py +4 -4
- squirrels/_package_data/base_project/dashboards/dashboard_example.yml +22 -0
- squirrels/{package_data → _package_data}/base_project/duckdb_init.sql +1 -0
- squirrels/_package_data/base_project/macros/macros_example.sql +17 -0
- squirrels/{package_data → _package_data}/base_project/models/builds/build_example.py +2 -2
- squirrels/{package_data → _package_data}/base_project/models/builds/build_example.sql +1 -1
- squirrels/{package_data → _package_data}/base_project/models/builds/build_example.yml +2 -0
- squirrels/_package_data/base_project/models/dbviews/dbview_example.sql +17 -0
- squirrels/_package_data/base_project/models/dbviews/dbview_example.yml +32 -0
- squirrels/_package_data/base_project/models/federates/federate_example.py +48 -0
- squirrels/_package_data/base_project/models/federates/federate_example.sql +21 -0
- squirrels/{package_data → _package_data}/base_project/models/federates/federate_example.yml +7 -7
- squirrels/{package_data → _package_data}/base_project/models/sources.yml +5 -6
- squirrels/{package_data → _package_data}/base_project/parameters.yml +32 -45
- squirrels/_package_data/base_project/pyconfigs/connections.py +18 -0
- squirrels/{package_data → _package_data}/base_project/pyconfigs/context.py +31 -22
- squirrels/_package_data/base_project/pyconfigs/parameters.py +141 -0
- squirrels/_package_data/base_project/pyconfigs/user.py +44 -0
- squirrels/{package_data → _package_data}/base_project/seeds/seed_categories.yml +1 -1
- squirrels/{package_data → _package_data}/base_project/seeds/seed_subcategories.yml +1 -1
- squirrels/_package_data/base_project/squirrels.yml.j2 +61 -0
- squirrels/_package_data/templates/dataset_results.html +112 -0
- squirrels/_package_data/templates/oauth_login.html +271 -0
- squirrels/_package_data/templates/squirrels_studio.html +20 -0
- squirrels/_parameter_configs.py +76 -55
- squirrels/_parameter_options.py +348 -0
- squirrels/_parameter_sets.py +53 -45
- squirrels/_parameters.py +1664 -0
- squirrels/_project.py +403 -242
- squirrels/_py_module.py +3 -2
- squirrels/_request_context.py +33 -0
- squirrels/_schemas/__init__.py +0 -0
- squirrels/_schemas/auth_models.py +167 -0
- squirrels/_schemas/query_param_models.py +75 -0
- squirrels/{_api_response_models.py → _schemas/response_models.py} +48 -18
- squirrels/_seeds.py +1 -1
- squirrels/_sources.py +23 -19
- squirrels/_utils.py +121 -39
- squirrels/_version.py +1 -1
- squirrels/arguments.py +7 -0
- squirrels/auth.py +4 -0
- squirrels/connections.py +3 -0
- squirrels/dashboards.py +2 -81
- squirrels/data_sources.py +14 -563
- squirrels/parameter_options.py +13 -348
- squirrels/parameters.py +14 -1266
- squirrels/types.py +16 -0
- {squirrels-0.5.0rc0.dist-info → squirrels-0.5.1.dist-info}/METADATA +42 -30
- squirrels-0.5.1.dist-info/RECORD +98 -0
- squirrels/package_data/base_project/dashboards/dashboard_example.yml +0 -22
- squirrels/package_data/base_project/macros/macros_example.sql +0 -15
- squirrels/package_data/base_project/models/dbviews/dbview_example.sql +0 -12
- squirrels/package_data/base_project/models/dbviews/dbview_example.yml +0 -26
- squirrels/package_data/base_project/models/federates/federate_example.py +0 -44
- squirrels/package_data/base_project/models/federates/federate_example.sql +0 -17
- squirrels/package_data/base_project/pyconfigs/connections.py +0 -14
- squirrels/package_data/base_project/pyconfigs/parameters.py +0 -93
- squirrels/package_data/base_project/pyconfigs/user.py +0 -23
- squirrels/package_data/base_project/squirrels.yml.j2 +0 -71
- squirrels-0.5.0rc0.dist-info/RECORD +0 -70
- /squirrels/{package_data → _package_data}/base_project/assets/expenses.db +0 -0
- /squirrels/{package_data → _package_data}/base_project/assets/weather.db +0 -0
- /squirrels/{package_data → _package_data}/base_project/docker/.dockerignore +0 -0
- /squirrels/{package_data → _package_data}/base_project/docker/Dockerfile +0 -0
- /squirrels/{package_data → _package_data}/base_project/docker/compose.yml +0 -0
- /squirrels/{package_data/base_project/.gitignore → _package_data/base_project/gitignore} +0 -0
- /squirrels/{package_data → _package_data}/base_project/seeds/seed_categories.csv +0 -0
- /squirrels/{package_data → _package_data}/base_project/seeds/seed_subcategories.csv +0 -0
- /squirrels/{package_data → _package_data}/base_project/tmp/.gitignore +0 -0
- {squirrels-0.5.0rc0.dist-info → squirrels-0.5.1.dist-info}/WHEEL +0 -0
- {squirrels-0.5.0rc0.dist-info → squirrels-0.5.1.dist-info}/entry_points.txt +0 -0
- {squirrels-0.5.0rc0.dist-info → squirrels-0.5.1.dist-info}/licenses/LICENSE +0 -0
squirrels/_initializer.py
CHANGED
|
@@ -1,35 +1,37 @@
|
|
|
1
1
|
from typing import Optional
|
|
2
2
|
from datetime import datetime
|
|
3
|
+
from pathlib import Path
|
|
3
4
|
import inquirer, os, shutil, secrets
|
|
4
5
|
|
|
5
6
|
from . import _constants as c, _utils as u
|
|
6
7
|
|
|
7
|
-
base_proj_dir =
|
|
8
|
+
base_proj_dir = Path(os.path.dirname(__file__), c.PACKAGE_DATA_FOLDER, c.BASE_PROJECT_FOLDER)
|
|
8
9
|
|
|
9
10
|
TMP_FOLDER = "tmp"
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
class Initializer:
|
|
13
|
-
def __init__(self, *, project_name: Optional[str] = None,
|
|
14
|
-
self.project_name = project_name
|
|
15
|
-
self.
|
|
14
|
+
def __init__(self, *, project_name: Optional[str] = None, use_curr_dir: bool = False):
|
|
15
|
+
self.project_name = project_name if not use_curr_dir else None
|
|
16
|
+
self.use_curr_dir = use_curr_dir
|
|
16
17
|
|
|
17
|
-
def _path_exists(self, filepath:
|
|
18
|
+
def _path_exists(self, filepath: Path) -> bool:
|
|
18
19
|
return os.path.exists(filepath)
|
|
19
20
|
|
|
20
|
-
def _files_have_same_content(self, file1:
|
|
21
|
+
def _files_have_same_content(self, file1: Path, file2: Path) -> bool:
|
|
21
22
|
with open(file1, 'rb') as f1, open(file2, 'rb') as f2:
|
|
22
23
|
return f1.read() == f2.read()
|
|
23
24
|
|
|
24
|
-
def _add_timestamp_to_filename(self, path:
|
|
25
|
+
def _add_timestamp_to_filename(self, path: Path) -> Path:
|
|
25
26
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
26
27
|
new_filename = f"{path.stem}_{timestamp}{path.suffix}"
|
|
27
28
|
return path.with_name(new_filename)
|
|
28
29
|
|
|
29
|
-
def _copy_file(self, filepath:
|
|
30
|
-
|
|
30
|
+
def _copy_file(self, filepath: Path, *, src_folder: str = "", src_file: Path | None = None):
|
|
31
|
+
src_file = src_file if src_file is not None else filepath
|
|
32
|
+
src_path = Path(base_proj_dir, src_folder, src_file)
|
|
31
33
|
|
|
32
|
-
filepath2 =
|
|
34
|
+
filepath2 = Path(self.project_name, filepath) if self.project_name else filepath
|
|
33
35
|
dest_dir = os.path.dirname(filepath2)
|
|
34
36
|
if dest_dir != "":
|
|
35
37
|
os.makedirs(dest_dir, exist_ok=True)
|
|
@@ -40,8 +42,6 @@ class Initializer:
|
|
|
40
42
|
if self._files_have_same_content(src_path, filepath2):
|
|
41
43
|
perform_copy = False
|
|
42
44
|
extra_msg = "Skipping... file contents is same as source"
|
|
43
|
-
elif self.overwrite:
|
|
44
|
-
extra_msg = "Overwriting file..."
|
|
45
45
|
else:
|
|
46
46
|
filepath2 = self._add_timestamp_to_filename(old_filepath)
|
|
47
47
|
extra_msg = f'Creating file as "{filepath2}" instead...'
|
|
@@ -53,38 +53,38 @@ class Initializer:
|
|
|
53
53
|
shutil.copy(src_path, filepath2)
|
|
54
54
|
|
|
55
55
|
def _copy_macros_file(self, filepath: str):
|
|
56
|
-
self._copy_file(
|
|
56
|
+
self._copy_file(Path(c.MACROS_FOLDER, filepath))
|
|
57
57
|
|
|
58
58
|
def _copy_models_file(self, filepath: str):
|
|
59
|
-
self._copy_file(
|
|
59
|
+
self._copy_file(Path(c.MODELS_FOLDER, filepath))
|
|
60
60
|
|
|
61
61
|
def _copy_build_file(self, filepath: str):
|
|
62
|
-
self._copy_file(
|
|
62
|
+
self._copy_file(Path(c.MODELS_FOLDER, c.BUILDS_FOLDER, filepath))
|
|
63
63
|
|
|
64
64
|
def _copy_dbview_file(self, filepath: str):
|
|
65
|
-
self._copy_file(
|
|
65
|
+
self._copy_file(Path(c.MODELS_FOLDER, c.DBVIEWS_FOLDER, filepath))
|
|
66
66
|
|
|
67
67
|
def _copy_federate_file(self, filepath: str):
|
|
68
|
-
self._copy_file(
|
|
68
|
+
self._copy_file(Path(c.MODELS_FOLDER, c.FEDERATES_FOLDER, filepath))
|
|
69
69
|
|
|
70
70
|
def _copy_database_file(self, filepath: str):
|
|
71
|
-
self._copy_file(
|
|
71
|
+
self._copy_file(Path(c.DATABASE_FOLDER, filepath))
|
|
72
72
|
|
|
73
73
|
def _copy_pyconfig_file(self, filepath: str):
|
|
74
|
-
self._copy_file(
|
|
74
|
+
self._copy_file(Path(c.PYCONFIGS_FOLDER, filepath))
|
|
75
75
|
|
|
76
76
|
def _copy_seed_file(self, filepath: str):
|
|
77
|
-
self._copy_file(
|
|
77
|
+
self._copy_file(Path(c.SEEDS_FOLDER, filepath))
|
|
78
78
|
|
|
79
79
|
def _copy_dashboard_file(self, filepath: str):
|
|
80
|
-
self._copy_file(
|
|
80
|
+
self._copy_file(Path(c.DASHBOARDS_FOLDER, filepath))
|
|
81
81
|
|
|
82
82
|
def _create_manifest_file(self, has_connections: bool, has_parameters: bool):
|
|
83
83
|
def get_content(file_name: Optional[str]) -> str:
|
|
84
84
|
if file_name is None:
|
|
85
85
|
return ""
|
|
86
86
|
|
|
87
|
-
yaml_path =
|
|
87
|
+
yaml_path = Path(base_proj_dir, file_name)
|
|
88
88
|
return yaml_path.read_text()
|
|
89
89
|
|
|
90
90
|
file_name_dict = {
|
|
@@ -95,10 +95,10 @@ class Initializer:
|
|
|
95
95
|
|
|
96
96
|
manifest_template = get_content(c.MANIFEST_JINJA_FILE)
|
|
97
97
|
manifest_content = u.render_string(manifest_template, **substitutions)
|
|
98
|
-
output_path =
|
|
98
|
+
output_path = Path(base_proj_dir, TMP_FOLDER, c.MANIFEST_FILE)
|
|
99
99
|
output_path.write_text(manifest_content)
|
|
100
100
|
|
|
101
|
-
self._copy_file(
|
|
101
|
+
self._copy_file(Path(c.MANIFEST_FILE), src_folder=TMP_FOLDER)
|
|
102
102
|
|
|
103
103
|
def _copy_dotenv_files(self, admin_password: str | None = None):
|
|
104
104
|
substitutions = {
|
|
@@ -106,52 +106,76 @@ class Initializer:
|
|
|
106
106
|
"random_admin_password": admin_password if admin_password else secrets.token_urlsafe(8),
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
dotenv_path =
|
|
109
|
+
dotenv_path = Path(base_proj_dir, c.DOTENV_FILE)
|
|
110
110
|
contents = u.render_string(dotenv_path.read_text(), **substitutions)
|
|
111
111
|
|
|
112
|
-
output_path =
|
|
112
|
+
output_path = Path(base_proj_dir, TMP_FOLDER, c.DOTENV_FILE)
|
|
113
113
|
output_path.write_text(contents)
|
|
114
114
|
|
|
115
|
-
self._copy_file(
|
|
116
|
-
self._copy_file(
|
|
115
|
+
self._copy_file(Path(c.DOTENV_FILE), src_folder=TMP_FOLDER)
|
|
116
|
+
self._copy_file(Path(c.DOTENV_FILE + ".example"))
|
|
117
|
+
|
|
118
|
+
def _copy_gitignore_file(self):
|
|
119
|
+
self._copy_file(Path(c.GITIGNORE_FILE), src_file=Path("gitignore"))
|
|
117
120
|
|
|
118
121
|
def init_project(self, args):
|
|
119
|
-
options = ["
|
|
120
|
-
|
|
122
|
+
options = ["connections", "parameters", "build", "federate", "dashboard", "admin_password"]
|
|
123
|
+
CONNECTIONS, PARAMETERS, BUILD, FEDERATE, DASHBOARD, ADMIN_PASSWORD = options
|
|
121
124
|
|
|
122
125
|
# Add project name prompt if not provided
|
|
123
|
-
if self.project_name is None:
|
|
126
|
+
if self.project_name is None and not args.curr_dir:
|
|
124
127
|
questions = [
|
|
125
|
-
inquirer.Text('project_name', message="What is your project name? (leave blank to create in current directory)")
|
|
128
|
+
inquirer.Text('project_name', message="What is your project folder name? (leave blank to create in current directory)")
|
|
126
129
|
]
|
|
127
130
|
answers = inquirer.prompt(questions)
|
|
128
131
|
assert isinstance(answers, dict)
|
|
129
132
|
self.project_name = answers['project_name']
|
|
130
133
|
|
|
131
134
|
answers = { x: getattr(args, x) for x in options }
|
|
132
|
-
if
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
)
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
)
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
135
|
+
if answers.get(DASHBOARD) is not None:
|
|
136
|
+
answers[DASHBOARD] = (answers[DASHBOARD] == 'y') # convert 'y' or 'n' to boolean
|
|
137
|
+
|
|
138
|
+
if not args.use_defaults:
|
|
139
|
+
questions = []
|
|
140
|
+
if answers.get(CONNECTIONS) is None:
|
|
141
|
+
questions.append(
|
|
142
|
+
inquirer.List(
|
|
143
|
+
CONNECTIONS, message=f"How would you like to configure the database connections?", choices=c.CONF_FORMAT_CHOICES
|
|
144
|
+
),
|
|
145
|
+
)
|
|
146
|
+
if answers.get(PARAMETERS) is None:
|
|
147
|
+
questions.append(
|
|
148
|
+
inquirer.List(
|
|
149
|
+
PARAMETERS, message=f"How would you like to configure the parameters?", choices=c.CONF_FORMAT_CHOICES2
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
if answers.get(BUILD) is None:
|
|
153
|
+
questions.append(
|
|
154
|
+
inquirer.List(
|
|
155
|
+
BUILD, message="What's the file format for the build model?", choices=c.FILE_TYPE_CHOICES
|
|
156
|
+
),
|
|
157
|
+
)
|
|
158
|
+
if answers.get(FEDERATE) is None:
|
|
159
|
+
questions.append(
|
|
160
|
+
inquirer.List(
|
|
161
|
+
FEDERATE, message="What's the file format for the federated model?", choices=c.FILE_TYPE_CHOICES
|
|
162
|
+
),
|
|
163
|
+
)
|
|
164
|
+
if answers.get(DASHBOARD) is None:
|
|
165
|
+
questions.append(
|
|
166
|
+
inquirer.Confirm(
|
|
167
|
+
DASHBOARD, message=f"Do you want to include a dashboard example?", default=False
|
|
168
|
+
),
|
|
169
|
+
)
|
|
170
|
+
if answers.get(ADMIN_PASSWORD) is None:
|
|
171
|
+
questions.append(
|
|
172
|
+
inquirer.Password(
|
|
173
|
+
"admin_password", message="What's the admin password? (leave blank to generate a random one)"
|
|
174
|
+
),
|
|
175
|
+
)
|
|
176
|
+
more_answers = inquirer.prompt(questions)
|
|
177
|
+
assert isinstance(more_answers, dict)
|
|
178
|
+
answers.update(more_answers)
|
|
155
179
|
|
|
156
180
|
def get_answer(key, default):
|
|
157
181
|
"""
|
|
@@ -177,7 +201,7 @@ class Initializer:
|
|
|
177
201
|
parameters_use_py = (parameters_format == c.PYTHON_FORMAT)
|
|
178
202
|
|
|
179
203
|
build_config_file = c.BUILD_FILE_STEM + ".yml"
|
|
180
|
-
build_format = get_answer(BUILD, c.
|
|
204
|
+
build_format = get_answer(BUILD, c.SQL_FILE_TYPE)
|
|
181
205
|
if build_format == c.SQL_FILE_TYPE:
|
|
182
206
|
build_file = c.BUILD_FILE_STEM + ".sql"
|
|
183
207
|
elif build_format == c.PYTHON_FILE_TYPE:
|
|
@@ -202,7 +226,7 @@ class Initializer:
|
|
|
202
226
|
self._copy_dotenv_files(admin_password)
|
|
203
227
|
self._create_manifest_file(connections_use_yaml, parameters_use_yaml)
|
|
204
228
|
|
|
205
|
-
self.
|
|
229
|
+
self._copy_gitignore_file()
|
|
206
230
|
|
|
207
231
|
if connections_use_py:
|
|
208
232
|
self._copy_pyconfig_file(c.CONNECTIONS_FILE)
|
|
@@ -242,7 +266,7 @@ class Initializer:
|
|
|
242
266
|
|
|
243
267
|
self._copy_database_file(c.EXPENSES_DB)
|
|
244
268
|
|
|
245
|
-
print(f"\nSuccessfully created new Squirrels project
|
|
269
|
+
print(f"\nSuccessfully created new Squirrels project!\n")
|
|
246
270
|
|
|
247
271
|
def get_file(self, args):
|
|
248
272
|
if args.file_name == c.DOTENV_FILE:
|
|
@@ -253,7 +277,7 @@ class Initializer:
|
|
|
253
277
|
print(f"You may also run `sqrl get-file {c.GITIGNORE_FILE}` to add a sample {c.GITIGNORE_FILE} file to your project.")
|
|
254
278
|
print()
|
|
255
279
|
elif args.file_name == c.GITIGNORE_FILE:
|
|
256
|
-
self.
|
|
280
|
+
self._copy_gitignore_file()
|
|
257
281
|
elif args.file_name == c.MANIFEST_FILE:
|
|
258
282
|
self._create_manifest_file(not args.no_connections, args.parameters)
|
|
259
283
|
elif args.file_name in (c.USER_FILE, c.CONNECTIONS_FILE, c.PARAMETERS_FILE, c.CONTEXT_FILE):
|
squirrels/_logging.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
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.format(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
|
+
base_path: str, log_to_file: bool, log_level: str, log_format: str, log_file_size_mb: int, 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
|
+
log_file_path = Path(base_path, c.LOGS_FOLDER, c.LOGS_FILE)
|
|
99
|
+
log_file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
|
|
101
|
+
# Rotating file handler
|
|
102
|
+
file_handler = RotatingFileHandler(
|
|
103
|
+
log_file_path,
|
|
104
|
+
maxBytes=log_file_size_mb * 1024 * 1024,
|
|
105
|
+
backupCount=log_file_backup_count
|
|
106
|
+
)
|
|
107
|
+
file_handler.setLevel(log_level.upper())
|
|
108
|
+
file_handler.setFormatter(file_formatter)
|
|
109
|
+
logger.addHandler(file_handler)
|
|
110
|
+
|
|
111
|
+
else:
|
|
112
|
+
stdout_handler = l.StreamHandler()
|
|
113
|
+
stdout_handler.setLevel(log_level.upper())
|
|
114
|
+
stdout_handler.setFormatter(stdout_formatter)
|
|
115
|
+
logger.addHandler(stdout_handler)
|
|
116
|
+
|
|
117
|
+
return logger
|