pymailfeedback 0.1.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.
- pymailfeedback/__init__.py +3 -0
- pymailfeedback/core.py +480 -0
- pymailfeedback-0.1.0.dist-info/METADATA +171 -0
- pymailfeedback-0.1.0.dist-info/RECORD +7 -0
- pymailfeedback-0.1.0.dist-info/WHEEL +5 -0
- pymailfeedback-0.1.0.dist-info/entry_points.txt +2 -0
- pymailfeedback-0.1.0.dist-info/top_level.txt +1 -0
pymailfeedback/core.py
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import time
|
|
4
|
+
import json
|
|
5
|
+
import socket
|
|
6
|
+
import smtplib
|
|
7
|
+
import platform
|
|
8
|
+
import traceback
|
|
9
|
+
import getpass
|
|
10
|
+
import inspect
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from functools import wraps
|
|
13
|
+
from email.message import EmailMessage
|
|
14
|
+
import multiprocessing as mp
|
|
15
|
+
|
|
16
|
+
# Global configuration variables, lazily populated by _ensure_config_loaded()
|
|
17
|
+
_SENDER_EMAIL = ""
|
|
18
|
+
_SENDER_PASSWORD = ""
|
|
19
|
+
_SMTP_SERVER = "smtp.mail.yahoo.com"
|
|
20
|
+
_SMTP_PORT = 465
|
|
21
|
+
_DEFAULT_RECIPIENT = ""
|
|
22
|
+
_DEFAULT_VERBOSE = 0
|
|
23
|
+
_CONFIG_LOADED = False # NEW: tracks whether config has been loaded yet
|
|
24
|
+
|
|
25
|
+
_TIC_TIMES = []
|
|
26
|
+
_BEACON_NEXT_TIME = None
|
|
27
|
+
|
|
28
|
+
_TIC_TIMES = []
|
|
29
|
+
_BEACON_NEXT_TIME = None
|
|
30
|
+
|
|
31
|
+
_CONFIG_PATH_HOME = Path.home() / ".pymailfeedback.json"
|
|
32
|
+
_CONFIG_PATH_CWD = Path.cwd() / ".pymailfeedback.json"
|
|
33
|
+
|
|
34
|
+
_CONFIG_FACSIMILE = """{
|
|
35
|
+
"sender_email": "sender_email@gmail.com",
|
|
36
|
+
"sender_password": "sender__app_password",
|
|
37
|
+
"smtp_server": "smtp.mail.yahoo.com",
|
|
38
|
+
"smtp_port": 465,
|
|
39
|
+
"default_recipient": "recipient@example.com",
|
|
40
|
+
"default_verbose": 0
|
|
41
|
+
}"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# CONFIGURATION HANDLING
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
def _interactive_setup():
|
|
49
|
+
"""Guides the user interactively to create a configuration file."""
|
|
50
|
+
print("\n" + "=" * 50)
|
|
51
|
+
print(" pymailfeedback - First Time Setup")
|
|
52
|
+
print("=" * 50)
|
|
53
|
+
print("Let's create a configuration file now.")
|
|
54
|
+
print(f"It will be saved to: {_CONFIG_PATH_HOME.resolve()}\n")
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
email = input("Sender Email (e.g., myemail@gmail.com): ").strip()
|
|
58
|
+
password = input("Sender Password (e.g., App Password): ").strip()
|
|
59
|
+
server = input("SMTP Server [press Enter for 'smtp.mail.yahoo.com']: ").strip() or "smtp.mail.yahoo.com"
|
|
60
|
+
port_str = input("SMTP Port [press Enter for '465']: ").strip() or "465"
|
|
61
|
+
port = int(port_str)
|
|
62
|
+
default_recipient = input("Default recipient email (optional, press Enter to skip): ").strip()
|
|
63
|
+
verbose_str = input("Default verbose level [0, 1, 2] (press Enter for '0'): ").strip() or "0"
|
|
64
|
+
default_verbose = int(verbose_str)
|
|
65
|
+
except (KeyboardInterrupt, EOFError):
|
|
66
|
+
print("\nSetup cancelled by user.", file=sys.stderr)
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
if not email or not password:
|
|
70
|
+
print("\nError: Email and password are required. Setup aborted.", file=sys.stderr)
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
config_data = {
|
|
74
|
+
"sender_email": email,
|
|
75
|
+
"sender_password": password,
|
|
76
|
+
"smtp_server": server,
|
|
77
|
+
"smtp_port": port,
|
|
78
|
+
"default_recipient": default_recipient,
|
|
79
|
+
"default_verbose": default_verbose,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
with open(_CONFIG_PATH_HOME, 'w', encoding='utf-8') as f:
|
|
84
|
+
json.dump(config_data, f, indent=4)
|
|
85
|
+
print(f"\nSuccess! Configuration saved to: {_CONFIG_PATH_HOME.resolve()}")
|
|
86
|
+
print("=" * 50 + "\n")
|
|
87
|
+
|
|
88
|
+
global _SENDER_EMAIL, _SENDER_PASSWORD, _SMTP_SERVER, _SMTP_PORT, _DEFAULT_RECIPIENT, _DEFAULT_VERBOSE
|
|
89
|
+
_SENDER_EMAIL = email
|
|
90
|
+
_SENDER_PASSWORD = password
|
|
91
|
+
_SMTP_SERVER = server
|
|
92
|
+
_SMTP_PORT = port
|
|
93
|
+
_DEFAULT_RECIPIENT = default_recipient
|
|
94
|
+
_DEFAULT_VERBOSE = default_verbose
|
|
95
|
+
return True
|
|
96
|
+
except Exception as e:
|
|
97
|
+
print(f"\nFailed to save configuration: {e}", file=sys.stderr)
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _raise_missing_config_error():
|
|
102
|
+
"""Raises a descriptive error explaining how to fix a missing configuration."""
|
|
103
|
+
raise RuntimeError(
|
|
104
|
+
"\n\nNo pymailfeedback configuration found.\n"
|
|
105
|
+
"You have two options:\n\n"
|
|
106
|
+
"1) Create the file manually at one of these locations:\n"
|
|
107
|
+
f" - {_CONFIG_PATH_CWD.resolve()}\n"
|
|
108
|
+
f" - {_CONFIG_PATH_HOME.resolve()}\n\n"
|
|
109
|
+
" Using this template:\n"
|
|
110
|
+
f"{_CONFIG_FACSIMILE}\n\n"
|
|
111
|
+
"2) Run the interactive setup wizard:\n"
|
|
112
|
+
" python -c \"from pymailfeedback.core import _interactive_setup; _interactive_setup()\"\n"
|
|
113
|
+
" (or use the 'pymailfeedback-init' command if installed as a CLI entry point)\n"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def _is_main_process():
|
|
117
|
+
"""Returns True only if running in the main process (not a DataLoader worker
|
|
118
|
+
or any other multiprocessing child process)."""
|
|
119
|
+
return mp.current_process().name == "MainProcess"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _load_config():
|
|
123
|
+
"""
|
|
124
|
+
Loads SMTP configuration. Order of precedence:
|
|
125
|
+
1. Environment variables
|
|
126
|
+
2. Local directory JSON file (./.pymailfeedback.json)
|
|
127
|
+
3. User home directory JSON file (~/.pymailfeedback.json)
|
|
128
|
+
4. Interactive setup (only in an interactive terminal)
|
|
129
|
+
If nothing is found, raises a RuntimeError explaining how to fix it.
|
|
130
|
+
"""
|
|
131
|
+
global _SENDER_EMAIL, _SENDER_PASSWORD, _SMTP_SERVER, _SMTP_PORT, _DEFAULT_RECIPIENT, _DEFAULT_VERBOSE
|
|
132
|
+
|
|
133
|
+
env_email = os.getenv("PYMAIL_SENDER_EMAIL")
|
|
134
|
+
env_pwd = os.getenv("PYMAIL_SENDER_PASSWORD")
|
|
135
|
+
if env_email and env_pwd:
|
|
136
|
+
if _is_main_process():
|
|
137
|
+
print("[pymailfeedback] Using email configuration from environment variables.", file=sys.stderr)
|
|
138
|
+
_SENDER_EMAIL = env_email
|
|
139
|
+
_SENDER_PASSWORD = env_pwd
|
|
140
|
+
_SMTP_SERVER = os.getenv("PYMAIL_SMTP_SERVER", _SMTP_SERVER)
|
|
141
|
+
_SMTP_PORT = int(os.getenv("PYMAIL_SMTP_PORT", _SMTP_PORT))
|
|
142
|
+
_DEFAULT_RECIPIENT = os.getenv("PYMAIL_DEFAULT_RECIPIENT", _DEFAULT_RECIPIENT)
|
|
143
|
+
_DEFAULT_VERBOSE = int(os.getenv("PYMAIL_DEFAULT_VERBOSE", _DEFAULT_VERBOSE))
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
for config_path in (_CONFIG_PATH_CWD, _CONFIG_PATH_HOME):
|
|
147
|
+
if config_path.is_file():
|
|
148
|
+
try:
|
|
149
|
+
with open(config_path, 'r', encoding='utf-8') as f:
|
|
150
|
+
config = json.load(f)
|
|
151
|
+
_SENDER_EMAIL = config.get("sender_email", _SENDER_EMAIL)
|
|
152
|
+
_SENDER_PASSWORD = config.get("sender_password", _SENDER_PASSWORD)
|
|
153
|
+
_SMTP_SERVER = config.get("smtp_server", _SMTP_SERVER)
|
|
154
|
+
_SMTP_PORT = config.get("smtp_port", _SMTP_PORT)
|
|
155
|
+
_DEFAULT_RECIPIENT = config.get("default_recipient", _DEFAULT_RECIPIENT)
|
|
156
|
+
_DEFAULT_VERBOSE = config.get("default_verbose", _DEFAULT_VERBOSE)
|
|
157
|
+
if _is_main_process():
|
|
158
|
+
print(f"[pymailfeedback] Loaded email configuration from {config_path.resolve()}.", file=sys.stderr)
|
|
159
|
+
return
|
|
160
|
+
except Exception as e:
|
|
161
|
+
print(f"Warning: Failed to read config file {config_path}: {e}", file=sys.stderr)
|
|
162
|
+
|
|
163
|
+
# Attempt interactive setup directly instead of relying on isatty().
|
|
164
|
+
# isatty() reports False in many IDE run consoles (e.g. PyCharm's Run
|
|
165
|
+
# window), even though input() still works fine there. We try the
|
|
166
|
+
# actual input() call and only give up on a real EOFError/OSError,
|
|
167
|
+
# which happens in truly non-interactive contexts (cron, CI, piped stdin).
|
|
168
|
+
try:
|
|
169
|
+
response = input("[pymailfeedback] No email configuration found. Setup now? (y/n): ").strip().lower()
|
|
170
|
+
except (EOFError, OSError):
|
|
171
|
+
_raise_missing_config_error()
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
if response == 'y' and _interactive_setup():
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
_raise_missing_config_error()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _ensure_config_loaded():
|
|
181
|
+
"""
|
|
182
|
+
Lazily loads the configuration on first actual use.
|
|
183
|
+
This avoids raising an error just because the module was imported
|
|
184
|
+
(e.g. to call _interactive_setup() manually).
|
|
185
|
+
"""
|
|
186
|
+
global _CONFIG_LOADED
|
|
187
|
+
if not _CONFIG_LOADED:
|
|
188
|
+
_load_config()
|
|
189
|
+
_CONFIG_LOADED = True
|
|
190
|
+
|
|
191
|
+
def _resolve_recipient(to_addresses):
|
|
192
|
+
"""Resolves the recipient list, falling back to the default recipient if none provided."""
|
|
193
|
+
if to_addresses:
|
|
194
|
+
return to_addresses
|
|
195
|
+
if _DEFAULT_RECIPIENT:
|
|
196
|
+
return _DEFAULT_RECIPIENT
|
|
197
|
+
raise ValueError(
|
|
198
|
+
"No recipient specified and no default_recipient configured. "
|
|
199
|
+
"Pass a recipient explicitly or set 'default_recipient' in your .pymailfeedback.json."
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
# UTILITIES
|
|
205
|
+
# ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
def _plural(n):
|
|
208
|
+
return "" if n == 1 else "s"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def readsec(t):
|
|
212
|
+
"""Converts seconds into a readable format (DAY:HH:MM:SS.mmm)."""
|
|
213
|
+
days = int(t // (24 * 3600))
|
|
214
|
+
t %= (24 * 3600)
|
|
215
|
+
hours = int(t // 3600)
|
|
216
|
+
t %= 3600
|
|
217
|
+
mins = int(t // 60)
|
|
218
|
+
sec = t % 60
|
|
219
|
+
|
|
220
|
+
time_string = ""
|
|
221
|
+
if days > 0:
|
|
222
|
+
time_string += f"{days} day{_plural(days)}, "
|
|
223
|
+
if hours > 0:
|
|
224
|
+
time_string += f"{hours} hour{_plural(hours)}, "
|
|
225
|
+
if mins > 0:
|
|
226
|
+
time_string += f"{mins} min and "
|
|
227
|
+
|
|
228
|
+
time_string += f"{sec:.3f} s"
|
|
229
|
+
return time_string
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _shutdown_computer():
|
|
233
|
+
"""Executes the appropriate system shutdown command based on the OS."""
|
|
234
|
+
system = platform.system()
|
|
235
|
+
try:
|
|
236
|
+
if system == "Windows":
|
|
237
|
+
os.system("shutdown /s /t 0")
|
|
238
|
+
elif system == "Linux":
|
|
239
|
+
os.system("sudo shutdown -h now")
|
|
240
|
+
elif system == "Darwin":
|
|
241
|
+
os.system("""osascript -e 'tell app "System Events" to shut down'""")
|
|
242
|
+
else:
|
|
243
|
+
print(f"Shutdown not supported for {system}.", file=sys.stderr)
|
|
244
|
+
except Exception as e:
|
|
245
|
+
print(f"Error during shutdown: {e}", file=sys.stderr)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _handle_shutdown(shutdown, shutdown_delay):
|
|
249
|
+
"""Shuts down the machine after shutdown_delay seconds if shutdown is True."""
|
|
250
|
+
if shutdown:
|
|
251
|
+
print(f"[pymailfeedback] System will shut down in {shutdown_delay} seconds...")
|
|
252
|
+
time.sleep(shutdown_delay)
|
|
253
|
+
_shutdown_computer()
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ---------------------------------------------------------------------------
|
|
257
|
+
# HTML EMAIL BUILDER
|
|
258
|
+
# ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
def _build_html_body(title_color, content_lines, footer_lines):
|
|
261
|
+
"""Builds a styled HTML email body (no leading title, subject already states it)."""
|
|
262
|
+
content_html = "".join(f"<p style='margin:4px 0;'>{line}</p>" for line in content_lines)
|
|
263
|
+
footer_html = "".join(f"<p style='margin:2px 0; color:#888; font-size:12px;'>{line}</p>" for line in footer_lines)
|
|
264
|
+
|
|
265
|
+
html = f"""\
|
|
266
|
+
<html>
|
|
267
|
+
<body style="font-family: Arial, sans-serif; color: #333;">
|
|
268
|
+
<div style="border-left: 4px solid {title_color}; padding-left: 12px; margin: 12px 0;">
|
|
269
|
+
{content_html}
|
|
270
|
+
</div>
|
|
271
|
+
<hr style="border:none; border-top:1px solid #ddd; margin: 16px 0;">
|
|
272
|
+
<div>
|
|
273
|
+
{footer_html}
|
|
274
|
+
<p style="margin:2px 0; font-size:12px; color:#aaa;">Generated automatically by <b>pymailfeedback</b></p>
|
|
275
|
+
</div>
|
|
276
|
+
</body>
|
|
277
|
+
</html>
|
|
278
|
+
"""
|
|
279
|
+
return html
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _build_plain_body(content_lines, footer_lines):
|
|
283
|
+
"""Builds the plain-text fallback body (no leading title)."""
|
|
284
|
+
lines = content_lines + [""] + footer_lines + ["", "Generated automatically by pymailfeedback"]
|
|
285
|
+
return "\n".join(lines)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# ---------------------------------------------------------------------------
|
|
289
|
+
# CORE MAIL FUNCTIONS
|
|
290
|
+
# ---------------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
def sendmsg(to_addresses=None, subject="", message="", attachments=None, html_body=None):
|
|
293
|
+
"""Sends an email with a subject, body message, and optional attachments."""
|
|
294
|
+
_ensure_config_loaded()
|
|
295
|
+
to_addresses = _resolve_recipient(to_addresses)
|
|
296
|
+
|
|
297
|
+
if not _SENDER_EMAIL or not _SENDER_PASSWORD:
|
|
298
|
+
print("Warning: Email credentials not configured. Skipping email.", file=sys.stderr)
|
|
299
|
+
return
|
|
300
|
+
|
|
301
|
+
msg = EmailMessage()
|
|
302
|
+
msg['Subject'] = subject
|
|
303
|
+
msg['From'] = _SENDER_EMAIL
|
|
304
|
+
msg['To'] = ", ".join(to_addresses) if isinstance(to_addresses, list) else to_addresses
|
|
305
|
+
msg.set_content(message)
|
|
306
|
+
|
|
307
|
+
if html_body:
|
|
308
|
+
msg.add_alternative(html_body, subtype='html')
|
|
309
|
+
|
|
310
|
+
if attachments:
|
|
311
|
+
if isinstance(attachments, str):
|
|
312
|
+
attachments = [attachments]
|
|
313
|
+
for filepath in attachments:
|
|
314
|
+
if os.path.exists(filepath):
|
|
315
|
+
with open(filepath, 'rb') as f:
|
|
316
|
+
file_data = f.read()
|
|
317
|
+
file_name = os.path.basename(filepath)
|
|
318
|
+
msg.add_attachment(file_data, maintype='application',
|
|
319
|
+
subtype='octet-stream', filename=file_name)
|
|
320
|
+
|
|
321
|
+
try:
|
|
322
|
+
if _SMTP_PORT == 465:
|
|
323
|
+
with smtplib.SMTP_SSL(_SMTP_SERVER, _SMTP_PORT) as server:
|
|
324
|
+
server.login(_SENDER_EMAIL, _SENDER_PASSWORD)
|
|
325
|
+
server.send_message(msg)
|
|
326
|
+
else:
|
|
327
|
+
with smtplib.SMTP(_SMTP_SERVER, _SMTP_PORT) as server:
|
|
328
|
+
server.starttls()
|
|
329
|
+
server.login(_SENDER_EMAIL, _SENDER_PASSWORD)
|
|
330
|
+
server.send_message(msg)
|
|
331
|
+
except Exception as e:
|
|
332
|
+
print(f"Failed to send email: {e}", file=sys.stderr)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def sendstatus(to_addresses=None, verbose=None, shutdown=False, shutdown_delay=60):
|
|
336
|
+
"""
|
|
337
|
+
Decorator to send the execution status via email.
|
|
338
|
+
Wraps the decorated function in a try/except block.
|
|
339
|
+
|
|
340
|
+
verbose = None: uses the default_verbose from configuration (falls back to 0)
|
|
341
|
+
verbose = 0: No attachments
|
|
342
|
+
verbose = 1: Attaches the file that caused the error
|
|
343
|
+
verbose = 2: Attaches the entire error stack trace files
|
|
344
|
+
|
|
345
|
+
shutdown: if True, shuts down the machine after the run completes (success or failure).
|
|
346
|
+
shutdown_delay: seconds to wait before shutting down the machine.
|
|
347
|
+
"""
|
|
348
|
+
_ensure_config_loaded()
|
|
349
|
+
if verbose is None:
|
|
350
|
+
verbose = _DEFAULT_VERBOSE
|
|
351
|
+
|
|
352
|
+
# Summary of the decorator's configuration (recepient, verbosity, shutdown behavior)
|
|
353
|
+
if _is_main_process():
|
|
354
|
+
print(f"[pymailfeedback] sendstatus decorator configured with recipient(s): {to_addresses or _DEFAULT_RECIPIENT}, "
|
|
355
|
+
f"shutdown: {shutdown}, shutdown_delay: {shutdown_delay} seconds.", file=sys.stderr)
|
|
356
|
+
|
|
357
|
+
def decorator(func):
|
|
358
|
+
@wraps(func)
|
|
359
|
+
def wrapper(*args, **kwargs):
|
|
360
|
+
global _TIC_TIMES
|
|
361
|
+
start_time = time.time()
|
|
362
|
+
_TIC_TIMES.append(start_time)
|
|
363
|
+
|
|
364
|
+
try:
|
|
365
|
+
username = getpass.getuser()
|
|
366
|
+
computername = socket.gethostname()
|
|
367
|
+
except Exception:
|
|
368
|
+
username, computername = "Unknown", "Unknown"
|
|
369
|
+
|
|
370
|
+
func_name = func.__name__
|
|
371
|
+
try:
|
|
372
|
+
func_file = inspect.getsourcefile(func)
|
|
373
|
+
file_label = os.path.basename(func_file) if func_file else "unknown_file"
|
|
374
|
+
except TypeError:
|
|
375
|
+
func_file = "Unknown path"
|
|
376
|
+
file_label = "unknown_file"
|
|
377
|
+
|
|
378
|
+
exit_status = 0
|
|
379
|
+
error_traceback = ""
|
|
380
|
+
try:
|
|
381
|
+
result = func(*args, **kwargs)
|
|
382
|
+
return result
|
|
383
|
+
except Exception:
|
|
384
|
+
exit_status = 1
|
|
385
|
+
error_traceback = traceback.format_exc()
|
|
386
|
+
raise
|
|
387
|
+
finally:
|
|
388
|
+
elapsed = time.time() - start_time
|
|
389
|
+
elapsed_str = readsec(elapsed)
|
|
390
|
+
|
|
391
|
+
if exit_status == 1:
|
|
392
|
+
subject = f"[{file_label}] {func_name} — ❌ FAILURE"
|
|
393
|
+
title_color = "#d32f2f"
|
|
394
|
+
content_lines = [
|
|
395
|
+
f"<b>Dear {username},</b>",
|
|
396
|
+
f"Function <b>{func_name}</b> in <b>{file_label}</b> has failed with the following error:",
|
|
397
|
+
f"<pre style='color:#d32f2f; font-weight:bold; background:#fdecea; padding:8px; border-radius:4px; white-space:pre-wrap;'>{error_traceback}</pre>",
|
|
398
|
+
]
|
|
399
|
+
plain_content = [
|
|
400
|
+
f"Dear {username},",
|
|
401
|
+
f"Function {func_name} in {file_label} failed with the following error:",
|
|
402
|
+
error_traceback,
|
|
403
|
+
]
|
|
404
|
+
else:
|
|
405
|
+
subject = f"[{file_label}] {func_name} — ✅ SUCCESS"
|
|
406
|
+
title_color = "#2e7d32"
|
|
407
|
+
content_lines = [
|
|
408
|
+
f"<b>Dear {username},</b>",
|
|
409
|
+
f"Function <b>{func_name}</b> in <b>{file_label}</b> completed successfully.",
|
|
410
|
+
]
|
|
411
|
+
plain_content = [
|
|
412
|
+
f"Dear {username},",
|
|
413
|
+
f"Function {func_name} in {file_label} completed successfully.",
|
|
414
|
+
]
|
|
415
|
+
|
|
416
|
+
footer_lines = [
|
|
417
|
+
f"Machine: {computername}",
|
|
418
|
+
f"Full path: {func_file}",
|
|
419
|
+
f"Elapsed time: {elapsed_str}",
|
|
420
|
+
f"Verbose level: {verbose}",
|
|
421
|
+
]
|
|
422
|
+
|
|
423
|
+
html_body = _build_html_body(title_color, content_lines, footer_lines)
|
|
424
|
+
plain_body = _build_plain_body(plain_content, footer_lines)
|
|
425
|
+
|
|
426
|
+
attachments = []
|
|
427
|
+
if exit_status == 1 and verbose > 0:
|
|
428
|
+
_, _, tb = sys.exc_info()
|
|
429
|
+
extracted_tb = traceback.extract_tb(tb)
|
|
430
|
+
|
|
431
|
+
if verbose >= 1 and func_file and os.path.exists(func_file):
|
|
432
|
+
attachments.append(func_file)
|
|
433
|
+
|
|
434
|
+
if verbose >= 2:
|
|
435
|
+
module_file = Path(__file__).resolve()
|
|
436
|
+
for frame in extracted_tb:
|
|
437
|
+
if Path(frame.filename) == module_file: # exclude this file
|
|
438
|
+
continue
|
|
439
|
+
if os.path.exists(frame.filename) and frame.filename not in attachments:
|
|
440
|
+
attachments.append(frame.filename)
|
|
441
|
+
|
|
442
|
+
sendmsg(to_addresses, subject, plain_body, attachments, html_body=html_body)
|
|
443
|
+
|
|
444
|
+
_handle_shutdown(shutdown, shutdown_delay)
|
|
445
|
+
|
|
446
|
+
return wrapper
|
|
447
|
+
return decorator
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def sendbeacon(to_addresses=None, delta_time_minutes=60):
|
|
451
|
+
"""Sends a periodic 'alive' email if delta_time_minutes has passed since the last beacon."""
|
|
452
|
+
_ensure_config_loaded()
|
|
453
|
+
global _BEACON_NEXT_TIME
|
|
454
|
+
current_time = time.time()
|
|
455
|
+
|
|
456
|
+
if _BEACON_NEXT_TIME is None:
|
|
457
|
+
_BEACON_NEXT_TIME = current_time + (delta_time_minutes * 60)
|
|
458
|
+
return
|
|
459
|
+
|
|
460
|
+
if current_time >= _BEACON_NEXT_TIME:
|
|
461
|
+
to_addresses = _resolve_recipient(to_addresses)
|
|
462
|
+
try:
|
|
463
|
+
computername = socket.gethostname()
|
|
464
|
+
except Exception:
|
|
465
|
+
computername = "Unknown"
|
|
466
|
+
|
|
467
|
+
subject = f"[{computername}] beacon — 🟢 Still Running"
|
|
468
|
+
content_lines = [
|
|
469
|
+
"<b>So far so good!</b>",
|
|
470
|
+
f"Beacon signal generated every {readsec(delta_time_minutes * 60)}."
|
|
471
|
+
]
|
|
472
|
+
html_body = _build_html_body("#2e7d32", content_lines, [f"Machine: {computername}"])
|
|
473
|
+
plain_body = _build_plain_body(
|
|
474
|
+
["So far so good!", f"Beacon signal generated every {readsec(delta_time_minutes * 60)}"],
|
|
475
|
+
[f"Machine: {computername}"]
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
sendmsg(to_addresses, subject, plain_body, html_body=html_body)
|
|
479
|
+
|
|
480
|
+
_BEACON_NEXT_TIME = current_time + (delta_time_minutes * 60)
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pymailfeedback
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Email yourself automatically when a long-running Python script succeeds or fails — with traceback, attachments, and more
|
|
5
|
+
Author-email: Daniele Mascali <danielemascali@gmail.com>
|
|
6
|
+
Project-URL: Homepage, https://github.com/dmascali/pymailfeedback
|
|
7
|
+
Project-URL: Repository, https://github.com/dmascali/pymailfeedback
|
|
8
|
+
Project-URL: Issues, https://github.com/dmascali/pymailfeedback/issues
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Requires-Python: >=3.7
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# pymailfeedback
|
|
16
|
+
|
|
17
|
+
**Never wonder if your long-running script finished or crashed.**
|
|
18
|
+
|
|
19
|
+
**pymailfeedback** is Python decorator that emails you the moment your script finishes, whether it succeeded or crashed. Get instant notifications with full tracebacks on failure and optional file attachments.
|
|
20
|
+
|
|
21
|
+
<div>
|
|
22
|
+
<img src="https://raw.githubusercontent.com/dmascali/pymailfeedback/refs/heads/master/assets/example_success_msg.png" alt="Example Success" width="75%" />
|
|
23
|
+
</div>
|
|
24
|
+
<br />
|
|
25
|
+
<div>
|
|
26
|
+
<img src="https://raw.githubusercontent.com/dmascali/pymailfeedback/refs/heads/master/assets/example_failure_msg.png" alt="Example Failure" width="75%" />
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
This project is a Python port of [MatlabMailFeedback](https://github.com/dmascali/MatlabMailFeedback). `pymailfeedback` reproduces the same core idea — wrapping a script/function in a try/except block to report its exit status by email.
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install pymailfeedback
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Quick start
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from pymailfeedback import sendstatus
|
|
43
|
+
|
|
44
|
+
@sendstatus("recipient@example.com")
|
|
45
|
+
def train_model():
|
|
46
|
+
# your long-running code here
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
train_model()
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
That's it. If `train_model()` finishes normally, you get a ✅ SUCCESS email. If it raises an exception, you get a ❌ FAILURE email with the full traceback — and the exception is still re-raised, so your program behaves exactly as if the decorator wasn't there.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## `sendstatus` — the core feature
|
|
57
|
+
|
|
58
|
+
`sendstatus` is a **decorator**. Place it directly above the function or script entry point you want to monitor:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
@sendstatus(to_addresses=None, verbose=None, shutdown=False, shutdown_delay=60)
|
|
62
|
+
def my_function(...):
|
|
63
|
+
...
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
| Parameter | Description |
|
|
67
|
+
|---|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|
68
|
+
| `to_addresses` | Recipient email address, or a list of addresses. If omitted, the `default_recipient` from your configuration is used. |
|
|
69
|
+
| `verbose` | Controls attachments sent on failure: `0` = no attachment, `1` = attach the file that caused the error, `2` = attach the entire traceback's file stack. If omitted, uses `default_verbose` from your configuration (default: `0`). |
|
|
70
|
+
| `shutdown` | If `True`, shuts down the machine after the run completes, regardless of success or failure. |
|
|
71
|
+
| `shutdown_delay` | Seconds to wait before shutting down (default 60 seconds). |
|
|
72
|
+
|
|
73
|
+
## Configuration
|
|
74
|
+
|
|
75
|
+
`pymailfeedback` needs SMTP credentials (sender email + password) to actually send mail.
|
|
76
|
+
There are three ways to configure it, checked in this order (note: you probably want to go with option 2 or 3):
|
|
77
|
+
|
|
78
|
+
### 1. Environment variables
|
|
79
|
+
|
|
80
|
+
| Variable | Maps to | Required |
|
|
81
|
+
|---|--------------------------------------------------|---|
|
|
82
|
+
| `PYMAIL_SENDER_EMAIL` | sender email | Yes |
|
|
83
|
+
| `PYMAIL_SENDER_PASSWORD` | sender password (e.g. a Yahoo Mail App Password) | Yes |
|
|
84
|
+
| `PYMAIL_SMTP_SERVER` | SMTP server | No (default: `smtp.mail.yahoo.com`) |
|
|
85
|
+
| `PYMAIL_SMTP_PORT` | SMTP port | No (default: `465`) |
|
|
86
|
+
| `PYMAIL_DEFAULT_RECIPIENT` | default recipient if none is passed explicitly | No |
|
|
87
|
+
| `PYMAIL_DEFAULT_VERBOSE` | default verbose level | No (default: `0`) |
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
export PYMAIL_SENDER_EMAIL="you@gmail.com"
|
|
91
|
+
export PYMAIL_SENDER_PASSWORD="your_app_password"
|
|
92
|
+
export PYMAIL_DEFAULT_RECIPIENT="you@example.com"
|
|
93
|
+
python train.py
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
If both `PYMAIL_SENDER_EMAIL` and `PYMAIL_SENDER_PASSWORD` are set, environment variables take priority over any JSON config file.
|
|
97
|
+
|
|
98
|
+
### 2. A JSON configuration file (recommended)
|
|
99
|
+
|
|
100
|
+
Create a file named `.pymailfeedback.json`, either in your current working directory or in your home directory (checked in that order):
|
|
101
|
+
|
|
102
|
+
```json
|
|
103
|
+
{
|
|
104
|
+
"sender_email": "you@gmail.com",
|
|
105
|
+
"sender_password": "your_app_password",
|
|
106
|
+
"smtp_server": "smtp.mail.yahoo.com",
|
|
107
|
+
"smtp_port": 465,
|
|
108
|
+
"default_recipient": "you@example.com",
|
|
109
|
+
"default_verbose": 0
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### 3. Interactive setup wizard
|
|
114
|
+
|
|
115
|
+
If no environment variables or config file are found, and you're running in an interactive terminal, `pymailfeedback` will offer to walk you through creating `~/.pymailfeedback.json` on the spot. You can also trigger this manually at any time:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
python -c "from pymailfeedback.core import _interactive_setup; _interactive_setup()"
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
It will prompt you for the sender email, password, SMTP server/port, default recipient, and default verbose level, then print exactly where the file was saved.
|
|
122
|
+
|
|
123
|
+
### No configuration found?
|
|
124
|
+
|
|
125
|
+
If configuration is missing and no interactive terminal is available (e.g. inside a script run non-interactively), `pymailfeedback` raises a `RuntimeError` with a ready-to-copy JSON template and the command to launch the setup wizard.
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## A note on security: don't use your personal email
|
|
130
|
+
|
|
131
|
+
Whichever configuration method you choose, the sender password ends up stored in plain text somewhere on your machine — in a JSON file, in an environment variable, or in your shell history. That's fine for a throwaway or dedicated address, but it is **not wise** to use your personal, everyday email account for this. If that password (or app password) ever leaks — through a shared server, a committed config file, a misconfigured Docker image — you don't want it to be the same account tied to your personal identity, contacts, and other services.
|
|
132
|
+
|
|
133
|
+
**Best practice: create a dedicated, "burner" email account used only for sending these notifications.** [Yahoo Mail](https://mail.yahoo.com) is a good, free choice for this. Once created, don't use its regular password in `pymailfeedback` — generate a dedicated **App Password** instead, which can be revoked independently at any time without affecting the account's main login:
|
|
134
|
+
|
|
135
|
+
1. Sign in to your new Yahoo account and go to **Account settings**.
|
|
136
|
+
2. Open **External Connections** (sometimes shown as "App passwords" depending on the region/UI).
|
|
137
|
+
3. Select **Create app password** (a generic label like "python" or "pymailfeedback" is fine).
|
|
138
|
+
4. Copy the generated password and use it as `sender_password` — either in your `.pymailfeedback.json` file or as the `PYMAIL_SENDER_PASSWORD` environment variable.
|
|
139
|
+
5. Set `smtp_server` to `smtp.mail.yahoo.com` when using a Yahoo account.
|
|
140
|
+
|
|
141
|
+
This way, even in the worst case, the only thing exposed is a disposable notification account, not your main mailbox.
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## Extra utilities
|
|
146
|
+
|
|
147
|
+
These are optional helpers built on top of the same configuration system. `sendstatus` is the main feature — the rest are conveniences for specific use cases.
|
|
148
|
+
|
|
149
|
+
### `sendmsg`
|
|
150
|
+
|
|
151
|
+
Send a one-off email manually, with an optional attachment:
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
from pymailfeedback import sendmsg
|
|
155
|
+
|
|
156
|
+
sendmsg("you@example.com", subject="Checkpoint saved", message="Epoch 50 completed.")
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
If `to_addresses` is omitted, the configured `default_recipient` is used.
|
|
160
|
+
|
|
161
|
+
### `sendbeacon`
|
|
162
|
+
|
|
163
|
+
Send a periodic "still alive" email from inside a long-running loop, without spamming your inbox every iteration:
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
from pymailfeedback import sendbeacon
|
|
167
|
+
|
|
168
|
+
for epoch in range(1000):
|
|
169
|
+
# ... training code ...
|
|
170
|
+
sendbeacon(delta_time_minutes=60 * 12) # one email every ~12 hours
|
|
171
|
+
```
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
pymailfeedback/__init__.py,sha256=1oXCXkGTORPhXDajYmMu3feFUXVDCl1PS078IkV7VlI,124
|
|
2
|
+
pymailfeedback/core.py,sha256=kKCf5Cu4fNhKCOmCGuhdDaK423Hb5kleXQcMU0Oe_WY,19296
|
|
3
|
+
pymailfeedback-0.1.0.dist-info/METADATA,sha256=awpjSBpBwePT0P5nvOFETISdl-2JiyrWsu9YZa9IFAo,8858
|
|
4
|
+
pymailfeedback-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
5
|
+
pymailfeedback-0.1.0.dist-info/entry_points.txt,sha256=kRaKkoDbzDloENXMFfQ9WaECHlCmqjqs-RH0CSY3uPk,79
|
|
6
|
+
pymailfeedback-0.1.0.dist-info/top_level.txt,sha256=k2bEc_OQS7wPgeNdH3QXMRyIDGw4EIqAxbFydbrzxd8,15
|
|
7
|
+
pymailfeedback-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pymailfeedback
|