velocity-python 0.0.30__py3-none-any.whl → 0.0.32__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 velocity-python might be problematic. Click here for more details.
- velocity/__init__.py +1 -1
- velocity/aws/__init__.py +4 -8
- velocity/aws/handlers/response.py +203 -52
- velocity/db/core/column.py +31 -20
- velocity/db/core/database.py +9 -9
- velocity/db/core/engine.py +101 -99
- velocity/db/core/exceptions.py +23 -0
- velocity/db/core/sequence.py +11 -3
- velocity/db/servers/mysql.py +580 -282
- velocity/db/servers/sqlite.py +649 -371
- velocity/db/servers/sqlserver.py +746 -331
- velocity/misc/conv/__init__.py +1 -1
- velocity/misc/conv/iconv.py +106 -143
- velocity/misc/conv/oconv.py +128 -162
- velocity/misc/db.py +25 -19
- velocity/misc/export.py +125 -115
- velocity/misc/format.py +57 -59
- velocity/misc/mail.py +51 -40
- velocity/misc/merge.py +34 -17
- velocity/misc/timer.py +35 -12
- {velocity_python-0.0.30.dist-info → velocity_python-0.0.32.dist-info}/METADATA +1 -1
- velocity_python-0.0.32.dist-info/RECORD +39 -0
- {velocity_python-0.0.30.dist-info → velocity_python-0.0.32.dist-info}/WHEEL +1 -1
- velocity_python-0.0.30.dist-info/RECORD +0 -39
- {velocity_python-0.0.30.dist-info → velocity_python-0.0.32.dist-info}/LICENSE +0 -0
- {velocity_python-0.0.30.dist-info → velocity_python-0.0.32.dist-info}/top_level.txt +0 -0
velocity/misc/db.py
CHANGED
|
@@ -4,13 +4,14 @@ import string
|
|
|
4
4
|
from functools import wraps
|
|
5
5
|
from velocity.db import exceptions
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
|
|
8
|
+
# TBD implement or delete all this code. It is not used anywhere.
|
|
8
9
|
def NotSupported(*args, **kwds):
|
|
9
10
|
raise Exception("Sorry, the driver for this database is not installed")
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
def NOTNULL(x):
|
|
13
|
-
"""
|
|
14
|
+
"""Helper function to filter out NULL values from keys/values functions"""
|
|
14
15
|
return len(x) == 2 and x[1] is not None
|
|
15
16
|
|
|
16
17
|
|
|
@@ -23,24 +24,23 @@ def pipe(func, primary, secondary, *args, **kwds):
|
|
|
23
24
|
class join(object):
|
|
24
25
|
@classmethod
|
|
25
26
|
def _or(cls, *args, **kwargs):
|
|
26
|
-
return
|
|
27
|
+
return "(" + " or ".join(cls._list(*args, **kwargs)) + ")"
|
|
27
28
|
|
|
28
29
|
@classmethod
|
|
29
30
|
def _and(cls, *args, **kwargs):
|
|
30
|
-
return
|
|
31
|
+
return "(" + " and ".join(cls._list(*args, **kwargs)) + ")"
|
|
31
32
|
|
|
32
33
|
@classmethod
|
|
33
34
|
def _list(cls, *args, **kwargs):
|
|
34
35
|
vals = []
|
|
35
36
|
vals.extend(args)
|
|
36
|
-
for key,val in kwargs.items():
|
|
37
|
+
for key, val in kwargs.items():
|
|
37
38
|
if isinstance(val, numbers.Number):
|
|
38
|
-
vals.append("{}={}".format(key,val))
|
|
39
|
+
vals.append("{}={}".format(key, val))
|
|
39
40
|
else:
|
|
40
|
-
vals.append("{}='{}'".format(key,val))
|
|
41
|
+
vals.append("{}='{}'".format(key, val))
|
|
41
42
|
return vals
|
|
42
43
|
|
|
43
|
-
|
|
44
44
|
|
|
45
45
|
def return_default(default=None):
|
|
46
46
|
"""
|
|
@@ -48,38 +48,44 @@ def return_default(default=None):
|
|
|
48
48
|
If an exception is raised within the function, the decorator
|
|
49
49
|
catches the exception and returns the default value instead.
|
|
50
50
|
"""
|
|
51
|
+
|
|
51
52
|
def decorator(f):
|
|
52
53
|
f.default = default
|
|
54
|
+
|
|
53
55
|
@wraps(f)
|
|
54
56
|
def return_default(self, *args, **kwds):
|
|
55
57
|
sp = self.tx.create_savepoint(cursor=self.table.cursor)
|
|
56
58
|
try:
|
|
57
59
|
result = f(self, *args, **kwds)
|
|
58
|
-
except (
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
60
|
+
except (
|
|
61
|
+
exceptions.DbApplicationError,
|
|
62
|
+
exceptions.DbTableMissingError,
|
|
63
|
+
exceptions.DbColumnMissingError,
|
|
64
|
+
exceptions.DbTruncationError,
|
|
65
|
+
StopIteration,
|
|
66
|
+
exceptions.DbObjectExistsError,
|
|
67
|
+
):
|
|
64
68
|
self.tx.rollback_savepoint(sp, cursor=self.table.cursor)
|
|
65
69
|
return f.default
|
|
66
70
|
self.tx.release_savepoint(sp, cursor=self.table.cursor)
|
|
67
71
|
return result
|
|
72
|
+
|
|
68
73
|
return return_default
|
|
74
|
+
|
|
69
75
|
return decorator
|
|
70
76
|
|
|
71
77
|
|
|
72
78
|
def randomword(length=None):
|
|
73
79
|
"""
|
|
74
80
|
Generate a random word consisting of lowercase letters. This is used to generate random savepoint names.
|
|
75
|
-
The length of the word can be specified, otherwise a random length between 5 and 15 will be used.
|
|
76
|
-
|
|
81
|
+
The length of the word can be specified, otherwise a random length between 5 and 15 will be used.
|
|
82
|
+
|
|
77
83
|
Parameters:
|
|
78
84
|
length (int, optional): The length of the random word. If not provided, a random length between 5 and 15 will be used.
|
|
79
|
-
|
|
85
|
+
|
|
80
86
|
Returns:
|
|
81
87
|
str: The randomly generated word.
|
|
82
88
|
"""
|
|
83
89
|
if length is None:
|
|
84
|
-
length = random.randint(5,15)
|
|
85
|
-
return
|
|
90
|
+
length = random.randint(5, 15)
|
|
91
|
+
return "".join(random.choice(string.ascii_lowercase) for i in range(length))
|
velocity/misc/export.py
CHANGED
|
@@ -1,147 +1,157 @@
|
|
|
1
|
+
import decimal
|
|
2
|
+
import json
|
|
3
|
+
from datetime import datetime, date, time, timedelta
|
|
4
|
+
from typing import Union, List, Dict
|
|
5
|
+
from io import BytesIO
|
|
6
|
+
import base64
|
|
1
7
|
import openpyxl
|
|
2
8
|
from openpyxl.styles import NamedStyle, Font, Border, Side, Alignment
|
|
3
9
|
from openpyxl.utils import get_column_letter
|
|
4
|
-
from io import BytesIO
|
|
5
|
-
import base64
|
|
6
10
|
|
|
7
|
-
|
|
8
|
-
|
|
11
|
+
|
|
12
|
+
def extract(d: dict, keys: List[str]) -> List:
|
|
13
|
+
"""Extract values from a dictionary based on a list of keys."""
|
|
14
|
+
return [d.get(key) for key in keys]
|
|
9
15
|
|
|
10
16
|
|
|
11
|
-
def autosize_columns(ws, fixed={}):
|
|
12
|
-
|
|
13
|
-
# content, font family and font size differences, etc.) There is no
|
|
14
|
-
# easy way to do this when buiding excel files.
|
|
17
|
+
def autosize_columns(ws, fixed: Dict[str, float] = {}):
|
|
18
|
+
"""Autosize columns in the worksheet based on content length."""
|
|
15
19
|
for col in ws.columns:
|
|
16
20
|
max_length = 0
|
|
17
21
|
for cell in col:
|
|
18
|
-
try:
|
|
19
|
-
if len(str(cell.value)) > max_length:
|
|
20
|
-
max_length = len(cell.value)
|
|
21
|
-
except:
|
|
22
|
-
|
|
22
|
+
try:
|
|
23
|
+
if cell.value and len(str(cell.value)) > max_length:
|
|
24
|
+
max_length = len(str(cell.value))
|
|
25
|
+
except Exception:
|
|
26
|
+
continue
|
|
23
27
|
adjusted_width = (max_length + 2) * 1.2
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
formats={},
|
|
42
|
-
named_styles=[],
|
|
43
|
-
freeze_panes='A2',
|
|
44
|
-
dimensions=None,
|
|
45
|
-
auto_size=True):
|
|
28
|
+
col_letter = get_column_letter(col[0].column)
|
|
29
|
+
ws.column_dimensions[col_letter].width = fixed.get(col_letter, adjusted_width)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def create_spreadsheet(
|
|
33
|
+
headers: List[str],
|
|
34
|
+
rows: List[List],
|
|
35
|
+
fileorbuffer,
|
|
36
|
+
styles: Dict[str, str] = {},
|
|
37
|
+
merge: List[str] = [],
|
|
38
|
+
formats: Dict[str, str] = {},
|
|
39
|
+
named_styles: List[NamedStyle] = [],
|
|
40
|
+
freeze_panes: str = "A2",
|
|
41
|
+
dimensions: dict = None,
|
|
42
|
+
auto_size: bool = True,
|
|
43
|
+
):
|
|
44
|
+
"""Create an Excel spreadsheet with specified headers, rows, and styles."""
|
|
46
45
|
wb = openpyxl.Workbook()
|
|
47
46
|
ws = wb.active
|
|
48
47
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
48
|
+
# Define default named styles
|
|
49
|
+
def get_named_styles():
|
|
50
|
+
named_styles = {
|
|
51
|
+
"col_header": NamedStyle(
|
|
52
|
+
name="col_header",
|
|
53
|
+
font=Font(bold=True),
|
|
54
|
+
border=Border(bottom=Side(style="medium", color="000000")),
|
|
55
|
+
),
|
|
56
|
+
"sum_total": NamedStyle(
|
|
57
|
+
name="sum_total",
|
|
58
|
+
border=Border(bottom=Side(style="double", color="000000")),
|
|
59
|
+
),
|
|
60
|
+
"sub_total": NamedStyle(
|
|
61
|
+
name="sub_total",
|
|
62
|
+
font=Font(bold=True),
|
|
63
|
+
border=Border(bottom=Side(style="thin", color="000000")),
|
|
64
|
+
),
|
|
65
|
+
"bold": NamedStyle(name="bold", font=Font(bold=True)),
|
|
66
|
+
"align_right": NamedStyle(
|
|
67
|
+
name="align_right",
|
|
68
|
+
font=Font(bold=True),
|
|
69
|
+
border=Border(top=Side(style="thin", color="000000")),
|
|
70
|
+
alignment=Alignment(horizontal="right", vertical="center"),
|
|
71
|
+
),
|
|
72
|
+
"align_left": NamedStyle(
|
|
73
|
+
name="align_left",
|
|
74
|
+
font=Font(bold=True),
|
|
75
|
+
border=Border(top=Side(style="thin", color="000000")),
|
|
76
|
+
alignment=Alignment(horizontal="left", vertical="center"),
|
|
77
|
+
),
|
|
78
|
+
"align_right_double": NamedStyle(
|
|
79
|
+
name="align_right_double",
|
|
80
|
+
font=Font(bold=True),
|
|
81
|
+
border=Border(top=Side(style="double", color="000000")),
|
|
82
|
+
alignment=Alignment(horizontal="right", vertical="center"),
|
|
83
|
+
),
|
|
84
|
+
"align_left_double": NamedStyle(
|
|
85
|
+
name="align_left_double",
|
|
86
|
+
font=Font(bold=True),
|
|
87
|
+
border=Border(top=Side(style="double", color="000000")),
|
|
88
|
+
alignment=Alignment(horizontal="left", vertical="center"),
|
|
89
|
+
),
|
|
90
|
+
}
|
|
91
|
+
return named_styles
|
|
92
|
+
|
|
93
|
+
# Add default and user-defined styles
|
|
94
|
+
local_styles = get_named_styles()
|
|
93
95
|
for style in named_styles:
|
|
94
96
|
local_styles[style.name] = style
|
|
95
|
-
|
|
96
97
|
for style in local_styles.values():
|
|
97
98
|
wb.add_named_style(style)
|
|
98
99
|
|
|
100
|
+
# Add headers and rows
|
|
99
101
|
ws.append(headers)
|
|
102
|
+
for row in rows:
|
|
103
|
+
ws.append(row)
|
|
100
104
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if freeze_panes:
|
|
104
|
-
ws.freeze_panes = freeze_panes
|
|
105
|
+
# Set freeze panes
|
|
106
|
+
ws.freeze_panes = freeze_panes
|
|
105
107
|
|
|
108
|
+
# Auto-size columns if enabled
|
|
106
109
|
if auto_size:
|
|
107
|
-
autosize_columns(ws)
|
|
110
|
+
autosize_columns(ws, fixed={})
|
|
108
111
|
|
|
112
|
+
# Set row and column dimensions if provided
|
|
109
113
|
if dimensions:
|
|
110
|
-
for key, val in dimensions.get(
|
|
114
|
+
for key, val in dimensions.get("rows", {}).items():
|
|
111
115
|
ws.row_dimensions[key].height = val
|
|
112
|
-
for key, val in dimensions.get(
|
|
116
|
+
for key, val in dimensions.get("columns", {}).items():
|
|
113
117
|
ws.column_dimensions[key].width = val
|
|
114
118
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
119
|
+
# Apply cell styles, merges, and formats
|
|
120
|
+
for cell, style_name in styles.items():
|
|
121
|
+
if style_name in local_styles:
|
|
122
|
+
ws[cell].style = local_styles[style_name]
|
|
118
123
|
for cell_range in merge:
|
|
119
124
|
ws.merge_cells(cell_range)
|
|
125
|
+
for cell, format_code in formats.items():
|
|
126
|
+
ws[cell].number_format = format_code
|
|
120
127
|
|
|
121
|
-
|
|
122
|
-
ws[cell].number_format = format
|
|
123
|
-
|
|
128
|
+
# Save workbook to the provided file or buffer
|
|
124
129
|
wb.save(fileorbuffer)
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
def
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def get_downloadable_spreadsheet(
|
|
133
|
+
headers: List[str],
|
|
134
|
+
rows: List[List],
|
|
135
|
+
styles: Dict[str, str] = {},
|
|
136
|
+
merge: List[str] = [],
|
|
137
|
+
formats: Dict[str, str] = {},
|
|
138
|
+
named_styles: List[NamedStyle] = [],
|
|
139
|
+
freeze_panes: str = "A2",
|
|
140
|
+
dimensions: dict = None,
|
|
141
|
+
auto_size: bool = True,
|
|
142
|
+
) -> str:
|
|
143
|
+
"""Generate a downloadable spreadsheet encoded in base64."""
|
|
144
|
+
buffer = BytesIO()
|
|
145
|
+
create_spreadsheet(
|
|
146
|
+
headers,
|
|
147
|
+
rows,
|
|
148
|
+
buffer,
|
|
149
|
+
styles,
|
|
150
|
+
merge,
|
|
151
|
+
formats,
|
|
152
|
+
named_styles,
|
|
153
|
+
freeze_panes,
|
|
154
|
+
dimensions,
|
|
155
|
+
auto_size,
|
|
156
|
+
)
|
|
147
157
|
return base64.b64encode(buffer.getvalue()).decode()
|
velocity/misc/format.py
CHANGED
|
@@ -1,81 +1,79 @@
|
|
|
1
1
|
import decimal
|
|
2
2
|
import json
|
|
3
3
|
from datetime import datetime, date, time, timedelta
|
|
4
|
+
from typing import Union
|
|
4
5
|
|
|
5
6
|
|
|
6
|
-
def gallons(data):
|
|
7
|
+
def gallons(data: Union[None, str, float, decimal.Decimal]) -> str:
|
|
8
|
+
"""Converts a value to a string formatted in gallons with two decimal places."""
|
|
7
9
|
if data is None:
|
|
8
|
-
return
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
return ""
|
|
11
|
+
try:
|
|
12
|
+
data = decimal.Decimal(data)
|
|
13
|
+
return f"{data:.2f}"
|
|
14
|
+
except (decimal.InvalidOperation, ValueError, TypeError):
|
|
15
|
+
return "" # Return an empty string for invalid input
|
|
12
16
|
|
|
13
17
|
|
|
14
|
-
def gallons2liters(data):
|
|
18
|
+
def gallons2liters(data: Union[None, str, float, decimal.Decimal]) -> str:
|
|
19
|
+
"""Converts gallons to liters and formats with two decimal places."""
|
|
15
20
|
if data is None:
|
|
16
|
-
return
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
21
|
+
return ""
|
|
22
|
+
try:
|
|
23
|
+
data = decimal.Decimal(data) * decimal.Decimal("3.78541")
|
|
24
|
+
return f"{data:.2f}"
|
|
25
|
+
except (decimal.InvalidOperation, ValueError, TypeError):
|
|
26
|
+
return ""
|
|
20
27
|
|
|
21
28
|
|
|
22
|
-
def currency(data):
|
|
29
|
+
def currency(data: Union[None, str, float, decimal.Decimal]) -> str:
|
|
30
|
+
"""Formats a value as currency with two decimal places."""
|
|
23
31
|
if data is None:
|
|
24
|
-
return
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
32
|
+
return ""
|
|
33
|
+
try:
|
|
34
|
+
data = decimal.Decimal(data)
|
|
35
|
+
return f"{data:.2f}"
|
|
36
|
+
except (decimal.InvalidOperation, ValueError, TypeError):
|
|
37
|
+
return ""
|
|
28
38
|
|
|
29
39
|
|
|
30
|
-
def human_delta(tdelta):
|
|
31
|
-
"""
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
print(human_delta(datetime(2014, 3, 30) - datetime.now()))
|
|
36
|
-
Example Results:
|
|
37
|
-
23 sec
|
|
38
|
-
12 min 45 sec
|
|
39
|
-
1 hr(s) 11 min 2 sec
|
|
40
|
-
3 day(s) 13 hr(s) 56 min 34 sec
|
|
41
|
-
:param tdelta: The timedelta object.
|
|
42
|
-
:return: The human formatted timedelta
|
|
43
|
-
"""
|
|
44
|
-
d = dict(days=tdelta.days)
|
|
45
|
-
d['hrs'], rem = divmod(tdelta.seconds, 3600)
|
|
46
|
-
d['min'], d['sec'] = divmod(rem, 60)
|
|
40
|
+
def human_delta(tdelta: timedelta) -> str:
|
|
41
|
+
"""Formats a timedelta object into a human-readable format."""
|
|
42
|
+
d = {"days": tdelta.days, "hrs": 0, "min": 0, "sec": 0}
|
|
43
|
+
d["hrs"], rem = divmod(tdelta.seconds, 3600)
|
|
44
|
+
d["min"], d["sec"] = divmod(rem, 60)
|
|
47
45
|
|
|
48
|
-
if d[
|
|
49
|
-
fmt =
|
|
50
|
-
elif d[
|
|
51
|
-
fmt =
|
|
52
|
-
elif d[
|
|
53
|
-
fmt =
|
|
46
|
+
if d["min"] == 0:
|
|
47
|
+
fmt = "{sec} sec"
|
|
48
|
+
elif d["hrs"] == 0:
|
|
49
|
+
fmt = "{min} min {sec} sec"
|
|
50
|
+
elif d["days"] == 0:
|
|
51
|
+
fmt = "{hrs} hr(s) {min} min {sec} sec"
|
|
54
52
|
else:
|
|
55
|
-
fmt =
|
|
53
|
+
fmt = "{days} day(s) {hrs} hr(s) {min} min {sec} sec"
|
|
56
54
|
|
|
57
55
|
return fmt.format(**d)
|
|
58
56
|
|
|
59
57
|
|
|
60
|
-
def to_json(o, datefmt="%Y-%m-%d", timefmt="%H:%M:%S"):
|
|
58
|
+
def to_json(o, datefmt: str = "%Y-%m-%d", timefmt: str = "%H:%M:%S") -> str:
|
|
59
|
+
"""Serializes an object to JSON, handling special types like Decimal and datetime."""
|
|
60
|
+
|
|
61
61
|
class JsonEncoder(json.JSONEncoder):
|
|
62
|
-
def default(self,
|
|
63
|
-
if hasattr(
|
|
64
|
-
return
|
|
65
|
-
elif
|
|
66
|
-
return
|
|
67
|
-
elif isinstance(
|
|
68
|
-
return float(
|
|
69
|
-
elif isinstance(
|
|
70
|
-
return
|
|
71
|
-
elif isinstance(
|
|
72
|
-
return
|
|
73
|
-
elif isinstance(
|
|
74
|
-
return
|
|
75
|
-
elif isinstance(
|
|
76
|
-
return human_delta(
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
except:
|
|
80
|
-
return str(o)
|
|
62
|
+
def default(self, obj):
|
|
63
|
+
if hasattr(obj, "to_json"):
|
|
64
|
+
return obj.to_json()
|
|
65
|
+
elif obj is None:
|
|
66
|
+
return None
|
|
67
|
+
elif isinstance(obj, decimal.Decimal):
|
|
68
|
+
return float(obj)
|
|
69
|
+
elif isinstance(obj, date):
|
|
70
|
+
return obj.strftime(datefmt)
|
|
71
|
+
elif isinstance(obj, datetime):
|
|
72
|
+
return obj.strftime(f"{datefmt} {timefmt}")
|
|
73
|
+
elif isinstance(obj, time):
|
|
74
|
+
return obj.strftime(timefmt)
|
|
75
|
+
elif isinstance(obj, timedelta):
|
|
76
|
+
return human_delta(obj)
|
|
77
|
+
return super().default(obj)
|
|
78
|
+
|
|
81
79
|
return json.dumps(o, cls=JsonEncoder)
|
velocity/misc/mail.py
CHANGED
|
@@ -1,67 +1,78 @@
|
|
|
1
1
|
#!/usr/local/bin/python
|
|
2
2
|
from email.parser import Parser as EmailParser
|
|
3
|
-
from
|
|
4
|
-
import
|
|
3
|
+
from email.message import Message
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
import mimetypes
|
|
6
|
+
import hashlib
|
|
7
|
+
|
|
5
8
|
|
|
6
9
|
class NotSupportedMailFormat(Exception):
|
|
10
|
+
"""Exception raised for unsupported mail formats."""
|
|
11
|
+
|
|
7
12
|
pass
|
|
8
13
|
|
|
9
|
-
|
|
14
|
+
|
|
15
|
+
class Attachment:
|
|
16
|
+
"""Represents an email attachment."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, name: str, data: bytes):
|
|
19
|
+
self.name = name
|
|
20
|
+
self.data = data
|
|
21
|
+
self.ctype = mimetypes.guess_type(name)[0] or "application/octet-stream"
|
|
22
|
+
self.size = len(data)
|
|
23
|
+
self.hash = hashlib.sha1(data).hexdigest()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_full_emails(addresses: List) -> List[str]:
|
|
27
|
+
"""Generates a list of formatted email addresses with names."""
|
|
10
28
|
results = []
|
|
11
29
|
for a in addresses:
|
|
12
|
-
|
|
13
|
-
|
|
30
|
+
mailbox = a.mailbox.decode("utf-8")
|
|
31
|
+
host = a.host.decode("utf-8")
|
|
32
|
+
name = a.name.decode("utf-8") if a.name else None
|
|
33
|
+
if name:
|
|
34
|
+
results.append(f"{name} <{mailbox}@{host}>")
|
|
14
35
|
else:
|
|
15
|
-
results.append(f"{
|
|
36
|
+
results.append(f"{mailbox}@{host}")
|
|
16
37
|
return results
|
|
17
38
|
|
|
18
|
-
def get_address_only(addresses):
|
|
19
|
-
results = []
|
|
20
|
-
for a in addresses:
|
|
21
|
-
results.append(f"{a.mailbox.decode('utf-8')}@{a.host.decode('utf-8')}")
|
|
22
|
-
return results
|
|
23
39
|
|
|
24
|
-
def
|
|
25
|
-
|
|
40
|
+
def get_address_only(addresses: List) -> List[str]:
|
|
41
|
+
"""Generates a list of email addresses without names."""
|
|
42
|
+
return [f"{a.mailbox.decode('utf-8')}@{a.host.decode('utf-8')}" for a in addresses]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parse_attachment(part: Message) -> Optional[Attachment]:
|
|
46
|
+
"""Parses an attachment from a message part if present."""
|
|
47
|
+
content_disposition = part.get("Content-Disposition")
|
|
26
48
|
if content_disposition:
|
|
27
49
|
dispositions = content_disposition.strip().split(";")
|
|
28
|
-
if
|
|
50
|
+
if dispositions[0].lower() == "attachment":
|
|
29
51
|
name = part.get_filename()
|
|
30
|
-
if not name:
|
|
31
|
-
return None
|
|
32
52
|
data = part.get_payload(decode=True)
|
|
33
|
-
if
|
|
34
|
-
return
|
|
35
|
-
attachment = Object()
|
|
36
|
-
attachment.data = data
|
|
37
|
-
attachment.ctype = mimetypes.guess_type(name)[0]
|
|
38
|
-
attachment.size = len(data)
|
|
39
|
-
attachment.name = name
|
|
40
|
-
attachment.hash = hashlib.sha1(data).hexdigest()
|
|
41
|
-
|
|
42
|
-
return attachment
|
|
43
|
-
|
|
53
|
+
if name and data:
|
|
54
|
+
return Attachment(name=name, data=data)
|
|
44
55
|
return None
|
|
45
56
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
57
|
+
|
|
58
|
+
def parse(content: str) -> dict:
|
|
59
|
+
"""Parses the email content and extracts plain text, HTML, and attachments."""
|
|
60
|
+
body = bytearray()
|
|
61
|
+
html = bytearray()
|
|
49
62
|
attachments = []
|
|
50
|
-
|
|
63
|
+
|
|
64
|
+
message = EmailParser().parsestr(content)
|
|
65
|
+
for part in message.walk():
|
|
51
66
|
attachment = parse_attachment(part)
|
|
52
67
|
if attachment:
|
|
53
68
|
attachments.append(attachment)
|
|
54
69
|
elif part.get_content_type() == "text/plain":
|
|
55
|
-
|
|
56
|
-
body = bytes()
|
|
57
|
-
body += part.get_payload(decode=True)
|
|
70
|
+
body.extend(part.get_payload(decode=True) or b"")
|
|
58
71
|
elif part.get_content_type() == "text/html":
|
|
59
|
-
|
|
60
|
-
html = bytes()
|
|
61
|
-
html += part.get_payload(decode=True)
|
|
72
|
+
html.extend(part.get_payload(decode=True) or b"")
|
|
62
73
|
|
|
63
74
|
return {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
75
|
+
"body": body.decode("utf-8") if body else None,
|
|
76
|
+
"html": html.decode("utf-8") if html else None,
|
|
77
|
+
"attachments": attachments,
|
|
67
78
|
}
|