velocity-python 0.0.103__py3-none-any.whl → 0.0.105__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.
- velocity/aws/amplify.py +1 -1
- velocity/db/__init__.py +11 -0
- velocity/db/core/engine.py +7 -7
- velocity/db/core/result.py +177 -27
- velocity/db/core/row.py +15 -3
- velocity/db/core/table.py +2 -1
- velocity/db/utils.py +209 -0
- {velocity_python-0.0.103.dist-info → velocity_python-0.0.105.dist-info}/METADATA +55 -4
- {velocity_python-0.0.103.dist-info → velocity_python-0.0.105.dist-info}/RECORD +12 -11
- {velocity_python-0.0.103.dist-info → velocity_python-0.0.105.dist-info}/WHEEL +0 -0
- {velocity_python-0.0.103.dist-info → velocity_python-0.0.105.dist-info}/licenses/LICENSE +0 -0
- {velocity_python-0.0.103.dist-info → velocity_python-0.0.105.dist-info}/top_level.txt +0 -0
velocity/aws/amplify.py
CHANGED
|
@@ -250,7 +250,7 @@ class AmplifyProject:
|
|
|
250
250
|
customRules=[
|
|
251
251
|
{"source": "/<*>", "target": "/index.html", "status": "404-200"},
|
|
252
252
|
{
|
|
253
|
-
"source": f'</^[^.]
|
|
253
|
+
"source": f'</^[^.]+$|\\.(?!({"|".join(srv_list)})$)([^.]+$)/>',
|
|
254
254
|
"target": "/",
|
|
255
255
|
"status": "200",
|
|
256
256
|
},
|
velocity/db/__init__.py
CHANGED
|
@@ -3,6 +3,17 @@ from velocity.db.servers import postgres
|
|
|
3
3
|
from velocity.db.servers import mysql
|
|
4
4
|
from velocity.db.servers import sqlite
|
|
5
5
|
from velocity.db.servers import sqlserver
|
|
6
|
+
from velocity.db import utils
|
|
6
7
|
|
|
7
8
|
# Export exceptions at the package level for backward compatibility
|
|
8
9
|
from velocity.db.exceptions import *
|
|
10
|
+
|
|
11
|
+
# Export commonly used utility functions
|
|
12
|
+
from velocity.db.utils import (
|
|
13
|
+
safe_sort_rows,
|
|
14
|
+
safe_sort_key_none_last,
|
|
15
|
+
safe_sort_key_none_first,
|
|
16
|
+
safe_sort_key_with_default,
|
|
17
|
+
group_by_fields,
|
|
18
|
+
safe_sort_grouped_rows
|
|
19
|
+
)
|
velocity/db/core/engine.py
CHANGED
|
@@ -335,8 +335,8 @@ class Engine:
|
|
|
335
335
|
sql: The SQL statement that caused the error (optional)
|
|
336
336
|
parameters: The parameters passed to the SQL statement (optional)
|
|
337
337
|
|
|
338
|
-
|
|
339
|
-
The appropriate velocity exception
|
|
338
|
+
Raises:
|
|
339
|
+
The appropriate velocity exception with proper chaining
|
|
340
340
|
"""
|
|
341
341
|
logger = logging.getLogger(__name__)
|
|
342
342
|
|
|
@@ -369,7 +369,7 @@ class Engine:
|
|
|
369
369
|
if error_code in codes:
|
|
370
370
|
logger.info(f"✅ Successfully classified error: {error_code} → {error_class}")
|
|
371
371
|
try:
|
|
372
|
-
|
|
372
|
+
raise self._create_exception_with_chaining(
|
|
373
373
|
error_class, error_message, exception, sql, parameters
|
|
374
374
|
)
|
|
375
375
|
except Exception as creation_error:
|
|
@@ -445,7 +445,7 @@ class Engine:
|
|
|
445
445
|
try:
|
|
446
446
|
if re.search(pattern, error_message_lower):
|
|
447
447
|
logger.info(f"✅ Classified error by pattern match: '{pattern}' → {error_class}")
|
|
448
|
-
|
|
448
|
+
raise self._create_exception_with_chaining(
|
|
449
449
|
error_class, error_message, exception, sql, parameters
|
|
450
450
|
)
|
|
451
451
|
except re.error as regex_error:
|
|
@@ -464,7 +464,7 @@ class Engine:
|
|
|
464
464
|
f" → Available Classifications: {available_codes or 'None configured'}"
|
|
465
465
|
)
|
|
466
466
|
|
|
467
|
-
|
|
467
|
+
raise self._create_exception_with_chaining(
|
|
468
468
|
'DatabaseError', error_message, exception, sql, parameters
|
|
469
469
|
)
|
|
470
470
|
|
|
@@ -1082,7 +1082,7 @@ class Engine:
|
|
|
1082
1082
|
else:
|
|
1083
1083
|
new_exception = ExceptionClass(formatted_message)
|
|
1084
1084
|
|
|
1085
|
-
# Only set __cause__ if original_exception is
|
|
1085
|
+
# Only set __cause__ if original_exception is not None and derives from BaseException
|
|
1086
1086
|
if isinstance(original_exception, BaseException):
|
|
1087
1087
|
new_exception.__cause__ = original_exception # Preserve exception chain
|
|
1088
1088
|
|
|
@@ -1105,7 +1105,7 @@ class Engine:
|
|
|
1105
1105
|
)
|
|
1106
1106
|
|
|
1107
1107
|
fallback_exception = DatabaseError(formatted_message)
|
|
1108
|
-
# Only set __cause__ if original_exception is
|
|
1108
|
+
# Only set __cause__ if original_exception is not None and derives from BaseException
|
|
1109
1109
|
if isinstance(original_exception, BaseException):
|
|
1110
1110
|
fallback_exception.__cause__ = original_exception
|
|
1111
1111
|
return fallback_exception
|
velocity/db/core/result.py
CHANGED
|
@@ -7,11 +7,37 @@ class Result:
|
|
|
7
7
|
"""
|
|
8
8
|
Wraps a database cursor to provide various convenience transformations
|
|
9
9
|
(dict, list, tuple, etc.) and helps iterate over query results.
|
|
10
|
+
|
|
11
|
+
Features:
|
|
12
|
+
- Pre-fetches first row for immediate boolean evaluation
|
|
13
|
+
- Boolean state changes as rows are consumed: bool(result) tells you if MORE rows are available
|
|
14
|
+
- Supports __bool__, is_empty(), has_results() for checking remaining results
|
|
15
|
+
- Efficient iteration without unnecessary fetchall() calls
|
|
16
|
+
- Caches next row to maintain accurate state without redundant database calls
|
|
17
|
+
|
|
18
|
+
Boolean Behavior:
|
|
19
|
+
- Initially: bool(result) = True if query returned any rows
|
|
20
|
+
- After each row: bool(result) = True if more rows are available to fetch
|
|
21
|
+
- After last row: bool(result) = False (no more rows)
|
|
22
|
+
- After one() or scalar(): bool(result) = False (marked as exhausted)
|
|
10
23
|
"""
|
|
11
24
|
|
|
12
25
|
def __init__(self, cursor=None, tx=None, sql=None, params=None):
|
|
13
26
|
self._cursor = cursor
|
|
14
|
-
|
|
27
|
+
# Safely extract headers from cursor description
|
|
28
|
+
try:
|
|
29
|
+
description = getattr(cursor, "description", []) or []
|
|
30
|
+
self._headers = []
|
|
31
|
+
for col in description:
|
|
32
|
+
if hasattr(col, '__getitem__'): # Tuple-like (col[0])
|
|
33
|
+
self._headers.append(col[0].lower())
|
|
34
|
+
elif hasattr(col, 'name'): # Object with name attribute
|
|
35
|
+
self._headers.append(col.name.lower())
|
|
36
|
+
else:
|
|
37
|
+
self._headers.append(f'column_{len(self._headers)}')
|
|
38
|
+
except (AttributeError, TypeError, IndexError):
|
|
39
|
+
self._headers = []
|
|
40
|
+
|
|
15
41
|
self.__as_strings = False
|
|
16
42
|
self.__enumerate = False
|
|
17
43
|
self.__count = -1
|
|
@@ -20,6 +46,32 @@ class Result:
|
|
|
20
46
|
self.__sql = sql
|
|
21
47
|
self.__params = params
|
|
22
48
|
self.transform = lambda row: dict(zip(self.headers, row)) # Default transform
|
|
49
|
+
self._cached_first_row = None
|
|
50
|
+
self._first_row_fetched = False
|
|
51
|
+
self._exhausted = False
|
|
52
|
+
|
|
53
|
+
# Pre-fetch the first row to enable immediate boolean evaluation
|
|
54
|
+
self._fetch_first_row()
|
|
55
|
+
|
|
56
|
+
def _fetch_first_row(self):
|
|
57
|
+
"""
|
|
58
|
+
Pre-fetch the first row from the cursor to enable immediate boolean evaluation.
|
|
59
|
+
"""
|
|
60
|
+
if self._first_row_fetched or not self._cursor:
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
raw_row = self._cursor.fetchone()
|
|
65
|
+
if raw_row:
|
|
66
|
+
self._cached_first_row = raw_row
|
|
67
|
+
else:
|
|
68
|
+
self._exhausted = True
|
|
69
|
+
except Exception:
|
|
70
|
+
# If there's an error fetching (e.g., cursor closed), assume no results
|
|
71
|
+
self._exhausted = True
|
|
72
|
+
self._cursor = None # Mark cursor as invalid
|
|
73
|
+
finally:
|
|
74
|
+
self._first_row_fetched = True
|
|
23
75
|
|
|
24
76
|
def __str__(self):
|
|
25
77
|
return repr(self.all())
|
|
@@ -31,20 +83,80 @@ class Result:
|
|
|
31
83
|
if not exc_type:
|
|
32
84
|
self.close()
|
|
33
85
|
|
|
86
|
+
def __bool__(self):
|
|
87
|
+
"""
|
|
88
|
+
Return True if there are more rows available to fetch.
|
|
89
|
+
This changes as rows are consumed - after the last row is fetched, this becomes False.
|
|
90
|
+
"""
|
|
91
|
+
return self.has_results()
|
|
92
|
+
|
|
93
|
+
def is_empty(self):
|
|
94
|
+
"""
|
|
95
|
+
Return True if there are no more rows available to fetch.
|
|
96
|
+
"""
|
|
97
|
+
return not self.has_results()
|
|
98
|
+
|
|
99
|
+
def has_results(self):
|
|
100
|
+
"""
|
|
101
|
+
Return True if there are more rows available to fetch.
|
|
102
|
+
This is based on whether we have a cached row or the cursor isn't exhausted.
|
|
103
|
+
"""
|
|
104
|
+
return self._cached_first_row is not None or (not self._exhausted and self._cursor)
|
|
105
|
+
|
|
34
106
|
def __next__(self):
|
|
35
107
|
"""
|
|
36
108
|
Iterator interface to retrieve the next row.
|
|
37
109
|
"""
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
110
|
+
# If we have a cached first row, return it and clear the cache
|
|
111
|
+
if self._cached_first_row is not None:
|
|
112
|
+
row = self._cached_first_row
|
|
113
|
+
self._cached_first_row = None
|
|
114
|
+
# Try to pre-fetch the next row to update our state
|
|
115
|
+
self._try_cache_next_row()
|
|
116
|
+
elif not self._exhausted and self._cursor:
|
|
117
|
+
try:
|
|
118
|
+
row = self._cursor.fetchone()
|
|
119
|
+
if not row:
|
|
120
|
+
self._exhausted = True
|
|
121
|
+
raise StopIteration
|
|
122
|
+
# Try to pre-fetch the next row to update our state
|
|
123
|
+
self._try_cache_next_row()
|
|
124
|
+
except Exception as e:
|
|
125
|
+
# Handle cursor errors (e.g., closed cursor)
|
|
126
|
+
self._exhausted = True
|
|
127
|
+
self._cursor = None
|
|
128
|
+
if isinstance(e, StopIteration):
|
|
129
|
+
raise
|
|
130
|
+
raise StopIteration
|
|
131
|
+
else:
|
|
132
|
+
raise StopIteration
|
|
133
|
+
|
|
134
|
+
# Apply transformations
|
|
135
|
+
if self.__as_strings:
|
|
136
|
+
row = ["" if x is None else str(x) for x in row]
|
|
137
|
+
if self.__enumerate:
|
|
138
|
+
self.__count += 1
|
|
139
|
+
return (self.__count, self.transform(row))
|
|
140
|
+
return self.transform(row)
|
|
141
|
+
|
|
142
|
+
def _try_cache_next_row(self):
|
|
143
|
+
"""
|
|
144
|
+
Try to cache the next row to maintain accurate boolean state.
|
|
145
|
+
This is called after we return a row to check if there are more.
|
|
146
|
+
"""
|
|
147
|
+
if not self._cursor or self._cached_first_row is not None:
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
next_row = self._cursor.fetchone()
|
|
152
|
+
if next_row:
|
|
153
|
+
self._cached_first_row = next_row
|
|
154
|
+
else:
|
|
155
|
+
self._exhausted = True
|
|
156
|
+
except Exception:
|
|
157
|
+
# If cursor is closed or has error, mark as exhausted
|
|
158
|
+
self._exhausted = True
|
|
159
|
+
self._cursor = None
|
|
48
160
|
|
|
49
161
|
def batch(self, qty=1):
|
|
50
162
|
"""
|
|
@@ -90,16 +202,33 @@ class Result:
|
|
|
90
202
|
def columns(self):
|
|
91
203
|
"""
|
|
92
204
|
Retrieves detailed column information from the cursor.
|
|
205
|
+
Gracefully handles different database types.
|
|
93
206
|
"""
|
|
94
207
|
if not self.__columns and self._cursor and hasattr(self._cursor, "description"):
|
|
95
208
|
for column in self._cursor.description:
|
|
96
209
|
data = {
|
|
97
|
-
"type_name":
|
|
210
|
+
"type_name": "unknown" # Default value
|
|
98
211
|
}
|
|
212
|
+
|
|
213
|
+
# Try to get type information (PostgreSQL specific)
|
|
214
|
+
try:
|
|
215
|
+
if hasattr(column, 'type_code') and self.__tx and hasattr(self.__tx, 'pg_types'):
|
|
216
|
+
data["type_name"] = self.__tx.pg_types.get(column.type_code, "unknown")
|
|
217
|
+
except (AttributeError, KeyError):
|
|
218
|
+
# Keep default value
|
|
219
|
+
pass
|
|
220
|
+
|
|
221
|
+
# Get all other column attributes safely
|
|
99
222
|
for key in dir(column):
|
|
100
|
-
if "__"
|
|
101
|
-
|
|
102
|
-
|
|
223
|
+
if not key.startswith("__"):
|
|
224
|
+
try:
|
|
225
|
+
data[key] = getattr(column, key)
|
|
226
|
+
except (AttributeError, TypeError):
|
|
227
|
+
# Skip attributes that can't be accessed
|
|
228
|
+
continue
|
|
229
|
+
|
|
230
|
+
column_name = getattr(column, 'name', f'column_{len(self.__columns)}')
|
|
231
|
+
self.__columns[column_name] = data
|
|
103
232
|
return self.__columns
|
|
104
233
|
|
|
105
234
|
@property
|
|
@@ -108,10 +237,19 @@ class Result:
|
|
|
108
237
|
|
|
109
238
|
def close(self):
|
|
110
239
|
"""
|
|
111
|
-
Closes the underlying cursor if it exists.
|
|
240
|
+
Closes the underlying cursor if it exists and marks result as exhausted.
|
|
112
241
|
"""
|
|
113
242
|
if self._cursor:
|
|
114
|
-
|
|
243
|
+
try:
|
|
244
|
+
self._cursor.close()
|
|
245
|
+
except Exception:
|
|
246
|
+
# Ignore errors when closing cursor
|
|
247
|
+
pass
|
|
248
|
+
finally:
|
|
249
|
+
self._cursor = None
|
|
250
|
+
# Mark as exhausted and clear cached data
|
|
251
|
+
self._exhausted = True
|
|
252
|
+
self._cached_first_row = None
|
|
115
253
|
|
|
116
254
|
def as_dict(self):
|
|
117
255
|
"""
|
|
@@ -165,23 +303,35 @@ class Result:
|
|
|
165
303
|
def scalar(self, default=None):
|
|
166
304
|
"""
|
|
167
305
|
Return the first column of the first row, or `default` if no rows.
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
306
|
+
After calling this method, the result is marked as exhausted.
|
|
307
|
+
"""
|
|
308
|
+
if self._cached_first_row is not None:
|
|
309
|
+
val = self._cached_first_row
|
|
310
|
+
self._cached_first_row = None
|
|
311
|
+
self._exhausted = True # Mark as exhausted since we only want one value
|
|
312
|
+
return val[0] if val else default
|
|
313
|
+
elif not self._exhausted and self._cursor:
|
|
314
|
+
try:
|
|
315
|
+
val = self._cursor.fetchone()
|
|
316
|
+
self._exhausted = True
|
|
317
|
+
return val[0] if val else default
|
|
318
|
+
except Exception:
|
|
319
|
+
# If cursor error, return default
|
|
320
|
+
self._exhausted = True
|
|
321
|
+
self._cursor = None
|
|
322
|
+
return default
|
|
323
|
+
return default
|
|
175
324
|
|
|
176
325
|
def one(self, default=None):
|
|
177
326
|
"""
|
|
178
327
|
Return the first row or `default` if no rows.
|
|
328
|
+
After calling this method, the result is marked as exhausted.
|
|
179
329
|
"""
|
|
180
330
|
try:
|
|
181
331
|
row = next(self)
|
|
182
|
-
#
|
|
183
|
-
|
|
184
|
-
|
|
332
|
+
# Mark as exhausted since we only want one row
|
|
333
|
+
self._exhausted = True
|
|
334
|
+
self._cached_first_row = None # Clear any cached row
|
|
185
335
|
return row
|
|
186
336
|
except StopIteration:
|
|
187
337
|
return default
|
velocity/db/core/row.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import pprint
|
|
2
|
+
from velocity.db.exceptions import DbColumnMissingError
|
|
2
3
|
|
|
3
4
|
|
|
4
5
|
class Row:
|
|
@@ -85,10 +86,21 @@ class Row:
|
|
|
85
86
|
return list(d.items())
|
|
86
87
|
|
|
87
88
|
def get(self, key, failobj=None):
|
|
88
|
-
|
|
89
|
-
|
|
89
|
+
try:
|
|
90
|
+
data = self[key]
|
|
91
|
+
if data is None:
|
|
92
|
+
return failobj
|
|
93
|
+
return data
|
|
94
|
+
except DbColumnMissingError:
|
|
95
|
+
# Column doesn't exist in the table, return the default value
|
|
90
96
|
return failobj
|
|
91
|
-
|
|
97
|
+
except Exception as e:
|
|
98
|
+
# Check if the error message indicates a missing column
|
|
99
|
+
error_msg = str(e).lower()
|
|
100
|
+
if 'column' in error_msg and ('does not exist' in error_msg or 'not found' in error_msg):
|
|
101
|
+
return failobj
|
|
102
|
+
# Re-raise other exceptions
|
|
103
|
+
raise
|
|
92
104
|
|
|
93
105
|
def setdefault(self, key, default=None):
|
|
94
106
|
data = self[key]
|
velocity/db/core/table.py
CHANGED
velocity/db/utils.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Database utility functions for common operations.
|
|
3
|
+
|
|
4
|
+
This module provides utility functions to handle common database operations
|
|
5
|
+
safely, including sorting with None values and other edge cases.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any, Callable, List, Optional, Union
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def safe_sort_key_none_last(field_name: str) -> Callable[[dict], tuple]:
|
|
12
|
+
"""
|
|
13
|
+
Create a sort key function that places None values at the end.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
field_name: Name of the field to sort by
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
A function suitable for use as a sort key that handles None values
|
|
20
|
+
|
|
21
|
+
Example:
|
|
22
|
+
rows = [{"date": "2024-01"}, {"date": None}, {"date": "2023-12"}]
|
|
23
|
+
sorted_rows = sorted(rows, key=safe_sort_key_none_last("date"))
|
|
24
|
+
# Result: [{"date": "2023-12"}, {"date": "2024-01"}, {"date": None}]
|
|
25
|
+
"""
|
|
26
|
+
def sort_key(row: dict) -> tuple:
|
|
27
|
+
value = row.get(field_name)
|
|
28
|
+
if value is None:
|
|
29
|
+
return (1, "") # None values sort last
|
|
30
|
+
return (0, value)
|
|
31
|
+
|
|
32
|
+
return sort_key
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def safe_sort_key_none_first(field_name: str) -> Callable[[dict], tuple]:
|
|
36
|
+
"""
|
|
37
|
+
Create a sort key function that places None values at the beginning.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
field_name: Name of the field to sort by
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
A function suitable for use as a sort key that handles None values
|
|
44
|
+
|
|
45
|
+
Example:
|
|
46
|
+
rows = [{"date": "2024-01"}, {"date": None}, {"date": "2023-12"}]
|
|
47
|
+
sorted_rows = sorted(rows, key=safe_sort_key_none_first("date"))
|
|
48
|
+
# Result: [{"date": None}, {"date": "2023-12"}, {"date": "2024-01"}]
|
|
49
|
+
"""
|
|
50
|
+
def sort_key(row: dict) -> tuple:
|
|
51
|
+
value = row.get(field_name)
|
|
52
|
+
if value is None:
|
|
53
|
+
return (0, "") # None values sort first
|
|
54
|
+
return (1, value)
|
|
55
|
+
|
|
56
|
+
return sort_key
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def safe_sort_key_with_default(field_name: str, default_value: Any = "") -> Callable[[dict], Any]:
|
|
60
|
+
"""
|
|
61
|
+
Create a sort key function that replaces None values with a default.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
field_name: Name of the field to sort by
|
|
65
|
+
default_value: Value to use for None entries
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
A function suitable for use as a sort key that handles None values
|
|
69
|
+
|
|
70
|
+
Example:
|
|
71
|
+
rows = [{"date": "2024-01"}, {"date": None}, {"date": "2023-12"}]
|
|
72
|
+
sorted_rows = sorted(rows, key=safe_sort_key_with_default("date", "1900-01"))
|
|
73
|
+
# Result: [{"date": None}, {"date": "2023-12"}, {"date": "2024-01"}]
|
|
74
|
+
"""
|
|
75
|
+
def sort_key(row: dict) -> Any:
|
|
76
|
+
value = row.get(field_name)
|
|
77
|
+
return default_value if value is None else value
|
|
78
|
+
|
|
79
|
+
return sort_key
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def safe_sort_rows(rows: List[dict], field_name: str,
|
|
83
|
+
none_handling: str = "last",
|
|
84
|
+
default_value: Any = "",
|
|
85
|
+
reverse: bool = False) -> List[dict]:
|
|
86
|
+
"""
|
|
87
|
+
Safely sort a list of dictionaries by a field that may contain None values.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
rows: List of dictionaries to sort
|
|
91
|
+
field_name: Name of the field to sort by
|
|
92
|
+
none_handling: How to handle None values - "first", "last", or "default"
|
|
93
|
+
default_value: Default value to use when none_handling is "default"
|
|
94
|
+
reverse: Whether to reverse the sort order
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
New list of dictionaries sorted by the specified field
|
|
98
|
+
|
|
99
|
+
Raises:
|
|
100
|
+
ValueError: If none_handling is not a valid option
|
|
101
|
+
|
|
102
|
+
Example:
|
|
103
|
+
rows = [
|
|
104
|
+
{"name": "Alice", "date": "2024-01"},
|
|
105
|
+
{"name": "Bob", "date": None},
|
|
106
|
+
{"name": "Charlie", "date": "2023-12"}
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
# None values last
|
|
110
|
+
sorted_rows = safe_sort_rows(rows, "date")
|
|
111
|
+
|
|
112
|
+
# None values first
|
|
113
|
+
sorted_rows = safe_sort_rows(rows, "date", none_handling="first")
|
|
114
|
+
|
|
115
|
+
# Replace None with default
|
|
116
|
+
sorted_rows = safe_sort_rows(rows, "date", none_handling="default",
|
|
117
|
+
default_value="1900-01")
|
|
118
|
+
"""
|
|
119
|
+
if none_handling == "last":
|
|
120
|
+
key_func = safe_sort_key_none_last(field_name)
|
|
121
|
+
elif none_handling == "first":
|
|
122
|
+
key_func = safe_sort_key_none_first(field_name)
|
|
123
|
+
elif none_handling == "default":
|
|
124
|
+
key_func = safe_sort_key_with_default(field_name, default_value)
|
|
125
|
+
else:
|
|
126
|
+
raise ValueError(f"Invalid none_handling option: {none_handling}. "
|
|
127
|
+
"Must be 'first', 'last', or 'default'")
|
|
128
|
+
|
|
129
|
+
return sorted(rows, key=key_func, reverse=reverse)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def group_by_fields(rows: List[dict], *field_names: str) -> dict:
|
|
133
|
+
"""
|
|
134
|
+
Group rows by one or more field values.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
rows: List of dictionaries to group
|
|
138
|
+
*field_names: Names of fields to group by
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
Dictionary where keys are tuples of field values and values are lists of rows
|
|
142
|
+
|
|
143
|
+
Example:
|
|
144
|
+
rows = [
|
|
145
|
+
{"email": "alice@example.com", "type": "premium", "amount": 100},
|
|
146
|
+
{"email": "alice@example.com", "type": "basic", "amount": 50},
|
|
147
|
+
{"email": "bob@example.com", "type": "premium", "amount": 100},
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
# Group by email only
|
|
151
|
+
groups = group_by_fields(rows, "email")
|
|
152
|
+
# Result: {
|
|
153
|
+
# ("alice@example.com",): [row1, row2],
|
|
154
|
+
# ("bob@example.com",): [row3]
|
|
155
|
+
# }
|
|
156
|
+
|
|
157
|
+
# Group by email and type
|
|
158
|
+
groups = group_by_fields(rows, "email", "type")
|
|
159
|
+
# Result: {
|
|
160
|
+
# ("alice@example.com", "premium"): [row1],
|
|
161
|
+
# ("alice@example.com", "basic"): [row2],
|
|
162
|
+
# ("bob@example.com", "premium"): [row3]
|
|
163
|
+
# }
|
|
164
|
+
"""
|
|
165
|
+
groups = {}
|
|
166
|
+
for row in rows:
|
|
167
|
+
key = tuple(row.get(field) for field in field_names)
|
|
168
|
+
if key not in groups:
|
|
169
|
+
groups[key] = []
|
|
170
|
+
groups[key].append(row)
|
|
171
|
+
|
|
172
|
+
return groups
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def safe_sort_grouped_rows(grouped_rows: dict, field_name: str,
|
|
176
|
+
none_handling: str = "last",
|
|
177
|
+
default_value: Any = "",
|
|
178
|
+
reverse: bool = False) -> dict:
|
|
179
|
+
"""
|
|
180
|
+
Safely sort rows within each group of a grouped result.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
grouped_rows: Dictionary of grouped rows (from group_by_fields)
|
|
184
|
+
field_name: Name of the field to sort by within each group
|
|
185
|
+
none_handling: How to handle None values - "first", "last", or "default"
|
|
186
|
+
default_value: Default value to use when none_handling is "default"
|
|
187
|
+
reverse: Whether to reverse the sort order
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
Dictionary with the same keys but sorted lists as values
|
|
191
|
+
|
|
192
|
+
Example:
|
|
193
|
+
# After grouping payment profiles by email and card number
|
|
194
|
+
groups = group_by_fields(payment_profiles, "email_address", "card_number")
|
|
195
|
+
|
|
196
|
+
# Sort each group by expiration date, with None values last
|
|
197
|
+
sorted_groups = safe_sort_grouped_rows(groups, "expiration_date")
|
|
198
|
+
|
|
199
|
+
# Now process each sorted group
|
|
200
|
+
for group_key, sorted_group in sorted_groups.items():
|
|
201
|
+
for idx, row in enumerate(sorted_group):
|
|
202
|
+
# Process each row safely
|
|
203
|
+
pass
|
|
204
|
+
"""
|
|
205
|
+
result = {}
|
|
206
|
+
for key, rows in grouped_rows.items():
|
|
207
|
+
result[key] = safe_sort_rows(rows, field_name, none_handling, default_value, reverse)
|
|
208
|
+
|
|
209
|
+
return result
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: velocity-python
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.105
|
|
4
4
|
Summary: A rapid application development library for interfacing with data storage
|
|
5
|
-
Author-email: Velocity Team <
|
|
6
|
-
License: MIT
|
|
5
|
+
Author-email: Velocity Team <info@codeclubs.org>
|
|
6
|
+
License-Expression: MIT
|
|
7
7
|
Project-URL: Homepage, https://codeclubs.org/projects/velocity
|
|
8
8
|
Keywords: database,orm,sql,rapid-development,data-storage
|
|
9
9
|
Classifier: Development Status :: 3 - Alpha
|
|
@@ -16,7 +16,6 @@ Classifier: Programming Language :: Python :: 3.8
|
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.9
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.10
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
20
19
|
Classifier: Operating System :: OS Independent
|
|
21
20
|
Requires-Python: >=3.7
|
|
22
21
|
Description-Content-Type: text/markdown
|
|
@@ -146,6 +145,58 @@ pip install velocity-python[sqlserver]
|
|
|
146
145
|
pip install velocity-python[all]
|
|
147
146
|
```
|
|
148
147
|
|
|
148
|
+
## Project Structure
|
|
149
|
+
|
|
150
|
+
```
|
|
151
|
+
velocity-python/
|
|
152
|
+
├── src/velocity/ # Main package source code
|
|
153
|
+
├── tests/ # Test suite
|
|
154
|
+
├── scripts/ # Utility scripts and demos
|
|
155
|
+
│ ├── run_tests.py # Test runner script
|
|
156
|
+
│ ├── bump.py # Version management
|
|
157
|
+
│ ├── demo_*.py # Demo scripts
|
|
158
|
+
│ └── README.md # Script documentation
|
|
159
|
+
├── docs/ # Documentation
|
|
160
|
+
│ ├── TESTING.md # Testing guide
|
|
161
|
+
│ ├── DUAL_FORMAT_DOCUMENTATION.md
|
|
162
|
+
│ ├── ERROR_HANDLING_IMPROVEMENTS.md
|
|
163
|
+
│ └── sample_error_email.html
|
|
164
|
+
├── Makefile # Development commands
|
|
165
|
+
├── pyproject.toml # Package configuration
|
|
166
|
+
└── README.md # This file
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Development
|
|
170
|
+
|
|
171
|
+
### Running Tests
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
# Run unit tests (fast, no database required)
|
|
175
|
+
make test-unit
|
|
176
|
+
|
|
177
|
+
# Run integration tests (requires database)
|
|
178
|
+
make test-integration
|
|
179
|
+
|
|
180
|
+
# Run with coverage
|
|
181
|
+
make coverage
|
|
182
|
+
|
|
183
|
+
# Clean cache files
|
|
184
|
+
make clean
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### Using Scripts
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
# Run the test runner directly
|
|
191
|
+
python scripts/run_tests.py --unit --verbose
|
|
192
|
+
|
|
193
|
+
# Version management
|
|
194
|
+
python scripts/bump.py
|
|
195
|
+
|
|
196
|
+
# See all available demo scripts
|
|
197
|
+
ls scripts/demo_*.py
|
|
198
|
+
```
|
|
199
|
+
|
|
149
200
|
## Quick Start
|
|
150
201
|
|
|
151
202
|
### Database Connection
|
|
@@ -5,24 +5,25 @@ velocity/app/orders.py,sha256=W-HAXEwY8-IFXbKh82HnMeRVZM7P-TWGEQOWtkLIzI4,6298
|
|
|
5
5
|
velocity/app/payments.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
6
|
velocity/app/purchase_orders.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
7
|
velocity/aws/__init__.py,sha256=tj9-NliYxRVPYLnnDuA4FMwBHbbH4ed8gtHgwRskNgY,647
|
|
8
|
-
velocity/aws/amplify.py,sha256=
|
|
8
|
+
velocity/aws/amplify.py,sha256=n6ttzHmbF8tc0ZDN8LIA6s_fnkZ3ylu3AAUAYyD9MzE,15048
|
|
9
9
|
velocity/aws/handlers/__init__.py,sha256=xnpFZJVlC2uoeeFW4zuPST8wA8ajaQDky5Y6iXZzi3A,172
|
|
10
10
|
velocity/aws/handlers/context.py,sha256=UIjNR83y2NSIyK8HMPX8t5tpJHFNabiZvNgmmdQL3HA,1822
|
|
11
11
|
velocity/aws/handlers/lambda_handler.py,sha256=0KrT6UIxDILzBRpoRSvwDgHpQ-vWfubcZFOCbJsewDc,6516
|
|
12
12
|
velocity/aws/handlers/response.py,sha256=LXhtizLKnVBWjtHyE0h0bk-NYDrRpj7CHa7tRz9KkC4,9324
|
|
13
13
|
velocity/aws/handlers/sqs_handler.py,sha256=nqt8NMOc5yO-L6CZo7NjgR8Q4KPKTDFBO-0eHu6oxkY,7149
|
|
14
|
-
velocity/db/__init__.py,sha256=
|
|
14
|
+
velocity/db/__init__.py,sha256=t7YJT42U19Vkd4MMz5MhU8IFryKfblNPLJcPqzpb4HQ,566
|
|
15
15
|
velocity/db/exceptions.py,sha256=oTXzdxP0GrraGrqRD1JgIVP5urO5yNN7A3IzTiAtNJ0,2173
|
|
16
|
+
velocity/db/utils.py,sha256=3uUTLjMUC6g18bn7jRGBajYlXNFlBDZkGxIwQSBK_Y8,7298
|
|
16
17
|
velocity/db/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
17
18
|
velocity/db/core/column.py,sha256=tAr8tL3a2nyaYpNHhGl508FrY_pGZTzyYgjAV5CEBv4,4092
|
|
18
19
|
velocity/db/core/database.py,sha256=3zNGItklu9tZCKsbx2T2vCcU1so8AL9PPL0DLjvaz6s,3554
|
|
19
20
|
velocity/db/core/decorators.py,sha256=76Jkr9XptXt8cvcgp1zbHfuL8uHzWy8lwfR29u-DVu4,4574
|
|
20
|
-
velocity/db/core/engine.py,sha256=
|
|
21
|
+
velocity/db/core/engine.py,sha256=iLQSr9hvn4w78EGiCbS2WsiojQgPYRBW_EGjTS2074g,49225
|
|
21
22
|
velocity/db/core/exceptions.py,sha256=tuDniRqTX8Opc2d033LPJOI3Ux4NSwUcHqW729n-HXA,1027
|
|
22
|
-
velocity/db/core/result.py,sha256=
|
|
23
|
-
velocity/db/core/row.py,sha256=
|
|
23
|
+
velocity/db/core/result.py,sha256=q5YvOP8_ozDdWJxLfFkGyEvF_ZqSn-WqI0qO5UgVBa4,12393
|
|
24
|
+
velocity/db/core/row.py,sha256=zZ3zZbWjZkZfYAYuZJLHFJ8jdXc7dYv8Iyv9Ut8W8tE,7261
|
|
24
25
|
velocity/db/core/sequence.py,sha256=VMBc0ZjGnOaWTwKW6xMNTdP8rZ2umQ8ml4fHTTwuGq4,3904
|
|
25
|
-
velocity/db/core/table.py,sha256=
|
|
26
|
+
velocity/db/core/table.py,sha256=yUZVKrQXgHBMWY5Mf-XmwgaNhW-HHqfg57PpUW_RXHQ,34586
|
|
26
27
|
velocity/db/core/transaction.py,sha256=unjmVkkfb7D8Wow6V8V8aLaxUZo316i--ksZxc4-I1Q,6613
|
|
27
28
|
velocity/db/servers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
28
29
|
velocity/db/servers/mysql.py,sha256=qHwlB_Mg02R7QFjD5QvJCorYYiP50CqEiQyZVl3uYns,20914
|
|
@@ -48,8 +49,8 @@ velocity/misc/tools.py,sha256=_bGneHHA_BV-kUonzw5H3hdJ5AOJRCKfzhgpkFbGqIo,1502
|
|
|
48
49
|
velocity/misc/conv/__init__.py,sha256=MLYF58QHjzfDSxb1rdnmLnuEQCa3gnhzzZ30CwZVvQo,40
|
|
49
50
|
velocity/misc/conv/iconv.py,sha256=d4_BucW8HTIkGNurJ7GWrtuptqUf-9t79ObzjJ5N76U,10603
|
|
50
51
|
velocity/misc/conv/oconv.py,sha256=h5Lo05DqOQnxoD3y6Px_MQP_V-pBbWf8Hkgkb9Xp1jk,6032
|
|
51
|
-
velocity_python-0.0.
|
|
52
|
-
velocity_python-0.0.
|
|
53
|
-
velocity_python-0.0.
|
|
54
|
-
velocity_python-0.0.
|
|
55
|
-
velocity_python-0.0.
|
|
52
|
+
velocity_python-0.0.105.dist-info/licenses/LICENSE,sha256=aoN245GG8s9oRUU89KNiGTU4_4OtnNmVi4hQeChg6rM,1076
|
|
53
|
+
velocity_python-0.0.105.dist-info/METADATA,sha256=TEKdozOuF6TjYw8yBGI57YZpr_bvGWAufFkdcRscBlI,34262
|
|
54
|
+
velocity_python-0.0.105.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
55
|
+
velocity_python-0.0.105.dist-info/top_level.txt,sha256=JW2vJPmodgdgSz7H6yoZvnxF8S3fTMIv-YJWCT1sNW0,9
|
|
56
|
+
velocity_python-0.0.105.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|