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.
@@ -0,0 +1,3 @@
1
+ from .core import sendstatus, sendmsg, sendbeacon, readsec
2
+
3
+ __all__ = ["sendstatus", "sendmsg", "sendbeacon", "readsec"]
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pymailfeedback-init = pymailfeedback.core:_interactive_setup
@@ -0,0 +1 @@
1
+ pymailfeedback