safeshield 1.0.0__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 safeshield might be problematic. Click here for more details.
- safeshield-1.0.0.dist-info/LICENSE +21 -0
- safeshield-1.0.0.dist-info/METADATA +32 -0
- safeshield-1.0.0.dist-info/RECORD +29 -0
- safeshield-1.0.0.dist-info/WHEEL +5 -0
- safeshield-1.0.0.dist-info/top_level.txt +1 -0
- validator/__init__.py +7 -0
- validator/core/__init__.py +3 -0
- validator/core/validator.py +299 -0
- validator/database/__init__.py +5 -0
- validator/database/detector.py +250 -0
- validator/database/manager.py +162 -0
- validator/exceptions.py +10 -0
- validator/factory.py +26 -0
- validator/rules/__init__.py +27 -0
- validator/rules/array.py +77 -0
- validator/rules/base.py +84 -0
- validator/rules/basic.py +41 -0
- validator/rules/comparison.py +240 -0
- validator/rules/conditional.py +332 -0
- validator/rules/date.py +154 -0
- validator/rules/files.py +167 -0
- validator/rules/format.py +105 -0
- validator/rules/string.py +74 -0
- validator/rules/type.py +42 -0
- validator/rules/utilities.py +213 -0
- validator/services/__init__.py +5 -0
- validator/services/rule_conflict.py +133 -0
- validator/services/rule_error_handler.py +42 -0
- validator/services/rule_preparer.py +120 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Wunsun Tarniho
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: safeshield
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Library for Help Validation Control
|
|
5
|
+
Home-page: https://github.com/WunsunTarniho/py-guard
|
|
6
|
+
Author: Wunsun Tarniho
|
|
7
|
+
Author-email: wunsun58@gmail.com
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/WunsunTarniho/py-guard/issues
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
15
|
+
Classifier: Topic :: Security
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: Django==5.2
|
|
20
|
+
Requires-Dist: Flask==3.1.1
|
|
21
|
+
Requires-Dist: mysql-connector-repackaged==0.3.1
|
|
22
|
+
Requires-Dist: odoo==1.0
|
|
23
|
+
Requires-Dist: Pillow==11.2.1
|
|
24
|
+
Requires-Dist: psycopg2==2.9.10
|
|
25
|
+
Requires-Dist: python-dotenv==1.1.1
|
|
26
|
+
Requires-Dist: python-dateutil==2.9.0.post0
|
|
27
|
+
Requires-Dist: setuptools==63.2.0
|
|
28
|
+
Requires-Dist: SQLAlchemy==2.0.41
|
|
29
|
+
Requires-Dist: Werkzeug==3.1.3
|
|
30
|
+
|
|
31
|
+
## License
|
|
32
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
validator/__init__.py,sha256=udxDzUicPfxBOAQvzsnl3pHur9VUppKbWMgg35hpiww,244
|
|
2
|
+
validator/exceptions.py,sha256=y2v7CaXmeGFHWcnigtLl4U-sFta_jMiXkGKXWIIVglY,366
|
|
3
|
+
validator/factory.py,sha256=9I_dIq22QxZxCqtCECp1T5K0GeyD_YuGc8xtw4eexMs,794
|
|
4
|
+
validator/core/__init__.py,sha256=ZcqlXJSk03i_CVzmIN-nVe1UOyvwwO5jhbEj7f62Y_o,59
|
|
5
|
+
validator/core/validator.py,sha256=00qVnbH-EJC5KALlaoUBLAfsszAFLcoSxfRbmy0amyk,12751
|
|
6
|
+
validator/database/__init__.py,sha256=vpGhoYt32oiYyJVsoWZkRKBgDZp8bTKeW2jXKVoNKA8,135
|
|
7
|
+
validator/database/detector.py,sha256=mPNZlOgwNBPNwL_XcKSmT6H5Bq370nMkqqMMOqkM9WY,9568
|
|
8
|
+
validator/database/manager.py,sha256=Ezz4NUh22Hz2puh-NJggSGCaw3lAGyp3V88plMeBBVU,6232
|
|
9
|
+
validator/rules/__init__.py,sha256=y1HsA25CIZoNmTDJp9CHspdybsNtrWEiX8w_NNANuGw,963
|
|
10
|
+
validator/rules/array.py,sha256=tx8FCDqn-27Vs7tgtjeoCE9ceDMVrdBEb2-pq-lNLuo,2809
|
|
11
|
+
validator/rules/base.py,sha256=UeUXJlNkBGxKhgNvcamJqoZKTh7HO5v-t0eAAks_MKM,2894
|
|
12
|
+
validator/rules/basic.py,sha256=fMk0s5_IEQu27X1wndP_ocNyOZ1upXJCn7fR9YYbI74,1527
|
|
13
|
+
validator/rules/comparison.py,sha256=9xb2mO5GiThR1iO8867ex2o7olQC2Bnew6MBdD2UtEo,9402
|
|
14
|
+
validator/rules/conditional.py,sha256=8_O1etzCyCGeD8lmfhegJ1uzhkBSjTEVdufbZmkqgP4,12993
|
|
15
|
+
validator/rules/date.py,sha256=18JIKTO5nzFtCnJEMXm4OUbneqTMB7HGPN6jUkxGU4Y,5278
|
|
16
|
+
validator/rules/files.py,sha256=vu_TZFffDPzDojyTsFXSQ6MmQm3WU6ppFfz7-wuDy98,6550
|
|
17
|
+
validator/rules/format.py,sha256=UeeIAkFn0CdzC5bS9tI78_lbPYRgcpaUujxdmyCSdzE,3744
|
|
18
|
+
validator/rules/string.py,sha256=vYzu4ICKY9FCuGahmsQCoJLmnlBF7uNvVazFj9DQ438,3178
|
|
19
|
+
validator/rules/type.py,sha256=Tu-EOBkTtxkcCe0ANXavurZC449n63iE_VXVzc3BIiM,1596
|
|
20
|
+
validator/rules/utilities.py,sha256=AIm9JRGYf6cTCSkivg3gTj3U5DnXlCAJ5ej1yUSa1dU,9724
|
|
21
|
+
validator/services/__init__.py,sha256=zzKTmqL7v4niFGWHJBfWLqgJ0iTaW_69OzYZN8uInzQ,210
|
|
22
|
+
validator/services/rule_conflict.py,sha256=s1RJNUY5d0WtSMHkrKulBCgJ2BZL2GE0Eu5pdAoiIbM,4943
|
|
23
|
+
validator/services/rule_error_handler.py,sha256=MGvvkP6hbZLpVXxC3xpzg15OmVdPlk7l0M2Srmy5VfM,1729
|
|
24
|
+
validator/services/rule_preparer.py,sha256=jRcMNjqq2xyZjO64Pim8jWmja5DmTzf0V_uuHG0lJTg,5621
|
|
25
|
+
safeshield-1.0.0.dist-info/LICENSE,sha256=qugtRyKckyaks6hd2xyxOFSOYM6au1N80pMXuMTPvC4,1090
|
|
26
|
+
safeshield-1.0.0.dist-info/METADATA,sha256=UNfuutZuSxKkc4MTaVOIbVynevAieQqkm88GDyEIT2I,1223
|
|
27
|
+
safeshield-1.0.0.dist-info/WHEEL,sha256=tZoeGjtWxWRfdplE7E3d45VPlLNQnvbKiYnx7gwAy8A,92
|
|
28
|
+
safeshield-1.0.0.dist-info/top_level.txt,sha256=iUtV3dlHOIiMfLuY4pruY00lFni8JzOkQ3Nh1II19OE,10
|
|
29
|
+
safeshield-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
validator
|
validator/__init__.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
from typing import Optional, Dict, Any, List, Tuple, Union
|
|
2
|
+
from validator.exceptions import ValidationException, RuleNotFoundException
|
|
3
|
+
from validator.factory import RuleFactory
|
|
4
|
+
from validator.database import DatabaseManager, DatabaseAutoDetector
|
|
5
|
+
from validator.rules import ValidationRule
|
|
6
|
+
from validator.services.rule_conflict import RuleConflictChecker
|
|
7
|
+
from validator.services.rule_error_handler import RuleErrorHandler
|
|
8
|
+
from validator.services.rule_preparer import RulePreparer
|
|
9
|
+
import warnings
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
|
|
12
|
+
class Validator:
|
|
13
|
+
"""Main validation class with proper abstractions"""
|
|
14
|
+
PRIORITY_RULES = {
|
|
15
|
+
'bail', 'exclude_unless', 'exclude_if', 'exclude_with', 'exclude_without', 'required', 'required_if', 'required_unless',
|
|
16
|
+
'required_with', 'required_without'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
data: Optional[Dict[str, Any]] = None,
|
|
22
|
+
rules: Optional[Dict[str, Union[
|
|
23
|
+
str,
|
|
24
|
+
List[Union[
|
|
25
|
+
str,
|
|
26
|
+
ValidationRule,
|
|
27
|
+
Tuple[str, Union[
|
|
28
|
+
str,
|
|
29
|
+
Union[
|
|
30
|
+
Tuple[str],
|
|
31
|
+
List[str]
|
|
32
|
+
],
|
|
33
|
+
Union[
|
|
34
|
+
Tuple[str],
|
|
35
|
+
List[str]
|
|
36
|
+
]
|
|
37
|
+
]]
|
|
38
|
+
]],
|
|
39
|
+
Tuple[Union[
|
|
40
|
+
str,
|
|
41
|
+
ValidationRule,
|
|
42
|
+
Tuple[str, Union[
|
|
43
|
+
str,
|
|
44
|
+
Union[
|
|
45
|
+
Tuple[str],
|
|
46
|
+
List[str]
|
|
47
|
+
],
|
|
48
|
+
Union[
|
|
49
|
+
Tuple[str],
|
|
50
|
+
List[str]
|
|
51
|
+
]
|
|
52
|
+
]]
|
|
53
|
+
]]
|
|
54
|
+
]]] = None,
|
|
55
|
+
messages: Optional[Dict[str, str]] = None,
|
|
56
|
+
custom_attributes: Optional[Dict[str, str]] = None,
|
|
57
|
+
db_config: Optional[Dict[str, Any]] = None,
|
|
58
|
+
# rule_preparer: Optional[RulePreparer] = None,
|
|
59
|
+
# error_handler: Optional[RuleErrorHandler] = None
|
|
60
|
+
):
|
|
61
|
+
self.data = data or {}
|
|
62
|
+
self._raw_rules = rules or {}
|
|
63
|
+
self._stop_on_first_failure = False
|
|
64
|
+
self._is_exclude = False
|
|
65
|
+
|
|
66
|
+
# Initialize dependencies
|
|
67
|
+
self.rule_preparer = RulePreparer(RuleFactory())
|
|
68
|
+
self.error_handler = RuleErrorHandler(messages, custom_attributes)
|
|
69
|
+
|
|
70
|
+
# Database configuration
|
|
71
|
+
self.db_config = db_config or DatabaseAutoDetector.detect()
|
|
72
|
+
if not self.db_config:
|
|
73
|
+
warnings.warn(
|
|
74
|
+
"No database config detected. exists/unique validations will be skipped!",
|
|
75
|
+
RuntimeWarning
|
|
76
|
+
)
|
|
77
|
+
self.db_manager = DatabaseManager(self.db_config) if self.db_config else None
|
|
78
|
+
|
|
79
|
+
def validate(self) -> bool:
|
|
80
|
+
"""Validate the data against the rules"""
|
|
81
|
+
prepared_rules = self.rule_preparer.prepare(self._raw_rules)
|
|
82
|
+
self.error_handler.errors.clear()
|
|
83
|
+
|
|
84
|
+
validated = self._validate_rules(prepared_rules, priority_only=True)
|
|
85
|
+
|
|
86
|
+
# First pass: priority rules
|
|
87
|
+
if self.error_handler.has_errors and self._stop_on_first_failure:
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
# Second pass: remaining rules
|
|
91
|
+
self._validate_rules(prepared_rules, priority_only=False)
|
|
92
|
+
|
|
93
|
+
if self.error_handler.has_errors:
|
|
94
|
+
return False
|
|
95
|
+
return True
|
|
96
|
+
|
|
97
|
+
def _validate_rules(self, prepared_rules: Dict[str, List[ValidationRule]], priority_only: bool):
|
|
98
|
+
validated = []
|
|
99
|
+
for field_pattern, rules in prepared_rules.items():
|
|
100
|
+
concrete_paths = self._resolve_wildcard_paths(field_pattern) if '*' in field_pattern else [field_pattern]
|
|
101
|
+
|
|
102
|
+
for actual_path in concrete_paths:
|
|
103
|
+
self._current_actual_path = actual_path
|
|
104
|
+
raw_values = self._get_nested_value(actual_path)
|
|
105
|
+
field_exists = self._field_exists_in_data(actual_path)
|
|
106
|
+
|
|
107
|
+
# Handle empty array case for wildcard
|
|
108
|
+
is_wildcard = '*' in field_pattern
|
|
109
|
+
is_empty_array = isinstance(raw_values, list) and len(raw_values) == 0
|
|
110
|
+
|
|
111
|
+
# Special case: wildcard path with empty array
|
|
112
|
+
if is_wildcard and is_empty_array:
|
|
113
|
+
values_to_validate = []
|
|
114
|
+
elif not is_wildcard and not is_empty_array:
|
|
115
|
+
values_to_validate = [raw_values]
|
|
116
|
+
else:
|
|
117
|
+
values_to_validate = [None] if not field_exists else (
|
|
118
|
+
[raw_values] if not isinstance(raw_values, list) else raw_values
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
for rule in rules:
|
|
122
|
+
if priority_only == (rule.rule_name in self.PRIORITY_RULES) and not self._is_exclude:
|
|
123
|
+
rule.set_field_exists(field_exists)
|
|
124
|
+
|
|
125
|
+
# Skip validation for empty array with wildcard unless it's a required rule
|
|
126
|
+
if is_wildcard and is_empty_array and rule.rule_name != 'required':
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
for value in values_to_validate:
|
|
130
|
+
validated.append(self._apply_rule(field_pattern, value, rule))
|
|
131
|
+
|
|
132
|
+
delattr(self, '_current_actual_path')
|
|
133
|
+
self._is_exclude = False
|
|
134
|
+
|
|
135
|
+
return all(valid for valid in validated)
|
|
136
|
+
|
|
137
|
+
def _resolve_wildcard_paths(self, pattern: str) -> List[str]:
|
|
138
|
+
parts = pattern.split('.')
|
|
139
|
+
|
|
140
|
+
def _resolve(data: Any, current_path: str, remaining_parts: List[str]) -> List[str]:
|
|
141
|
+
if not remaining_parts:
|
|
142
|
+
return [current_path] if current_path else []
|
|
143
|
+
|
|
144
|
+
part = remaining_parts[0]
|
|
145
|
+
next_parts = remaining_parts[1:]
|
|
146
|
+
|
|
147
|
+
if part == '*':
|
|
148
|
+
results = []
|
|
149
|
+
# Kasus khusus: wildcard di akhir path
|
|
150
|
+
if not next_parts:
|
|
151
|
+
if isinstance(data, (list, tuple)):
|
|
152
|
+
for i in range(len(data)):
|
|
153
|
+
new_path = f"{current_path}.{i}" if current_path else str(i)
|
|
154
|
+
results.append(new_path)
|
|
155
|
+
elif isinstance(data, dict):
|
|
156
|
+
for key in data.keys():
|
|
157
|
+
new_path = f"{current_path}.{key}" if current_path else key
|
|
158
|
+
results.append(new_path)
|
|
159
|
+
else:
|
|
160
|
+
# Jika bukan collection, kembalikan path saat ini
|
|
161
|
+
if current_path:
|
|
162
|
+
results.append(current_path)
|
|
163
|
+
return results
|
|
164
|
+
|
|
165
|
+
# Wildcard di tengah path (seperti sebelumnya)
|
|
166
|
+
if isinstance(data, (list, tuple)):
|
|
167
|
+
for i, item in enumerate(data):
|
|
168
|
+
new_path = f"{current_path}.{i}" if current_path else str(i)
|
|
169
|
+
results.extend(_resolve(item, new_path, next_parts))
|
|
170
|
+
elif isinstance(data, dict):
|
|
171
|
+
for key, value in data.items():
|
|
172
|
+
new_path = f"{current_path}.{key}" if current_path else key
|
|
173
|
+
results.extend(_resolve(value, new_path, next_parts))
|
|
174
|
+
return results
|
|
175
|
+
|
|
176
|
+
# Handle regular path (sama seperti sebelumnya)
|
|
177
|
+
next_data = None
|
|
178
|
+
if isinstance(data, dict) and part in data:
|
|
179
|
+
next_data = data[part]
|
|
180
|
+
elif isinstance(data, (list, tuple)) and part.isdigit():
|
|
181
|
+
index = int(part)
|
|
182
|
+
if 0 <= index < len(data):
|
|
183
|
+
next_data = data[index]
|
|
184
|
+
|
|
185
|
+
if next_data is not None:
|
|
186
|
+
new_path = f"{current_path}.{part}" if current_path else part
|
|
187
|
+
return _resolve(next_data, new_path, next_parts)
|
|
188
|
+
|
|
189
|
+
return []
|
|
190
|
+
|
|
191
|
+
return _resolve(self.data, '', parts)
|
|
192
|
+
|
|
193
|
+
def _field_exists_in_data(self, field_path: str) -> bool:
|
|
194
|
+
parts = field_path.split('.')
|
|
195
|
+
|
|
196
|
+
def _check(data: Any, remaining_parts: List[str]) -> bool:
|
|
197
|
+
if not remaining_parts:
|
|
198
|
+
return True
|
|
199
|
+
|
|
200
|
+
part = remaining_parts[0]
|
|
201
|
+
next_parts = remaining_parts[1:]
|
|
202
|
+
|
|
203
|
+
if part == '*':
|
|
204
|
+
if isinstance(data, (list, tuple)):
|
|
205
|
+
return any(_check(item, next_parts) for item in data)
|
|
206
|
+
elif isinstance(data, dict):
|
|
207
|
+
return any(_check(value, next_parts) for value in data.values())
|
|
208
|
+
return False
|
|
209
|
+
|
|
210
|
+
if isinstance(data, dict) and part in data:
|
|
211
|
+
return _check(data[part], next_parts)
|
|
212
|
+
elif isinstance(data, (list, tuple)) and part.isdigit():
|
|
213
|
+
index = int(part)
|
|
214
|
+
return 0 <= index < len(data) and _check(data[index], next_parts)
|
|
215
|
+
|
|
216
|
+
return False
|
|
217
|
+
|
|
218
|
+
return _check(self.data, parts)
|
|
219
|
+
|
|
220
|
+
def _apply_rule(self, field: str, value: Any, rule: ValidationRule) -> bool:
|
|
221
|
+
try:
|
|
222
|
+
rule.set_validator(self)
|
|
223
|
+
|
|
224
|
+
# Format field name untuk pesan error (otomatis tangkap path aktual)
|
|
225
|
+
display_field = getattr(self, '_current_actual_path', field)
|
|
226
|
+
|
|
227
|
+
if not rule.validate(field, value, getattr(rule, 'params', [])):
|
|
228
|
+
msg = rule.message(display_field, getattr(rule, 'params', []))
|
|
229
|
+
self.error_handler.add_error(display_field, rule.rule_name, msg, value)
|
|
230
|
+
return False
|
|
231
|
+
else:
|
|
232
|
+
return True
|
|
233
|
+
except Exception as e:
|
|
234
|
+
display_field = getattr(self, '_current_actual_path', field)
|
|
235
|
+
self.error_handler.add_error(display_field, rule.rule_name, str(e), value)
|
|
236
|
+
return False
|
|
237
|
+
|
|
238
|
+
def _get_nested_value(self, path: str) -> Any:
|
|
239
|
+
parts = path.split('.')
|
|
240
|
+
|
|
241
|
+
def _get(data: Any, remaining_parts: List[str]) -> Any:
|
|
242
|
+
if not remaining_parts:
|
|
243
|
+
return data
|
|
244
|
+
|
|
245
|
+
part = remaining_parts[0]
|
|
246
|
+
next_parts = remaining_parts[1:]
|
|
247
|
+
|
|
248
|
+
if part == '*':
|
|
249
|
+
if isinstance(data, (list, tuple)):
|
|
250
|
+
return [_get(item, next_parts) for item in data]
|
|
251
|
+
elif isinstance(data, dict):
|
|
252
|
+
return [_get(value, next_parts) for value in data.values()]
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
if isinstance(data, dict) and part in data:
|
|
256
|
+
return _get(data[part], next_parts)
|
|
257
|
+
elif isinstance(data, (list, tuple)) and part.isdigit():
|
|
258
|
+
index = int(part)
|
|
259
|
+
if 0 <= index < len(data):
|
|
260
|
+
return _get(data[index], next_parts)
|
|
261
|
+
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
result = _get(self.data, parts)
|
|
265
|
+
|
|
266
|
+
# Flatten only one level for wildcard results
|
|
267
|
+
if isinstance(result, list):
|
|
268
|
+
flat_result = []
|
|
269
|
+
for item in result:
|
|
270
|
+
if isinstance(item, list):
|
|
271
|
+
flat_result.extend(item)
|
|
272
|
+
elif item is not None:
|
|
273
|
+
flat_result.append(item)
|
|
274
|
+
return flat_result if flat_result else None
|
|
275
|
+
|
|
276
|
+
return result
|
|
277
|
+
|
|
278
|
+
def add_rule(self, field: str, rules: Union[str, List[Union[str, ValidationRule]], ValidationRule]):
|
|
279
|
+
"""Add new rules to a field"""
|
|
280
|
+
new_rules = self.rule_preparer._convert_to_rules(rules)
|
|
281
|
+
existing_rules = self.rule_preparer._convert_to_rules(self._raw_rules.get(field, []))
|
|
282
|
+
combined_rules = existing_rules + new_rules
|
|
283
|
+
|
|
284
|
+
RuleConflictChecker.check_conflicts(combined_rules)
|
|
285
|
+
self._raw_rules[field] = self.rule_preparer._deduplicate_rules(combined_rules)
|
|
286
|
+
|
|
287
|
+
def set_stop_on_first_failure(self, value: bool) -> None:
|
|
288
|
+
"""Set whether to stop validation after first failure"""
|
|
289
|
+
self._stop_on_first_failure = value
|
|
290
|
+
|
|
291
|
+
@property
|
|
292
|
+
def errors(self) -> Dict[str, List[str]]:
|
|
293
|
+
"""Get current validation errors"""
|
|
294
|
+
return self.error_handler.errors
|
|
295
|
+
|
|
296
|
+
@property
|
|
297
|
+
def has_errors(self) -> bool:
|
|
298
|
+
"""Check if there are any validation errors"""
|
|
299
|
+
return self.error_handler.has_errors
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import inspect
|
|
3
|
+
import warnings
|
|
4
|
+
from typing import Dict, Any, Optional
|
|
5
|
+
from urllib.parse import urlparse
|
|
6
|
+
|
|
7
|
+
class DatabaseAutoDetector:
|
|
8
|
+
@classmethod
|
|
9
|
+
def detect(cls) -> Optional[Dict[str, Any]]:
|
|
10
|
+
"""Main detection method that tries all available detectors"""
|
|
11
|
+
detectors = [
|
|
12
|
+
cls._detect_odoo,
|
|
13
|
+
cls._detect_django,
|
|
14
|
+
cls._detect_flask_env,
|
|
15
|
+
cls._detect_sqlalchemy,
|
|
16
|
+
cls._detect_env_vars
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
for detector in detectors:
|
|
20
|
+
try:
|
|
21
|
+
config = detector()
|
|
22
|
+
if config:
|
|
23
|
+
if cls._validate_config(config):
|
|
24
|
+
return config
|
|
25
|
+
except Exception as e:
|
|
26
|
+
print(f"Warning: Detector {detector.__name__} failed: {str(e)}")
|
|
27
|
+
continue
|
|
28
|
+
|
|
29
|
+
print("No database configuration detected!")
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def _validate_config(cls, config: Dict[str, Any]) -> bool:
|
|
34
|
+
"""Validate the detected configuration for all database types"""
|
|
35
|
+
required_keys = ['type', 'host', 'database']
|
|
36
|
+
|
|
37
|
+
# Daftar tipe database yang membutuhkan username
|
|
38
|
+
requires_username = [
|
|
39
|
+
'postgresql', 'mysql', 'mariadb', 'mssql',
|
|
40
|
+
'oracle', 'cockroachdb', 'redshift'
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
# SQLite adalah kasus khusus
|
|
44
|
+
if config.get('type') == 'sqlite':
|
|
45
|
+
return 'database' in config
|
|
46
|
+
|
|
47
|
+
# Validasi untuk database lainnya
|
|
48
|
+
if not all(key in config for key in required_keys):
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
# Periksa username untuk database yang membutuhkannya
|
|
52
|
+
if config['type'] in requires_username and 'username' not in config:
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
return True
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def _detect_odoo(cls) -> Optional[Dict[str, Any]]:
|
|
59
|
+
"""Detect Odoo database configuration"""
|
|
60
|
+
try:
|
|
61
|
+
from odoo.tools import config
|
|
62
|
+
return {
|
|
63
|
+
'type': 'postgresql', # Odoo selalu menggunakan PostgreSQL
|
|
64
|
+
'host': config['db_host'] or 'localhost',
|
|
65
|
+
'port': str(config['db_port'] or 5432),
|
|
66
|
+
'username': config['db_user'] or 'odoo',
|
|
67
|
+
'password': config['db_password'] or '',
|
|
68
|
+
'database': config['db_name']
|
|
69
|
+
}
|
|
70
|
+
except ImportError:
|
|
71
|
+
try:
|
|
72
|
+
# Cara 2: Deteksi melalui environment variables Odoo
|
|
73
|
+
db_name = os.getenv('ODOO_DATABASE') or os.getenv('DB_NAME')
|
|
74
|
+
if db_name:
|
|
75
|
+
return {
|
|
76
|
+
'type': 'postgresql',
|
|
77
|
+
'host': os.getenv('DB_HOST', 'localhost'),
|
|
78
|
+
'port': os.getenv('DB_PORT', '5432'),
|
|
79
|
+
'username': os.getenv('DB_USER', 'odoo'),
|
|
80
|
+
'password': os.getenv('DB_PASSWORD', ''),
|
|
81
|
+
'database': db_name
|
|
82
|
+
}
|
|
83
|
+
except Exception:
|
|
84
|
+
pass
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def _detect_django(cls) -> Optional[Dict[str, Any]]:
|
|
89
|
+
try:
|
|
90
|
+
from django.conf import settings
|
|
91
|
+
db = settings.DATABASES['default']
|
|
92
|
+
engine = db['ENGINE'].split('.')[-1]
|
|
93
|
+
|
|
94
|
+
engine_map = {
|
|
95
|
+
'postgresql': 'postgresql',
|
|
96
|
+
'mysql': 'mysql',
|
|
97
|
+
'sqlite3': 'sqlite'
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
'type': engine_map.get(engine, engine),
|
|
102
|
+
'host': db.get('HOST', 'localhost'),
|
|
103
|
+
'port': db.get('PORT', '5432' if engine == 'postgresql' else '3306'),
|
|
104
|
+
'username': db['USER'],
|
|
105
|
+
'password': db['PASSWORD'],
|
|
106
|
+
'database': db['NAME']
|
|
107
|
+
}
|
|
108
|
+
except ImportError:
|
|
109
|
+
return None
|
|
110
|
+
except Exception: # Tangkap error spesifik Django
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def _detect_sqlalchemy(cls) -> Optional[Dict[str, Any]]:
|
|
115
|
+
try:
|
|
116
|
+
from sqlalchemy import create_engine
|
|
117
|
+
from sqlalchemy.engine.url import make_url
|
|
118
|
+
|
|
119
|
+
# Cari engine SQLAlchemy di stack frame
|
|
120
|
+
for frame in inspect.stack():
|
|
121
|
+
for val in frame.frame.f_locals.values():
|
|
122
|
+
if isinstance(val, type(create_engine('sqlite://'))): # type: ignore
|
|
123
|
+
url = make_url(str(val.url))
|
|
124
|
+
return {
|
|
125
|
+
'type': url.drivername.split('+')[0],
|
|
126
|
+
'host': url.host or 'localhost',
|
|
127
|
+
'port': str(url.port) if url.port else '5432' if 'postgres' in url.drivername else '3306',
|
|
128
|
+
'username': url.username,
|
|
129
|
+
'password': url.password or '',
|
|
130
|
+
'database': url.database
|
|
131
|
+
}
|
|
132
|
+
return None
|
|
133
|
+
except ImportError:
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
@classmethod
|
|
137
|
+
def _detect_env_vars(cls) -> Optional[Dict[str, Any]]:
|
|
138
|
+
from dotenv import load_dotenv
|
|
139
|
+
load_dotenv()
|
|
140
|
+
|
|
141
|
+
standard_vars = {
|
|
142
|
+
'DB_TYPE': os.getenv('DB_TYPE'),
|
|
143
|
+
'DB_HOST': os.getenv('DB_HOST'),
|
|
144
|
+
'DB_USER': os.getenv('DB_USER'),
|
|
145
|
+
'DB_PASSWORD': os.getenv('DB_PASSWORD'),
|
|
146
|
+
'DB_NAME': os.getenv('DB_NAME')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if any(standard_vars.values()):
|
|
150
|
+
return {
|
|
151
|
+
'type': standard_vars['DB_TYPE'] or 'mysql',
|
|
152
|
+
'host': standard_vars['DB_HOST'] or 'localhost',
|
|
153
|
+
'username': standard_vars['DB_USER'] or 'root',
|
|
154
|
+
'password': standard_vars['DB_PASSWORD'] or '',
|
|
155
|
+
'database': standard_vars['DB_NAME'] or ''
|
|
156
|
+
}
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
@classmethod
|
|
160
|
+
def _detect_flask_env(cls) -> Optional[Dict[str, Any]]:
|
|
161
|
+
"""Deteksi konfigurasi database dari Flask app"""
|
|
162
|
+
try:
|
|
163
|
+
from flask import current_app
|
|
164
|
+
if not current_app or not hasattr(current_app, 'config'):
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
# Handle Flask-SQLAlchemy
|
|
168
|
+
if 'SQLALCHEMY_DATABASE_URI' in current_app.config:
|
|
169
|
+
uri = current_app.config['SQLALCHEMY_DATABASE_URI']
|
|
170
|
+
return cls._parse_sqlalchemy_uri(uri)
|
|
171
|
+
|
|
172
|
+
# Handle Flask default database config
|
|
173
|
+
elif 'DATABASE_URL' in current_app.config:
|
|
174
|
+
uri = current_app.config['DATABASE_URL']
|
|
175
|
+
return cls._parse_sqlalchemy_uri(uri)
|
|
176
|
+
|
|
177
|
+
# Handle Flask-MySQLdb
|
|
178
|
+
elif 'MYSQL_DATABASE_HOST' in current_app.config:
|
|
179
|
+
return {
|
|
180
|
+
'type': 'mysql',
|
|
181
|
+
'host': current_app.config.get('MYSQL_DATABASE_HOST', 'localhost'),
|
|
182
|
+
'port': current_app.config.get('MYSQL_DATABASE_PORT', 3306),
|
|
183
|
+
'username': current_app.config.get('MYSQL_DATABASE_USER', 'root'),
|
|
184
|
+
'password': current_app.config.get('MYSQL_DATABASE_PASSWORD', ''),
|
|
185
|
+
'database': current_app.config.get('MYSQL_DATABASE_DB', '')
|
|
186
|
+
}
|
|
187
|
+
return None
|
|
188
|
+
except (ImportError, RuntimeError):
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
@classmethod
|
|
192
|
+
def _parse_sqlalchemy_uri(cls, uri: str) -> Dict[str, Any]:
|
|
193
|
+
"""Parse SQLAlchemy URI format"""
|
|
194
|
+
from urllib.parse import urlparse
|
|
195
|
+
parsed = urlparse(uri)
|
|
196
|
+
|
|
197
|
+
# Mapping database types
|
|
198
|
+
db_type_map = {
|
|
199
|
+
'postgres': 'postgresql',
|
|
200
|
+
'postgresql': 'postgresql',
|
|
201
|
+
'mysql': 'mysql',
|
|
202
|
+
'sqlite': 'sqlite',
|
|
203
|
+
'mssql': 'mssql'
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
db_type = db_type_map.get(parsed.scheme.split('+')[0], parsed.scheme)
|
|
207
|
+
|
|
208
|
+
config = {
|
|
209
|
+
'type': db_type,
|
|
210
|
+
'host': parsed.hostname or 'localhost',
|
|
211
|
+
'port': parsed.port or (5432 if db_type == 'postgresql' else 3306),
|
|
212
|
+
'username': parsed.username or '',
|
|
213
|
+
'password': parsed.password or '',
|
|
214
|
+
'database': parsed.path[1:] if parsed.path else ''
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
# Handle SQLite special case
|
|
218
|
+
if db_type == 'sqlite':
|
|
219
|
+
config['database'] = parsed.path # Full path termasuk leading slash
|
|
220
|
+
|
|
221
|
+
return config
|
|
222
|
+
|
|
223
|
+
@classmethod
|
|
224
|
+
def _parse_rails_env_vars(cls) -> Optional[Dict[str, Any]]:
|
|
225
|
+
"""Parse Rails-style ENV variables"""
|
|
226
|
+
def get_env(key, default=None):
|
|
227
|
+
return os.getenv(key) or os.getenv(f'DATABASE_{key}', default)
|
|
228
|
+
|
|
229
|
+
adapter = get_env('ADAPTER')
|
|
230
|
+
if not adapter:
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
# Mapping adapter Rails
|
|
234
|
+
adapter_map = {
|
|
235
|
+
'postgresql': 'postgresql',
|
|
236
|
+
'mysql2': 'mysql',
|
|
237
|
+
'sqlite3': 'sqlite'
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
db_type = adapter_map.get(adapter, adapter)
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
'type': db_type,
|
|
244
|
+
'host': get_env('HOST', 'localhost'),
|
|
245
|
+
'port': int(get_env('PORT', 5432 if db_type == 'postgresql' else 3306)),
|
|
246
|
+
'username': get_env('USERNAME', 'root'),
|
|
247
|
+
'password': get_env('PASSWORD', ''),
|
|
248
|
+
'database': get_env('DATABASE', ''),
|
|
249
|
+
'timeout': int(get_env('TIMEOUT', '5000'))
|
|
250
|
+
}
|