Skip to content

Commit 47d9ef6

Browse files
committed
Add utils.py file with functions for logging. Implement logic for having one logging file for a report object and all its components. Also, add error handling and logging into the StreamlitReportView class
1 parent 6c87855 commit 47d9ef6

File tree

7 files changed

+499
-156
lines changed

7 files changed

+499
-156
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Byte-compiled / optimized / DLL files
22
__pycache__/
33
report/__pycache__/
4+
report/helpers/__pycache__/
45
*.py[cod]
56
*$py.class
67

report/helpers/utils.py

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import os
2+
import sys
3+
from datetime import datetime
4+
import logging
5+
6+
## CHECKS
7+
def assert_path(filepath: str):
8+
"""
9+
Checks that fpath is a string and that it exists
10+
11+
PARAMS
12+
-----
13+
- filepath (str): the filepath or folderpath
14+
15+
OUTPUTS
16+
-----
17+
- raises assertion error if filepath is not a string or doesn't exist
18+
"""
19+
20+
assert isinstance(filepath, str), f"filepath must be a string: {filepath}"
21+
assert os.path.exists(
22+
os.path.abspath(filepath)
23+
), f"filepath does not exist: {os.path.abspath(filepath)}"
24+
25+
26+
## LOGGING
27+
def get_basename(fname: None | str = None) -> str:
28+
"""
29+
- For a given filename, returns basename WITHOUT file extension
30+
- If no fname given (i.e., None) then return basename that the function is called in
31+
32+
PARAMS
33+
-----
34+
- fname (None or str): the filename to get basename of, or None
35+
36+
OUTPUTS
37+
-----
38+
- basename of given filepath or the current file the function is executed
39+
40+
EXAMPLES
41+
-----
42+
1)
43+
>>> get_basename()
44+
utils
45+
46+
2)
47+
>>> get_basename('this/is-a-filepath.csv')
48+
is-a-filepath
49+
"""
50+
if fname is not None:
51+
# PRECONDITION
52+
assert_path(fname)
53+
# MAIN FUNCTIONS
54+
return os.path.splitext(os.path.basename(fname))[0]
55+
else:
56+
return os.path.splitext(os.path.basename(sys.argv[0]))[0]
57+
58+
59+
def get_time(incl_time: bool = True, incl_timezone: bool = True) -> str:
60+
"""
61+
Gets current date, time (optional) and timezone (optional) for file naming
62+
63+
PARAMETERS
64+
-----
65+
- incl_time (bool): whether to include timestamp in the string
66+
- incl_timezone (bool): whether to include the timezone in the string
67+
68+
RETURNS
69+
-----
70+
- fname (str): includes date, timestamp and/or timezone
71+
connected by '_' in one string e.g. yyyyMMdd_hhmm_timezone
72+
73+
EXAMPLES
74+
-----
75+
1)
76+
>>> get_time()
77+
'20231019_101758_CEST'
78+
79+
2)
80+
>>> get_time(incl_time=False)
81+
'20231019_CEST'
82+
83+
"""
84+
85+
# PRECONDITIONALS
86+
assert isinstance(incl_time, bool), "incl_time must be True or False"
87+
assert isinstance(incl_timezone, bool), "incl_timezone must be True or False"
88+
89+
# MAIN FUNCTION
90+
# getting current time and timezone
91+
the_time = datetime.now()
92+
timezone = datetime.now().astimezone().tzname()
93+
# convert date parts to string
94+
y = str(the_time.year)
95+
M = str(the_time.month)
96+
d = str(the_time.day)
97+
h = str(the_time.hour)
98+
m = str(the_time.minute)
99+
s = str(the_time.second)
100+
# putting date parts into one string
101+
if incl_time and incl_timezone:
102+
fname = "_".join([y + M + d, h + m + s, timezone])
103+
elif incl_time:
104+
fname = "_".join([y + M + d, h + m + s])
105+
elif incl_timezone:
106+
fname = "_".join([y + M + d, timezone])
107+
else:
108+
fname = y + M + d
109+
110+
# POSTCONDITIONALS
111+
parts = fname.split("_")
112+
if incl_time and incl_timezone:
113+
assert len(parts) == 3, f"time and/or timezone inclusion issue: {fname}"
114+
elif incl_time or incl_timezone:
115+
assert len(parts) == 2, f"time/timezone inclusion issue: {fname}"
116+
else:
117+
assert len(parts) == 1, f"time/timezone inclusion issue: {fname}"
118+
119+
return fname
120+
121+
122+
def generate_log_filename(folder: str = "logs", suffix: str = "") -> str:
123+
"""
124+
Creates log file name and path
125+
126+
PARAMETERS
127+
-----
128+
folder (str): name of the folder to put the log file in
129+
suffix (str): anything else you want to add to the log file name
130+
131+
RETURNS
132+
-----
133+
log_filepath (str): the file path to the log file
134+
"""
135+
# PRECONDITIONS
136+
assert_path(folder)
137+
138+
# MAIN FUNCTION
139+
log_filename = get_time(incl_timezone=False) + "_" + suffix + ".log"
140+
log_filepath = os.path.join(folder, log_filename)
141+
142+
return log_filepath
143+
144+
145+
def init_log(filename: str, display: bool = False, logger_id: str | None = None):
146+
"""
147+
- Custom python logger configuration (basicConfig())
148+
with two handlers (for stdout and for file)
149+
- from: https://stackoverflow.com/a/44760039
150+
- Keeps a log record file of the python application, with option to
151+
display in stdout
152+
153+
PARAMETERS
154+
-----
155+
- filename (str): filepath to log record file
156+
- display (bool): whether to print the logs to whatever standard output
157+
- logger_id (str): an optional identifier for yourself,
158+
if None then defaults to 'root'
159+
160+
RETURNS
161+
-----
162+
- logger object
163+
164+
EXAMPLE
165+
-----
166+
>>> logger = init_log('logs/tmp.log', display=True)
167+
>>> logger.info('Loading things')
168+
[2023-10-20 10:38:03,074] root: INFO - Loading things
169+
"""
170+
# PRECONDITIONALS
171+
assert isinstance(filename, str), "filename must be a string"
172+
assert (
173+
isinstance(logger_id, str) or logger_id is None
174+
), "logger_id must be a string or None"
175+
176+
# MAIN FUNCTION
177+
# init handlers
178+
file_handler = logging.FileHandler(filename=filename)
179+
stdout_handler = logging.StreamHandler(stream=sys.stdout)
180+
if display:
181+
handlers = [file_handler, stdout_handler]
182+
else:
183+
handlers = [file_handler]
184+
185+
# logger configuration
186+
logging.basicConfig(
187+
# level=logging.DEBUG,
188+
format="[%(asctime)s] %(name)s: %(levelname)s - %(message)s",
189+
handlers=handlers,
190+
)
191+
logging.getLogger("matplotlib.font_manager").disabled = True
192+
193+
# instantiate the logger
194+
logger = logging.getLogger(logger_id)
195+
logger.setLevel(logging.DEBUG)
196+
197+
return logger
198+
199+
200+
def get_logger():
201+
"""
202+
Putting at all together to init the log file.
203+
"""
204+
# get log suffix, which will be the current script's base file name
205+
log_suffix = get_basename()
206+
# generate log file name
207+
log_file = generate_log_filename(suffix=log_suffix)
208+
# init logger
209+
logger = init_log(log_file, display=True)
210+
# log it
211+
logger.info(f"Path to log file: {log_file}")
212+
213+
return logger

