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 +80 -0
- BdrcDbLib/DbApp.py +262 -0
- BdrcDbLib/DbAppParser.py +118 -0
- BdrcDbLib/DbOrm/DrsContextBase.py +251 -0
- BdrcDbLib/DbOrm/ProjectManagerUtils.py +54 -0
- BdrcDbLib/DbOrm/__init__.py +0 -0
- BdrcDbLib/SprocColumnError.py +4 -0
- BdrcDbLib/SqlAlchemy_get_or_create.py +63 -0
- BdrcDbLib/__init__.py +0 -0
- BdrcDbModels/__init__.py +3 -0
- BdrcDbModels/base.py +25 -0
- BdrcDbModels/drs.py +148 -0
- BdrcDbModels/models.py +248 -0
- BdrcDbModels/project_manager.py +88 -0
- Tests/DBConfigTest.py +40 -0
- Tests/__init__.py +2 -0
- Tests/test_drs.py +89 -0
- bdrc_db_lib2-2.0.1.dist-info/METADATA +165 -0
- bdrc_db_lib2-2.0.1.dist-info/RECORD +21 -0
- bdrc_db_lib2-2.0.1.dist-info/WHEEL +5 -0
- bdrc_db_lib2-2.0.1.dist-info/top_level.txt +3 -0
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
|
BdrcDbLib/DbAppParser.py
ADDED
|
@@ -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
|