ecodev-core 0.0.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 ecodev-core might be problematic. Click here for more details.

@@ -0,0 +1,194 @@
1
+ """
2
+ Low level methods to retrieve data from db in a paginated way
3
+ """
4
+ from math import ceil
5
+ from typing import Any
6
+ from typing import Callable
7
+ from typing import Dict
8
+ from typing import List
9
+ from typing import Optional
10
+ from typing import Tuple
11
+ from typing import Union
12
+
13
+ import pandas as pd
14
+ from sqlmodel import col
15
+ from sqlmodel import or_
16
+ from sqlmodel import select
17
+ from sqlmodel import Session
18
+ from sqlmodel.sql.expression import Select
19
+ from sqlmodel.sql.expression import SelectOfScalar
20
+
21
+ from ecodev_core.db_connection import engine
22
+ from ecodev_core.db_filters import SERVER_SIDE_FILTERS
23
+ from ecodev_core.db_filters import ServerSideFilter
24
+ from ecodev_core.list_utils import first_or_default
25
+ from ecodev_core.pydantic_utils import Frozen
26
+
27
+ SelectOfScalar.inherit_cache = True # type: ignore
28
+ Select.inherit_cache = True # type: ignore
29
+ OPERATORS = ['>=', '<=', '!=', '=', '<', '>', 'contains ']
30
+
31
+
32
+ class ServerSideField(Frozen):
33
+ """
34
+ Simple class used for sever side data retrieval
35
+
36
+ Attributes are:
37
+ - col_name: the name as it will appear on the frontend interface
38
+ - field_name: the SQLModel attribute name associated with this field
39
+ - field: the SQLModel attribute associated with this field
40
+ - filter: the filtering mechanism to use for this field
41
+ """
42
+ col_name: str
43
+ field_name: str
44
+ field: Any
45
+ filter: ServerSideFilter
46
+
47
+
48
+ def count_rows(fields: List[ServerSideField],
49
+ model: Any,
50
+ limit: Union[int, None] = None,
51
+ filter_str: str = '',
52
+ search_str: str = '',
53
+ search_cols: Optional[List] = None) -> int:
54
+ """
55
+ Count the total number of rows in the db model, with statically defined field_filters fed with
56
+ dynamically set frontend filters. Divide this total number by limit to account for pagination.
57
+ """
58
+ with Session(engine) as session:
59
+ count = _get_full_query(fields, model, filter_str, session, True,
60
+ search_str, search_cols).count()
61
+
62
+ return ceil(count / limit) if limit else count
63
+
64
+
65
+ def get_rows(fields: List[ServerSideField],
66
+ model: Any,
67
+ limit: Union[int, None] = None,
68
+ offset: Union[int, None] = None,
69
+ filter_str: str = '',
70
+ search_str: str = '',
71
+ search_cols: Optional[List] = None,
72
+ fields_order: Optional[Callable] = None
73
+ ) -> pd.DataFrame:
74
+ """
75
+ Select relevant row lines from model db. Select the whole db if no limit or offset is provided.
76
+ Convert the rows to a dataframe in order to show the result in a dash data_table.
77
+
78
+ NB:
79
+ * 'fields_order' specify how to order the result rows
80
+ * 'limit' and 'offset' correspond to the pagination of the results.
81
+ * 'search_str' corresponds to the search string from the search input.
82
+ """
83
+ with Session(engine) as session:
84
+ rows = _paginate_db_lines(fields, model, session, limit, offset, filter_str,
85
+ search_str, search_cols, fields_order)
86
+ if len(raw_df := pd.DataFrame.from_records([row.dict() for row in rows])) > 0:
87
+ return raw_df.rename(columns={field.field_name: field.col_name for field in fields}
88
+ )[[field.col_name for field in fields]]
89
+ return pd.DataFrame(columns=[field.col_name for field in fields])
90
+
91
+
92
+ def _paginate_db_lines(fields: List[ServerSideField],
93
+ model: Any,
94
+ session: Session,
95
+ limit: Union[int, None],
96
+ offset: Union[int, None],
97
+ filter_str: str,
98
+ search_str: str = '',
99
+ search_cols: Optional[List] = None,
100
+ fields_order: Optional[Callable] = None,
101
+ ) -> List:
102
+ """
103
+ Select relevant row lines from model db. Select the whole db if no limit or offset is provided.
104
+ """
105
+ if fields_order is None:
106
+ fields_order = _get_default_field_order(fields)
107
+
108
+ query = fields_order(_get_full_query(fields, model, filter_str, session, count=False,
109
+ search_str=search_str, search_cols=search_cols))
110
+ if limit is not None and offset is not None:
111
+ return list(session.exec(query.offset(offset * limit).limit(limit)))
112
+ return list(session.exec(query).all())
113
+
114
+
115
+ def _get_full_query(fields: List[ServerSideField],
116
+ model: Any,
117
+ filter_str: str,
118
+ session: Session,
119
+ count: bool = False,
120
+ search_str: str = '',
121
+ search_cols: Optional[List] = None
122
+ ) -> SelectOfScalar:
123
+ """
124
+ Forge a complete select query given both search and filter strings
125
+
126
+ NB:
127
+ * This relies on the passed statically defined field_filters corresponding to the model.
128
+ * The field_filters are used jointly with the dynamically set frontend filters.
129
+
130
+ """
131
+ filter_query = _get_filter_query(fields, model, _get_frontend_filters(filter_str), session,
132
+ count)
133
+
134
+ if not search_str or not search_cols:
135
+ return filter_query
136
+
137
+ return filter_query.where(or_(col(field).ilike(f'%{search_str.strip()}%')
138
+ for field in search_cols))
139
+
140
+
141
+ def _get_frontend_filters(raw_filters: str) -> Dict[str, Tuple[str, str]]:
142
+ """
143
+ Forge a dictionary of field keys, (operator, value) values in order to filter a db model.
144
+ """
145
+ split_filters = raw_filters.split(' && ')
146
+ return {elt[elt.find('{') + 1: elt.rfind('}')]: _forge_filter(elt) for elt in split_filters}
147
+
148
+
149
+ def _forge_filter(elt: str) -> Tuple[str, str]:
150
+ """
151
+ Forge the operator and value associated to the passed element. Do so by scanning the ordered
152
+ sequence of OPERATORS and returning the first matching (value is on the right of it).
153
+ """
154
+ return next(((key, elt.split(key)[-1]) for key in OPERATORS if key in elt), ('', ''))
155
+
156
+
157
+ def _get_filter_query(fields: List[ServerSideField],
158
+ model: Any,
159
+ frontend_filters: Dict[str, Tuple[str, str]],
160
+ session: Session,
161
+ count: bool = False
162
+ ) -> SelectOfScalar:
163
+ """
164
+ Filter a model given backend static field_filters called with dynamically set frontend_filters.
165
+
166
+ Returns:
167
+ * either the query fetching the filtered rows (count = False)
168
+ * or the filter row count.
169
+ """
170
+ query = session.query(model) if count else select(model)
171
+ if not frontend_filters or not all(frontend_filters.keys()):
172
+ return query
173
+
174
+ for key, (operator, value) in frontend_filters.items():
175
+ if field := first_or_default(fields, lambda x: x.col_name == key):
176
+ query = SERVER_SIDE_FILTERS[field.filter](query=query, operator=operator,
177
+ value=value, field=field.field)
178
+
179
+ return query
180
+
181
+
182
+ def _get_default_field_order(fields: List[ServerSideField]) -> Callable:
183
+ """
184
+ Recover default field order from list of fields
185
+ """
186
+ def fields_order(query):
187
+ """
188
+ Default field ordering
189
+
190
+ Take the initial query as input and specify the order to use.
191
+ """
192
+ return query.order_by(*[field.field for field in fields])
193
+
194
+ return fields_order
@@ -0,0 +1,21 @@
1
+ """
2
+ Module implementing helper methods working on lists
3
+ """
4
+ from enum import Enum
5
+ from typing import Type
6
+ from typing import Union
7
+
8
+ from ecodev_core.safe_utils import stringify
9
+
10
+
11
+ def enum_converter(field: Union[str, float],
12
+ enum_type: Type,
13
+ default: Union[Enum, None] = None
14
+ ) -> Union[Enum, None]:
15
+ """
16
+ Convert possibly None field to an enum_type if possible, return default otherwise
17
+ """
18
+ try:
19
+ return enum_type(stringify(field))
20
+ except ValueError:
21
+ return default
@@ -0,0 +1,65 @@
1
+ """
2
+ Module implementing helper methods working on lists
3
+ """
4
+ from collections import defaultdict
5
+ from typing import Any
6
+ from typing import Callable
7
+ from typing import Dict
8
+ from typing import List
9
+ from typing import Optional
10
+ from typing import Union
11
+
12
+
13
+ def group_by_value(list_to_group: List[Any]) -> Dict[Any, List[int]]:
14
+ """
15
+ Given a list, group together all equal values by storing them in a dictionary.
16
+ The keys are the unique list values (think about overriding the class equals if you pass
17
+ to this method your custom classes) and the values are list of ints, corresponding to the
18
+ position of the current key in the original list.
19
+
20
+ See https://towardsdatascience.com/explaining-the-settingwithcopywarning-in-pandas-ebc19d799d25
21
+ for why not to use df['base_year'][values] for instance
22
+ """
23
+
24
+ indices: Dict[Any, List[int]] = defaultdict(list)
25
+ for i in range(len(list_to_group)):
26
+ indices[list_to_group[i]].append(i)
27
+ return indices
28
+
29
+
30
+ def first_or_default(sequence: Union[List[Any], None],
31
+ condition: Union[Callable, None] = None,
32
+ default: Optional[Any] = None
33
+ ) -> Union[Any, None]:
34
+ """
35
+ Returns the first element of a sequence, or default value if the sequence contains no elements.
36
+ """
37
+ if not sequence:
38
+ return default
39
+
40
+ if condition is None:
41
+ return next(iter(sequence), default)
42
+ return next((elt for elt in sequence if condition(elt)), default)
43
+
44
+
45
+ def lselect(sequence: List[Any], condition: Union[Callable, None] = None) -> List[Any]:
46
+ """
47
+ Filter the passed sequence according to the passed condition
48
+ """
49
+ return list(filter(condition, sequence))
50
+
51
+
52
+ def lselectfirst(sequence: List[Any], condition: Union[Callable, None] = None) -> Union[Any, None]:
53
+ """
54
+ Select the filtered element of the passed sequence according to the passed condition
55
+ """
56
+
57
+ return filtered_list[0] if (filtered_list := list(filter(condition, sequence))) else None
58
+
59
+
60
+ def first_transformed_or_default(sequence: List[Any], transformation: Callable) -> Union[Any, None]:
61
+ """
62
+ Returns the first non-trivial transformed element of a sequence,
63
+ or default value if no non-trivial transformed elements are found.
64
+ """
65
+ return next((fx for elt in sequence if (fx := transformation(elt)) is not None), None)
ecodev_core/logger.py ADDED
@@ -0,0 +1,106 @@
1
+ """
2
+ Helpers for pretty logging
3
+ """
4
+ import logging
5
+ import sys
6
+ import traceback
7
+
8
+
9
+ def log_critical(message: str, logger):
10
+ """
11
+ Traceback enabled for unintended serious errors
12
+ """
13
+ logger.error(message)
14
+ logger.error(traceback.format_exc())
15
+
16
+
17
+ def logger_get(name, level=logging.DEBUG):
18
+ """
19
+ Main method called by all other modules to log
20
+ """
21
+ logger = logging.getLogger(name)
22
+ config_log(logger, level, MyFormatter())
23
+ return logger
24
+
25
+
26
+ class MyFormatter(logging.Formatter):
27
+ """
28
+ Formatter to print %(filename)s:%(funcName)s:%(lineno)d on 24 characters
29
+
30
+ Typical format :
31
+ 2016-10-26 14:20:21,379 | DEBUG | logger:log_me:57 : This is a log
32
+ """
33
+ message_width = 110
34
+ cpath_width = 32
35
+ date_fmt = '%Y-%m-%d %H:%M:%S'
36
+
37
+ pink = '\x1b[35m'
38
+ green = '\x1b[32m'
39
+ yellow = '\x1b[33m'
40
+ red = '\x1b[31m'
41
+ bold_red = '\x1b[31;1m'
42
+ reset = '\x1b[0m'
43
+
44
+ FORMATS = {
45
+ logging.DEBUG: pink,
46
+ logging.INFO: green,
47
+ logging.WARNING: yellow,
48
+ logging.ERROR: red,
49
+ logging.CRITICAL: bold_red,
50
+ }
51
+
52
+ def format(self, record):
53
+ """
54
+ Format logs
55
+ """
56
+ initial_record = f'{record.module}:{record.funcName}:{ record.lineno}'
57
+ cpath = initial_record[-self.cpath_width:].ljust(self.cpath_width)
58
+ time = self.formatTime(record, self.date_fmt)
59
+ prefix = f'{time} | {record.levelname} | {record.process} | {cpath}'
60
+
61
+ # fixing max length
62
+ limited_lines = []
63
+ for line in record.getMessage().split(str('\n')):
64
+ while len(line) > self.message_width:
65
+ if (last_space_position := line[:self.message_width - 1].rfind(' ')) > 0:
66
+ splitting_position = last_space_position
67
+ else:
68
+ splitting_position = self.message_width
69
+ limited_lines.append(line[:splitting_position])
70
+ line = line[splitting_position:]
71
+
72
+ # don't forget end of line
73
+ limited_lines.append(line)
74
+
75
+ # formatting final message
76
+ final_message = ''.join(f'{prefix} | {line}\n' for line in limited_lines).rstrip()
77
+
78
+ return f'{self.FORMATS[record.levelno]}{final_message}{self.reset}'
79
+
80
+
81
+ def config_log(logger, level, formatter):
82
+ """ Configures the logging.
83
+
84
+ This function defines the root logger. It needs to be called only once.
85
+ Then, all modules should log like this:
86
+ '''
87
+ from logger.logger import get as logger_get
88
+ log = logger_get(__name__)
89
+ '''
90
+ If the function is called more than once, duplicate handlers are ignored
91
+ to avoid duplicate logging.
92
+
93
+ Args:
94
+ logger: logging object
95
+ level: Logging level
96
+ formatter: Logging format
97
+
98
+ """
99
+ # Get the root logger (because no name is specified in getLogger())
100
+ logger.setLevel(level)
101
+ logger.propagate = False
102
+
103
+ console_handler = logging.StreamHandler(stream=sys.stdout)
104
+ if all(handler.stream.name != console_handler.stream.name for handler in logger.handlers):
105
+ console_handler.setFormatter(formatter)
106
+ logger.addHandler(console_handler)
@@ -0,0 +1,30 @@
1
+ """
2
+ Module implementing some utilitary methods on pandas types
3
+ """
4
+ import tempfile
5
+ from pathlib import Path
6
+ from typing import Dict
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+
11
+
12
+ def pd_equals(prediction: pd.DataFrame, gt_path=Path):
13
+ """
14
+ Since some Nones are serialized as Nans by pandas (heavy type inference),
15
+ we store the prediction at a temporary location in order to reload it on the fly and compare it
16
+ to a pre-store ground truth, in order that both gt and prediction benefited from the same
17
+ type inferences.
18
+ """
19
+ with tempfile.TemporaryDirectory() as folder:
20
+ prediction.to_csv(Path(folder) / 'tmp.csv', index=False)
21
+ reloaded_prediction = pd.read_csv(Path(folder) / 'tmp.csv')
22
+ pd.testing.assert_frame_equal(reloaded_prediction, pd.read_csv(gt_path))
23
+
24
+
25
+ def jsonify_series(row: pd.Series) -> Dict:
26
+ """
27
+ Convert a serie into a json compliant dictionary (replacing np.nans by Nones)
28
+ """
29
+ return {key: None if isinstance(value, float) and np.isnan(value) else value for key, value in
30
+ row.to_dict().items()}
@@ -0,0 +1,15 @@
1
+ """
2
+ Module implementing all permission levels an application user can have
3
+ """
4
+ from enum import Enum
5
+ from enum import unique
6
+
7
+
8
+ @unique
9
+ class Permission(str, Enum):
10
+ """
11
+ Enum listing all permission levels an application user can have
12
+ """
13
+ ADMIN = 'Admin'
14
+ Consultant = 'Consultant'
15
+ Client = 'Client'
@@ -0,0 +1,52 @@
1
+ """
2
+ Simple Pydantic wrapper classes around BaseModel to accommodate for orm and frozen cases
3
+ """
4
+ from pydantic import BaseModel
5
+
6
+
7
+ class Basic(BaseModel):
8
+ """
9
+ Basic pydantic configuration
10
+ """
11
+
12
+ class Config:
13
+ """
14
+ Allow mutation in inheriting classes
15
+ """
16
+ allow_mutation = True
17
+ arbitrary_types_allowed = True
18
+
19
+
20
+ class Frozen(BaseModel):
21
+ """
22
+ Frozen pydantic configuration
23
+ """
24
+
25
+ class Config:
26
+ """
27
+ Forbid mutation in order to freeze the inheriting classes
28
+ """
29
+ allow_mutation = False
30
+
31
+
32
+ class CustomFrozen(Frozen):
33
+ """
34
+ Frozen pydantic configuration for custom types
35
+ """
36
+ class Config:
37
+ """
38
+ Allow arbitrary custom types
39
+ """
40
+ arbitrary_types_allowed = True
41
+
42
+
43
+ class OrmFrozen(CustomFrozen):
44
+ """
45
+ Frozen pydantic configuration for orm like object
46
+ """
47
+
48
+ class Config:
49
+ """
50
+ Allow to create object from orm one
51
+ """
52
+ orm_mode = True
@@ -0,0 +1,40 @@
1
+ """
2
+ Module regrouping low level reading and writing helper methods
3
+ """
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Dict
8
+ from typing import List
9
+ from typing import Union
10
+
11
+
12
+ def write_json_file(json_data: Union[Dict, List], file_path: Path):
13
+ """
14
+ Write json_data at file_path location
15
+ """
16
+ os.umask(0)
17
+ with open(file_path, 'w', encoding='utf-8') as f:
18
+ f.write(json.dumps(json_data, indent=4))
19
+
20
+
21
+ def load_json_file(file_path: Path):
22
+ """
23
+ Load a json file at file_path location
24
+ """
25
+ with open(file_path, 'r', encoding='utf-8') as f:
26
+ loaded_json = json.load(f)
27
+
28
+ return loaded_json
29
+
30
+
31
+ def make_dir(directory: Path):
32
+ """
33
+ Helper that create the directory "directory" if it doesn't exist yet
34
+ """
35
+ try:
36
+ os.umask(0)
37
+ os.makedirs(directory)
38
+ except OSError as error:
39
+ if not directory.is_dir():
40
+ raise OSError(f'directory={directory!r} should exist but does not.: {error}') from error