report/main.py

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,31 @@
22
import quarto_reportview as doc_reportview
33
from metadata_manager import MetadataManager
44
from report import ReportType
5+
from helpers.utils import get_logger
56

67
if __name__ == '__main__':
7-
# Load report metadata from YAML file
8-
yaml_manager = MetadataManager()
9-
8+
# Create logging file
9+
logger = get_logger()
10+
11+
# Load report object and metadata from YAML file
12+
yaml_manager = MetadataManager(logger)
1013
report, report_metadata = yaml_manager.load_report_metadata('./report_metadata_micw2graph.yaml')
1114

1215
# Create report view
13-
doc_report = doc_reportview.QuartoReportView(report_metadata['report']['id'], report_metadata['report']['name'],
14-
report=report,
15-
report_type = ReportType[report_metadata['report']['report_type'].upper()],
16-
report_format = doc_reportview.ReportFormat[report_metadata['report']['report_format'].upper()],
17-
columns=None)
18-
doc_report.generate_report(output_dir="quarto_report/")
19-
doc_report.run_report(output_dir="quarto_report/")
16+
# doc_report = doc_reportview.QuartoReportView(report_metadata['report']['id'],
17+
# report_metadata['report']['name'],
18+
# report=report,
19+
# report_type = ReportType[report_metadata['report']['report_type'].upper()],
20+
# report_format = doc_reportview.ReportFormat[report_metadata['report']['report_format'].upper()],
21+
# columns=None)
22+
# doc_report.generate_report()
23+
# doc_report.run_report()
2024

21-
st_report = st_reportview.StreamlitReportView(report_metadata['report']['id'], report_metadata['report']['name'],
22-
report=report, report_type = ReportType[report_metadata['report']['report_type'].upper()], columns=None)
23-
st_report.generate_report(output_dir="streamlit_report/sections")
24-
st_report.run_report(output_dir="streamlit_report/sections")
25+
st_report = st_reportview.StreamlitReportView(report_metadata['report']['id'],
26+
report_metadata['report']['name'],
27+
report=report,
28+
report_type = ReportType[report_metadata['report']['report_type'].upper()],
29+
columns=None)
30+
st_report.generate_report()
31+
st_report.run_report()
2532

report/metadata_manager.py

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,26 @@
11
import os
22
import yaml
33
import report as r
4+
import logging
45
from enum import StrEnum
5-
from typing import Type
6+
from typing import Type, Optional
7+
from helpers.utils import get_logger
68

