bdrc-db-lib2 2.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.
BdrcDbLib/DBConfig.py ADDED
@@ -0,0 +1,80 @@
1
+ """
2
+ Created on Mar 7, 2018
3
+
4
+ @author: jsk
5
+ """
6
+ import configparser
7
+ import os
8
+ from pathlib import Path
9
+
10
+ class DBConfig:
11
+ """
12
+ :summary: Prepares database connection options from a single config file and environment variables.
13
+ The config file path is obtained from the BDRC_DB_CNF environment variable with no fallback.
14
+ Passwords are NEVER extracted from the config file and must be provided via environment variables.
15
+ """
16
+
17
+ def __init__(self, dbName, configFileName=None):
18
+ """
19
+ :param dbName: section in the config file. Saved in db_alias
20
+ :param configFileName: Ignored. Path is taken from BDRC_DB_CNF environment variable.
21
+ """
22
+ self.db_alias = dbName
23
+ cnf_path = os.getenv('BDRC_DB_CNF')
24
+ if not cnf_path:
25
+ raise OSError("Environment variable BDRC_DB_CNF is not set. It must point to the database configuration file.")
26
+
27
+ self._configFQPath = os.path.expanduser(cnf_path)
28
+ self._configParser = configparser.ConfigParser()
29
+ if not self._configParser.read(self._configFQPath):
30
+ raise FileNotFoundError(f"Database configuration file not found at {self._configFQPath}")
31
+
32
+ @property
33
+ def db_alias(self):
34
+ return self._serverSection
35
+
36
+ @db_alias.setter
37
+ def db_alias(self, value):
38
+ self._serverSection = value
39
+
40
+ @property
41
+ def db_host(self):
42
+ """Returns the section name in the config file (for compatibility)."""
43
+ return self.db_alias
44
+
45
+ @property
46
+ def db_cnf(self):
47
+ """Returns the path to the config file."""
48
+ return self._configFQPath
49
+
50
+ def get_value(self, section: str, key: str):
51
+ """
52
+ Gets a value from the config, with environment variable overrides.
53
+ 'section' is ignored in favor of the instance's db_alias.
54
+ """
55
+ key = key.lower()
56
+
57
+ if key == 'password':
58
+ # Always from environment
59
+ val = os.getenv(f"BDRC_{self.db_alias.upper()}_PASSWORD")
60
+ if not val:
61
+ val = os.getenv("BDRC_DB_PASSWORD")
62
+ if not val:
63
+ raise ValueError(f"Password for section [{self.db_alias}] not found in environment (checked BDRC_{self.db_alias.upper()}_PASSWORD and BDRC_DB_PASSWORD)")
64
+ return val
65
+
66
+ if not self._configParser.has_section(self.db_alias):
67
+ raise ValueError(f"Section [{self.db_alias}] not found in config file {self._configFQPath}")
68
+
69
+ if self._configParser.has_option(self.db_alias, key):
70
+ return self._configParser.get(self.db_alias, key)
71
+
72
+ # Default for port, overridden by file value
73
+ if key == 'port':
74
+ return '3306'
75
+
76
+ raise ValueError(f"Key '{key}' not found in section [{self.db_alias}] and no environment override found.")
77
+
78
+ @property
79
+ def config_file_name(self):
80
+ return self._configFQPath
BdrcDbLib/DbApp.py ADDED
@@ -0,0 +1,262 @@
1
+ """
2
+ Created 2018-VIII-24
3
+ @author: jimk
4
+ """
5
+ import logging
6
+ import os
7
+ import random
8
+ import sys
9
+ import time
10
+
11
+ import pymysql
12
+ import pymysql as mysql
13
+
14
+ from BdrcDbLib.DBConfig import DBConfig
15
+ from BdrcDbLib.SprocColumnError import SprocColumnError
16
+
17
+
18
+ # create
19
+ def retry(max_retries=3, sleep_range=(7, 15)):
20
+ """
21
+ a python decorator that calls a function and retries
22
+ it a set number of times, sleeping a random number of seconds
23
+ between 7 and 15 seconds, until a retry limit is reached or the
24
+ operation succeeds. On success, return the argument's results'
25
+ :param max_retries: number of retries
26
+ :param sleep_range: range of seconds to sleep between retries
27
+ :return: the function's return value
28
+ """
29
+
30
+ def decorator(func):
31
+ def wrapper(*args, **kwargs):
32
+ log:logging = logging.getLogger(__name__)
33
+ for i in range(max_retries):
34
+ try:
35
+ log.info(f"Attempt {i + 1} of {max_retries} for {func.__name__}")
36
+ return func(*args, **kwargs)
37
+ except Exception as e:
38
+ log.error(f"Attempt {i + 1} of {max_retries} failed: {e}")
39
+ if i < max_retries - 1:
40
+ time.sleep(random.randint(*sleep_range))
41
+ else:
42
+ raise e
43
+ return wrapper
44
+ return decorator
45
+
46
+
47
+ class DbApp:
48
+ """
49
+ Base class for database applications
50
+ """
51
+ _invoked_object: str
52
+ _dbConfig: DBConfig
53
+ _cn: mysql.Connection
54
+ _dbConnection: mysql.Connection
55
+ _expectedColumns: list = None
56
+ _log: logging
57
+
58
+ def __init__(self, db_config: str):
59
+ self.dbConfig = db_config
60
+ self.connection = None
61
+ self.ExpectedColumns = []
62
+ self._log = logging.getLogger(__name__)
63
+ logging.basicConfig(datefmt='[ %Y-%m-%d %X ]', format='%(asctime)s%(levelname)s:%(message)s',
64
+ level=logging.INFO)
65
+
66
+ def start_connect(self) -> None:
67
+ """
68
+ Opens a database connection using the DBConfig
69
+ :return: Nothing. Sets class connection property
70
+ """
71
+ user = self.dbConfig.get_value(None, "user")
72
+ password = self.dbConfig.get_value(None, "password")
73
+ host = self.dbConfig.get_value(None, "host")
74
+ port = self.dbConfig.get_value(None, "port")
75
+ database = self.dbConfig.get_value(None, "database")
76
+
77
+ self.connection = mysql.connect(
78
+ user=user,
79
+ password=password,
80
+ host=host,
81
+ port=int(port),
82
+ database=database,
83
+ charset='utf8'
84
+ )
85
+
86
+ @property
87
+ def ExpectedColumns(self) -> list:
88
+ """
89
+ If a subclass returns a result set from a query, you may only want some of the query columns
90
+ If this list is empty, all the data set columns are returned
91
+ :return:
92
+ """
93
+ return self._expectedColumns
94
+
95
+ @ExpectedColumns.setter
96
+ def ExpectedColumns(self, value: list):
97
+ assert isinstance(value, list)
98
+ self._expectedColumns = value
99
+
100
+ @property
101
+ def dbConfig(self) -> DBConfig:
102
+ return self._dbConfig
103
+
104
+ @dbConfig.setter
105
+ def dbConfig(self, drs_db_config: str):
106
+ """
107
+ gets dbConfig values for setup
108
+ :param drs_db_config: the database alias (optionally alias:ignored_file)
109
+ :return:
110
+ """
111
+ if drs_db_config is None:
112
+ # noinspection PyTypeChecker
113
+ self._dbConfig = None
114
+ return
115
+
116
+ db_name = drs_db_config.split(':')[0]
117
+ self._dbConfig = DBConfig(db_name)
118
+
119
+ @property
120
+ def connection(self) -> mysql.Connection:
121
+ return self._cn
122
+
123
+ @connection.setter
124
+ def connection(self, value):
125
+ self._cn = value
126
+
127
+ def validateExpectedColumns(self, cursor_description: list) -> None:
128
+ """
129
+ Validates the cursor after a call to the database. Checks for
130
+ the required columns (from member ExpectedColumns) in the output
131
+
132
+ :param cursor_description: tuple of tuples
133
+ :return: Throws ValueError on fail
134
+ """
135
+ found = False
136
+ found_columns: list = []
137
+
138
+ # Data expected but not returned
139
+ if cursor_description is None and len(self.ExpectedColumns) > 0:
140
+ raise SprocColumnError(f'Invoked object {self._invoked_object} returned no expected data.')
141
+
142
+ for expected_column in self.ExpectedColumns:
143
+ found = False
144
+ for cursor_tuple in cursor_description:
145
+ if cursor_tuple[0] == expected_column:
146
+ found = True
147
+ found_columns.append(cursor_tuple[0])
148
+ break
149
+ # each expected column must be in the list
150
+ if not found:
151
+ break
152
+ if not found:
153
+ raise SprocColumnError(
154
+ f'Invoked object {self._invoked_object} Expected to return columns {self.ExpectedColumns}. Only '
155
+ f'returned {found_columns}.')
156
+
157
+ # desc = queryCursor.description
158
+ # hope something's here
159
+
160
+ def GetSprocResults(self, sproc: str, max_works: int = 200) -> list:
161
+ """
162
+ call a sproc using the internal connection,
163
+ validate the result columns with the internal member.
164
+
165
+ :rtype: list of dictionary objects of results. Caller decodes format
166
+ :param sproc: routine to call
167
+ :param max_works: limit of return rows
168
+ :returns: a list of dictionary items, each item is a return row
169
+ """
170
+
171
+ self._invoked_object = sproc
172
+ self.start_connect()
173
+
174
+ rl: list[dict] = []
175
+
176
+ has_next: bool = True
177
+ with self.connection:
178
+ try:
179
+ # jimk #drs-deposit 76. Dont use unbuffered cursor, which blocks access to the db
180
+ # until it's done or cleared. (e.g. pyCharm queries
181
+ work_cursor: mysql.Connection.Cursor = self.connection.cursor(mysql.cursors.DictCursor)
182
+ print(f'Calling {sproc} for n = {max_works} ')
183
+ work_cursor.callproc(f'{sproc}', (max_works,))
184
+ self.validateExpectedColumns(work_cursor.description)
185
+
186
+ while has_next:
187
+ result_rows = work_cursor.fetchall()
188
+ rl.append(result_rows)
189
+ has_next = work_cursor.nextset()
190
+ finally:
191
+ # have to drain result sets if there was an exception (if
192
+ while has_next:
193
+ work_cursor.fetchall()
194
+ has_next = work_cursor.nextset()
195
+ return rl
196
+
197
+ @retry(max_retries=3, sleep_range=(15, 40))
198
+ def CallAnySproc(self, sproc: str, *args) -> []:
199
+ """
200
+ Calls a routine without analyzing the result
201
+ :param sproc: routine name
202
+ :param out_arg_index: If there is an out arg, its index in the tuple of args
203
+ :param args: arguments
204
+ :return: true if there are any results, throws exception otherwise.
205
+ Caller handles
206
+ """
207
+ self.start_connect()
208
+
209
+ rl: [] = []
210
+
211
+ with self.connection:
212
+
213
+ work_cursor: mysql.Connection.Cursor = self.connection.cursor(mysql.cursors.DictCursor)
214
+
215
+ sql_args = tuple(arg for arg in args)
216
+ self._log.debug(f"Calling {sproc} args {':'.join(str(sql_arg) for sql_arg in sql_args)}")
217
+ try:
218
+ work_cursor.callproc(f'{sproc}', sql_args)
219
+ has_next: bool = True
220
+ while has_next:
221
+ result_rows = work_cursor.fetchall()
222
+ rl.append(result_rows)
223
+ has_next = work_cursor.nextset()
224
+ self.connection.commit()
225
+ except pymysql.Error as e:
226
+ self._log.error("Error calling", exc_info=sys.exc_info())
227
+ self.connection.rollback()
228
+ raise e
229
+ return rl
230
+
231
+ def ExecQuery(self, query: str, *args) -> []:
232
+ """
233
+ Calls a routine without analyzing the result
234
+ :param sproc: routine name
235
+ :param out_arg_index: If there is an out arg, its index in the tuple of args
236
+ :param args: arguments
237
+ :return: true if there are any results, throws exception otherwise.
238
+ Caller handles
239
+ """
240
+ self.start_connect()
241
+
242
+ rl: [] = []
243
+
244
+ with self.connection:
245
+
246
+ work_cursor: mysql.Connection.Cursor = self.connection.cursor(mysql.cursors.DictCursor)
247
+
248
+ sql_args = tuple(arg for arg in args)
249
+ self._log.debug(f"Calling query args {':'.join(str(sql_arg) for sql_arg in sql_args)}")
250
+ try:
251
+ work_cursor.execute(query, sql_args)
252
+ has_next: bool = True
253
+ while has_next:
254
+ result_rows = work_cursor.fetchall()
255
+ rl.append(result_rows)
256
+ has_next = work_cursor.nextset()
257
+ self.connection.commit()
258
+ except pymysql.Error as e:
259
+ self._log.error("Error invoking", exc_info=sys.exc_info())
260
+ self.connection.rollback()
261
+ raise e
262
+ return rl
@@ -0,0 +1,118 @@
1
+ """
2
+ Base class for all parsers for Db Applications
3
+ """
4
+ import argparse
5
+ import pathlib
6
+ import datetime
7
+ import os
8
+
9
+
10
+ class DbArgNamespace:
11
+ """
12
+ Empty arguments, holds output of arg parsing
13
+ """
14
+ pass
15
+
16
+
17
+ class DbAppParser:
18
+ """
19
+ Base class for database arguments. When a subclass calls
20
+ argparse.parseArguments, this class returns a structure containing
21
+ a member drsDbConfig: str
22
+ """
23
+
24
+ _default_config: str = 'prod'
25
+ _parser: argparse.ArgumentParser = None
26
+
27
+ _args: DbArgNamespace = None
28
+
29
+ def __init__(self, description: str, usage: str):
30
+ self._parser = argparse.ArgumentParser(description=description,
31
+ usage="%(prog)s | -d DBAppSection " + usage)
32
+ self._parser.add_argument('-d', '--drsDbConfig', help='specify database alias (section in config file)', required=False,
33
+ default=self._default_config)
34
+
35
+ @property
36
+ def parsedArgs(self) -> DbArgNamespace:
37
+ """
38
+ Readonly, calc once
39
+ parses the classes arguments, and returns the namespace
40
+ :return:
41
+ """
42
+ # Enforce once only
43
+ if self._args is None:
44
+ self._args = DbArgNamespace()
45
+ # noinspection PyTypeChecker
46
+ self._parser.parse_args(namespace=self._args)
47
+ return self._args
48
+
49
+
50
+ # section parser validations and utilities
51
+
52
+
53
+ def str2date(arg: str) -> datetime.datetime:
54
+ """
55
+ parses date given in yyyy-mm-dd
56
+ """
57
+ return datetime.datetime.strptime(arg, "%Y-%m-%d")
58
+
59
+
60
+ def str2datetime(arg: str) -> datetime.datetime:
61
+ """
62
+ parses date given as in bash date +"%Y-%m-%d %R:%S",
63
+ or 2021-05-24 5:3.22
64
+ """
65
+ return datetime.datetime.strptime(arg, "%Y-%m-%d %H:%M:%S")
66
+
67
+
68
+ def X_writableExpandoFile(path: str):
69
+ """
70
+ argparse type for a file in a writable directory
71
+ :param path:
72
+ :return:
73
+ """
74
+
75
+ os_path = os.path.expanduser(path)
76
+ p = pathlib.Path(os_path)
77
+ if os.path.isdir(os_path):
78
+ raise argparse.ArgumentTypeError(f"{os_path} is a directory. A file name is required.")
79
+
80
+ # Is the parent writable?
81
+ p_dir = p.parent
82
+ if not os.access(str(p_dir), os.W_OK):
83
+ raise argparse.ArgumentTypeError(f"{os_path} is in a readonly directory ")
84
+
85
+ return path
86
+
87
+
88
+ # We don't need existing directories and files any
89
+ def X_mustExistDirectory(path: str):
90
+ """
91
+ Argparse type specifying a string which represents
92
+ an existing file path
93
+ :param path:
94
+ :return:
95
+ """
96
+ if not os.path.isdir(path):
97
+ raise argparse.ArgumentTypeError(f"{path} not found")
98
+ for root, dirs, files in os.walk(path, True):
99
+ if len(dirs) == 0:
100
+ raise argparse.ArgumentTypeError
101
+ else:
102
+ return path
103
+
104
+
105
+ def X_mustExistFile(path: str):
106
+ """
107
+ Common utility. Returns
108
+ :param path:
109
+ :return:
110
+ """
111
+ full_path = os.path.expanduser(path)
112
+ if not os.path.exists(full_path):
113
+ raise argparse.ArgumentTypeError(f"{full_path} not found")
114
+ else:
115
+ return full_path
116
+
117
+
118
+ # end section parser validations and utilities