79
class MetadataManager:
810
"""
911
Class for handling metadata of reports from YAML files and creating report objects.
1012
"""
13+
def __init__(self, logger: Optional[logging.Logger] = None):
14+
"""
15+
Initializes the MetadataManager with an optional logger.
16+
17+
Parameters
18+
----------
19+
logger : logging.Logger, optional
20+
A logger object to track warnings, errors, and info messages. If not provided,
21+
a default logger will be used.
22+
"""
23+
self.logger = logger or get_logger()
1124

1225
def load_report_metadata(self, file_path: str) -> tuple[r.Report, dict]:
1326
"""
@@ -30,33 +43,39 @@ def load_report_metadata(self, file_path: str) -> tuple[r.Report, dict]:
3043
ValueError
3144
If the YAML file is corrupted or contains missing/invalid values.
3245
"""
46+
self.logger.info(f"Loading report metadata from {file_path}")
3347
# Check the existence of the file_path
3448
if not os.path.exists(file_path):
49+
self.logger.error(f"Config file not found: {file_path}")
3550
raise FileNotFoundError(f"The config file at {file_path} was not found.")
3651

3752
# Load the YAML configuration file
3853
with open(file_path, 'r') as file:
3954
try:
4055
metadata = yaml.safe_load(file)
4156
except yaml.YAMLError as exc:
57+
self.logger.error(f"Error parsing YAML file at {file_path}: {exc}")
4258
raise ValueError(f"Error parsing YAML file: {exc}")
4359

60+
self.logger.info("Successfully loaded metadata. Creating report object.")
4461
# Create a Report object from metadata
4562
report = r.Report(
4663
id=metadata['report']['id'],
4764
name=metadata['report']['name'],
65+
sections=[],
4866
title=metadata['report'].get('title'),
4967
description=metadata['report'].get('description'),
5068
graphical_abstract=metadata['report'].get('graphical_abstract'),
5169
logo=metadata['report'].get('logo'),
52-
sections=[]
70+
logger = self.logger
5371
)
5472

5573
# Create sections and subsections
5674
for section_data in metadata.get('sections', []):
5775
section = self._create_section(section_data)
5876
report.sections.append(section)
5977

78+
self.logger.info(f"Report '{report.name}' initialized with {len(report.sections)} sections.")
6079
return report, metadata
6180

6281
def _create_section(self, section_data: dict) -> r.Section:
@@ -86,7 +105,7 @@ def _create_section(self, section_data: dict) -> r.Section:
86105
for subsection_data in section_data.get('subsections', []):
87106
subsection = self._create_subsection(subsection_data)
88107
section.subsections.append(subsection)
89-
108+
90109
return section
91110

92111
def _create_subsection(self, subsection_data: dict) -> r.Subsection:
@@ -174,7 +193,8 @@ def _create_plot_component(self, component_data: dict) -> r.Plot:
174193
int_visualization_tool=int_visualization_tool,
175194
title=component_data.get('title'),
176195
caption=component_data.get('caption'),
177-
csv_network_format=csv_network_format
196+
csv_network_format=csv_network_format,
197+
logger = self.logger
178198
)
179199

180200
def _create_dataframe_component(self, component_data: dict) -> r.DataFrame:
@@ -190,7 +210,7 @@ def _create_dataframe_component(self, component_data: dict) -> r.DataFrame:
190210
-------
191211
DataFrame
192212
A DataFrame object populated with the provided metadata.
193-
"""
213+
"""
194214
# Validate enum field and return dataframe
195215
file_format = self._validate_enum_value(r.DataFrameFormat, component_data['file_format'])
196216
return r.DataFrame(
@@ -200,7 +220,8 @@ def _create_dataframe_component(self, component_data: dict) -> r.DataFrame:
200220
file_format=file_format,
201221
delimiter=component_data.get('delimiter'),
202222
title=component_data.get('title'),
203-
caption=component_data.get('caption')
223+
caption=component_data.get('caption'),
224+
logger = self.logger
204225
)
205226

206227
def _create_markdown_component(self, component_data: dict) -> r.Markdown:
@@ -222,7 +243,8 @@ def _create_markdown_component(self, component_data: dict) -> r.Markdown:
222243
name=component_data['name'],
223244
file_path=component_data['file_path'],
224245
title=component_data.get('title'),
225-
caption=component_data.get('caption')
246+
caption=component_data.get('caption'),
247+
logger = self.logger
226248
)
227249

228250
def _validate_enum_value(self, enum_class: Type[StrEnum], value: str) -> StrEnum:
@@ -249,4 +271,5 @@ def _validate_enum_value(self, enum_class: Type[StrEnum], value: str) -> StrEnum
249271
try:
250272
return enum_class[value.upper()]
251273
except KeyError:
274+
self.logger.error(f"Invalid value for {enum_class.__name__}: {value}")
252275
raise ValueError(f"Invalid {enum_class.__name__}: {value}")

0 commit comments

Comments
 (0)