julien-python-toolkit 0.2.2__tar.gz → 0.2.5__tar.gz

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.
Files changed (29) hide show
  1. julien_python_toolkit-0.2.5/PKG-INFO +160 -0
  2. julien_python_toolkit-0.2.5/README.md +131 -0
  3. {julien_python_toolkit-0.2.2 → julien_python_toolkit-0.2.5}/setup.py +12 -7
  4. julien_python_toolkit-0.2.5/src/julien_python_toolkit/email_logger.py +150 -0
  5. {julien_python_toolkit-0.2.2 → julien_python_toolkit-0.2.5/src}/julien_python_toolkit/email_sender.py +70 -39
  6. julien_python_toolkit-0.2.5/src/julien_python_toolkit/file_utilities.py +35 -0
  7. julien_python_toolkit-0.2.5/src/julien_python_toolkit/google_services.py +700 -0
  8. julien_python_toolkit-0.2.5/src/julien_python_toolkit/log_utilities.py +164 -0
  9. julien_python_toolkit-0.2.5/src/julien_python_toolkit.egg-info/PKG-INFO +160 -0
  10. julien_python_toolkit-0.2.5/src/julien_python_toolkit.egg-info/SOURCES.txt +19 -0
  11. julien_python_toolkit-0.2.5/tests/test_email_logger_behavior.py +74 -0
  12. julien_python_toolkit-0.2.5/tests/test_email_sender_behavior.py +83 -0
  13. julien_python_toolkit-0.2.5/tests/test_file_utilities_behavior.py +32 -0
  14. julien_python_toolkit-0.2.5/tests/test_google_services_behavior.py +270 -0
  15. julien_python_toolkit-0.2.5/tests/test_log_utilities.py +143 -0
  16. julien_python_toolkit-0.2.2/PKG-INFO +0 -31
  17. julien_python_toolkit-0.2.2/README.md +0 -3
  18. julien_python_toolkit-0.2.2/julien_python_toolkit/email_logger.py +0 -67
  19. julien_python_toolkit-0.2.2/julien_python_toolkit/file_utilities.py +0 -13
  20. julien_python_toolkit-0.2.2/julien_python_toolkit/google_services.py +0 -522
  21. julien_python_toolkit-0.2.2/julien_python_toolkit/log_utilities.py +0 -101
  22. julien_python_toolkit-0.2.2/julien_python_toolkit.egg-info/PKG-INFO +0 -31
  23. julien_python_toolkit-0.2.2/julien_python_toolkit.egg-info/SOURCES.txt +0 -14
  24. {julien_python_toolkit-0.2.2 → julien_python_toolkit-0.2.5}/LICENSE.txt +0 -0
  25. {julien_python_toolkit-0.2.2 → julien_python_toolkit-0.2.5}/setup.cfg +0 -0
  26. {julien_python_toolkit-0.2.2 → julien_python_toolkit-0.2.5/src}/julien_python_toolkit/__init__.py +0 -0
  27. {julien_python_toolkit-0.2.2 → julien_python_toolkit-0.2.5/src}/julien_python_toolkit.egg-info/dependency_links.txt +0 -0
  28. {julien_python_toolkit-0.2.2 → julien_python_toolkit-0.2.5/src}/julien_python_toolkit.egg-info/requires.txt +0 -0
  29. {julien_python_toolkit-0.2.2 → julien_python_toolkit-0.2.5/src}/julien_python_toolkit.egg-info/top_level.txt +0 -0
@@ -0,0 +1,160 @@
1
+ Metadata-Version: 2.4
2
+ Name: julien-python-toolkit
3
+ Version: 0.2.5
4
+ Summary: Important code that I reuse through multiple projects. Please see license for allowed use.
5
+ Home-page: https://github.com/JulienPython/JulienPythonToolkit-V001
6
+ Author: Julien Python
7
+ Author-email: python.julien@hotmail.com
8
+ License: Custom Non-Commercial License
9
+ Classifier: Programming Language :: Python :: 3
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE.txt
12
+ Requires-Dist: certifi==2024.8.30
13
+ Requires-Dist: google-api-core==2.19.1
14
+ Requires-Dist: google-api-python-client==2.139.0
15
+ Requires-Dist: google-auth==2.32.0
16
+ Requires-Dist: google-auth-httplib2==0.2.0
17
+ Requires-Dist: google-auth-oauthlib==1.2.1
18
+ Requires-Dist: googleapis-common-protos==1.63.2
19
+ Dynamic: author
20
+ Dynamic: author-email
21
+ Dynamic: classifier
22
+ Dynamic: description
23
+ Dynamic: description-content-type
24
+ Dynamic: home-page
25
+ Dynamic: license
26
+ Dynamic: license-file
27
+ Dynamic: requires-dist
28
+ Dynamic: summary
29
+
30
+ # JulienPythonToolkit-V001
31
+
32
+ Important code that I reuse through multiple projects. Please see license for allowed use.
33
+
34
+ ## 🚀 Release & Publishing Guide (GitHub → PyPI)
35
+
36
+ This repository uses GitHub Actions to automatically build and publish the package to PyPI when a new version tag is created.
37
+
38
+ ---
39
+
40
+ ### How GitHub Actions Works in This Repository
41
+
42
+ The publishing workflow is defined in:
43
+
44
+ .github/workflows/publish.yml
45
+
46
+ The workflow is triggered **only when a new tag starting with `v` is pushed**.
47
+
48
+ Trigger condition:
49
+
50
+ on:
51
+ push:
52
+ tags:
53
+ - "v*"
54
+
55
+ This means:
56
+
57
+ - Normal commits to `main` do NOT publish.
58
+ - Editing a GitHub release does NOT publish.
59
+ - Reusing an existing tag does NOT publish.
60
+ - Only a brand-new tag push triggers the workflow.
61
+
62
+ When triggered, the workflow:
63
+
64
+ 1. Verifies the tag points to a commit on `main`
65
+ 2. Builds the package using `python -m build`
66
+ 3. Publishes to PyPI using Trusted Publishing
67
+
68
+ ---
69
+
70
+ ### PyPI Trusted Publishing
71
+
72
+ This project uses **PyPI Trusted Publishing (OIDC)**.
73
+
74
+ This means:
75
+
76
+ - No PyPI API token is stored in GitHub secrets.
77
+ - GitHub securely authenticates directly with PyPI.
78
+ - The workflow requires `id-token: write` permission.
79
+
80
+ If Trusted Publishing is not configured in the PyPI project settings, publishing will fail.
81
+
82
+ ---
83
+
84
+ ### Correct Release Workflow
85
+
86
+ Follow this process exactly.
87
+
88
+ 1. Create a feature branch
89
+ 2. Make your changes
90
+ 3. Bump the version in `setup.py`
91
+ Example:
92
+ version='0.2.4'
93
+
94
+ 4. Commit and push the branch
95
+ 5. Merge the branch into `main`
96
+ 6. On GitHub:
97
+ - Go to **Releases**
98
+ - Click **Draft a new release**
99
+ - Create a new tag (e.g. `v0.2.4`)
100
+ - Target branch must be `main`
101
+ - Publish release
102
+
103
+ Creating the release creates and pushes the tag.
104
+
105
+ This triggers the GitHub Action.
106
+
107
+ To monitor progress:
108
+
109
+ Repository → Actions
110
+
111
+ Green check = success
112
+ Red X = failure
113
+
114
+ After success, the new version appears on PyPI.
115
+
116
+ ---
117
+
118
+ ### Important Rules
119
+
120
+ 1. The tag must be new
121
+ Reusing the same tag will NOT trigger the workflow again.
122
+
123
+ 2. The package version must be new
124
+ PyPI does not allow re-uploading the same version number, even if the previous upload failed.
125
+
126
+ 3. The version inside the package must match the tag
127
+ Tag: v0.2.4
128
+ setup.py version: 0.2.4
129
+
130
+ 4. Always bump the version before merging to `main`.
131
+
132
+ 5. Always tag after the change is merged to `main`.
133
+ Do not tag on feature branches.
134
+
135
+ 6. Test the build locally before releasing:
136
+
137
+ python -m pip install build
138
+ python -m build
139
+
140
+ If it fails locally, CI will fail.
141
+
142
+ ---
143
+
144
+ ### If Publishing Does Not Trigger
145
+
146
+ Check:
147
+
148
+ - Is the workflow file merged into `main`?
149
+ - Does the tag start with `v`?
150
+ - Is it a new tag?
151
+ - Does the tag target `main`?
152
+ - Is Trusted Publishing configured in PyPI?
153
+
154
+ ---
155
+
156
+ ### Release Summary
157
+
158
+ Branch → Implement changes → Bump version → Merge to main → Create GitHub release (new tag) → Workflow runs → Check Actions → Version appears on PyPI.
159
+
160
+ This ensures secure, reproducible, and clean releases.
@@ -0,0 +1,131 @@
1
+ # JulienPythonToolkit-V001
2
+
3
+ Important code that I reuse through multiple projects. Please see license for allowed use.
4
+
5
+ ## 🚀 Release & Publishing Guide (GitHub → PyPI)
6
+
7
+ This repository uses GitHub Actions to automatically build and publish the package to PyPI when a new version tag is created.
8
+
9
+ ---
10
+
11
+ ### How GitHub Actions Works in This Repository
12
+
13
+ The publishing workflow is defined in:
14
+
15
+ .github/workflows/publish.yml
16
+
17
+ The workflow is triggered **only when a new tag starting with `v` is pushed**.
18
+
19
+ Trigger condition:
20
+
21
+ on:
22
+ push:
23
+ tags:
24
+ - "v*"
25
+
26
+ This means:
27
+
28
+ - Normal commits to `main` do NOT publish.
29
+ - Editing a GitHub release does NOT publish.
30
+ - Reusing an existing tag does NOT publish.
31
+ - Only a brand-new tag push triggers the workflow.
32
+
33
+ When triggered, the workflow:
34
+
35
+ 1. Verifies the tag points to a commit on `main`
36
+ 2. Builds the package using `python -m build`
37
+ 3. Publishes to PyPI using Trusted Publishing
38
+
39
+ ---
40
+
41
+ ### PyPI Trusted Publishing
42
+
43
+ This project uses **PyPI Trusted Publishing (OIDC)**.
44
+
45
+ This means:
46
+
47
+ - No PyPI API token is stored in GitHub secrets.
48
+ - GitHub securely authenticates directly with PyPI.
49
+ - The workflow requires `id-token: write` permission.
50
+
51
+ If Trusted Publishing is not configured in the PyPI project settings, publishing will fail.
52
+
53
+ ---
54
+
55
+ ### Correct Release Workflow
56
+
57
+ Follow this process exactly.
58
+
59
+ 1. Create a feature branch
60
+ 2. Make your changes
61
+ 3. Bump the version in `setup.py`
62
+ Example:
63
+ version='0.2.4'
64
+
65
+ 4. Commit and push the branch
66
+ 5. Merge the branch into `main`
67
+ 6. On GitHub:
68
+ - Go to **Releases**
69
+ - Click **Draft a new release**
70
+ - Create a new tag (e.g. `v0.2.4`)
71
+ - Target branch must be `main`
72
+ - Publish release
73
+
74
+ Creating the release creates and pushes the tag.
75
+
76
+ This triggers the GitHub Action.
77
+
78
+ To monitor progress:
79
+
80
+ Repository → Actions
81
+
82
+ Green check = success
83
+ Red X = failure
84
+
85
+ After success, the new version appears on PyPI.
86
+
87
+ ---
88
+
89
+ ### Important Rules
90
+
91
+ 1. The tag must be new
92
+ Reusing the same tag will NOT trigger the workflow again.
93
+
94
+ 2. The package version must be new
95
+ PyPI does not allow re-uploading the same version number, even if the previous upload failed.
96
+
97
+ 3. The version inside the package must match the tag
98
+ Tag: v0.2.4
99
+ setup.py version: 0.2.4
100
+
101
+ 4. Always bump the version before merging to `main`.
102
+
103
+ 5. Always tag after the change is merged to `main`.
104
+ Do not tag on feature branches.
105
+
106
+ 6. Test the build locally before releasing:
107
+
108
+ python -m pip install build
109
+ python -m build
110
+
111
+ If it fails locally, CI will fail.
112
+
113
+ ---
114
+
115
+ ### If Publishing Does Not Trigger
116
+
117
+ Check:
118
+
119
+ - Is the workflow file merged into `main`?
120
+ - Does the tag start with `v`?
121
+ - Is it a new tag?
122
+ - Does the tag target `main`?
123
+ - Is Trusted Publishing configured in PyPI?
124
+
125
+ ---
126
+
127
+ ### Release Summary
128
+
129
+ Branch → Implement changes → Bump version → Merge to main → Create GitHub release (new tag) → Workflow runs → Check Actions → Version appears on PyPI.
130
+
131
+ This ensures secure, reproducible, and clean releases.
@@ -5,16 +5,21 @@ from pathlib import Path
5
5
  this_directory = Path(__file__).parent
6
6
  long_description = (this_directory / "README.md").read_text()
7
7
 
8
- # Load requirements from a file
9
- with open('requirements.txt') as f:
10
- required = f.read().splitlines()
11
-
12
8
  setup(
13
9
  name='julien-python-toolkit',
14
- version='0.2.2',
15
- packages=find_packages(),
10
+ version='0.2.5',
11
+ package_dir={"": "src"},
12
+ packages=find_packages(where="src"),
16
13
  license='Custom Non-Commercial License', # Reference your custom license
17
- install_requires=required, # Use the list from requirements.txt
14
+ install_requires=[
15
+ "certifi==2024.8.30",
16
+ "google-api-core==2.19.1",
17
+ "google-api-python-client==2.139.0",
18
+ "google-auth==2.32.0",
19
+ "google-auth-httplib2==0.2.0",
20
+ "google-auth-oauthlib==1.2.1",
21
+ "googleapis-common-protos==1.63.2",
22
+ ],
18
23
  description='Important code that I reuse through multiple projects. Please see license for allowed use.',
19
24
  long_description=long_description, # Include long description here
20
25
  long_description_content_type='text/markdown', # Set to 'text/markdown' for Markdown files
@@ -0,0 +1,150 @@
1
+ # This file is part of the "your-package-name" project.
2
+ # It is licensed under the "Custom Non-Commercial License".
3
+ # You may not use this file for commercial purposes without
4
+ # explicit permission from the author.
5
+
6
+
7
+ import datetime
8
+ import time
9
+ from typing import Protocol
10
+
11
+
12
+ class _SupportsEmailSend(Protocol):
13
+ def send_emails(self, subject: str, body: str) -> None:
14
+ """Send an email payload to one or many recipients.
15
+
16
+ Args:
17
+ subject: Subject line used for the outgoing email.
18
+ body: Message body that should be sent.
19
+ """
20
+
21
+
22
+ class EmailLogger:
23
+ """Collect log lines and send them by email in one batch."""
24
+
25
+ def __init__(
26
+ self,
27
+ email_sender: _SupportsEmailSend,
28
+ logger_name: str,
29
+ subject: str,
30
+ timestamp: bool = True,
31
+ ) -> None:
32
+ """Create a logger that stores messages until ``send`` is called.
33
+
34
+ Args:
35
+ email_sender: Object that can send emails.
36
+ logger_name: Name to show inside each formatted log line.
37
+ subject: Subject line to use when sending buffered logs.
38
+ timestamp: If ``True``, prepend timestamps to buffered log lines.
39
+ """
40
+
41
+ self.email_sender = email_sender
42
+ self.logger_name = logger_name
43
+ self.subject = subject
44
+ self.timestamp = timestamp
45
+ self.buffer: list[str] = []
46
+
47
+ def critical(self, message: str) -> None:
48
+ """Add a CRITICAL log message to the email buffer.
49
+
50
+ Args:
51
+ message: Log message text to store.
52
+ """
53
+
54
+ self._log_message(message, "CRITICAL")
55
+
56
+ def error(self, message: str) -> None:
57
+ """Add an ERROR log message to the email buffer.
58
+
59
+ Args:
60
+ message: Log message text to store.
61
+ """
62
+
63
+ self._log_message(message, "ERROR")
64
+
65
+ def warning(self, message: str) -> None:
66
+ """Add a WARNING log message to the email buffer.
67
+
68
+ Args:
69
+ message: Log message text to store.
70
+ """
71
+
72
+ self._log_message(message, "WARNING")
73
+
74
+ def info(self, message: str) -> None:
75
+ """Add an INFO log message to the email buffer.
76
+
77
+ Args:
78
+ message: Log message text to store.
79
+ """
80
+
81
+ self._log_message(message, "INFO")
82
+
83
+ def debug(self, message: str) -> None:
84
+ """Add a DEBUG log message to the email buffer.
85
+
86
+ Args:
87
+ message: Log message text to store.
88
+ """
89
+
90
+ self._log_message(message, "DEBUG")
91
+
92
+ def _log_message(self, message: str, log_level: str = "INFO") -> None:
93
+ """Format one message and append it to the internal buffer."""
94
+
95
+ formatted_message = ""
96
+
97
+ if self.timestamp:
98
+ formatted_message += f"{datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - "
99
+
100
+ formatted_message += f"{log_level} - {self.logger_name} - {message}"
101
+
102
+ self.buffer.append(formatted_message)
103
+
104
+ def send(self) -> None:
105
+ """Send all buffered log lines as one email and clear the buffer.
106
+
107
+ This method does nothing when the buffer is empty.
108
+ """
109
+
110
+ if self.buffer:
111
+ log_message = "\n".join(self.buffer)
112
+ self.email_sender.send_emails(self.subject, log_message)
113
+ self.buffer = []
114
+
115
+
116
+ class TimedEmailLogger(EmailLogger):
117
+ """Email logger that auto-sends buffered logs on a time interval."""
118
+
119
+ def __init__(
120
+ self,
121
+ email_sender: _SupportsEmailSend,
122
+ logger_name: str,
123
+ subject: str,
124
+ timestamp: bool = True,
125
+ interval: int = 60,
126
+ ) -> None:
127
+ """Create a timed logger with an interval in seconds.
128
+
129
+ Args:
130
+ email_sender: Object that can send emails.
131
+ logger_name: Name to show inside each formatted log line.
132
+ subject: Subject line to use when sending buffered logs.
133
+ timestamp: If ``True``, prepend timestamps to buffered log lines.
134
+ interval: Number of seconds to wait between automatic sends.
135
+ """
136
+
137
+ super().__init__(email_sender, logger_name, subject, timestamp=timestamp)
138
+ self.interval = interval
139
+ self.last_email_time = time.time()
140
+
141
+ def _log_message(self, message: str, log_level: str = "INFO") -> None:
142
+ """Append a message and send if the interval has elapsed."""
143
+
144
+ super()._log_message(message, log_level)
145
+
146
+ current_time = time.time()
147
+
148
+ if current_time - self.last_email_time >= self.interval:
149
+ self.send()
150
+ self.last_email_time = current_time
@@ -4,12 +4,13 @@
4
4
  # explicit permission from the author.
5
5
 
6
6
 
7
- import ssl
8
7
  import smtplib
8
+ import socket
9
+ import ssl
9
10
  import time
10
11
  from email.message import EmailMessage
12
+
11
13
  import certifi
12
- import socket
13
14
 
14
15
  from . import log_utilities
15
16
 
@@ -22,19 +23,38 @@ from . import log_utilities
22
23
 
23
24
 
24
25
  # Setup Logger
25
- logger = log_utilities.Logger("EmailGroup", "email_group.log", stream_log_level = log_utilities.INFO, file_log_level = log_utilities.DEBUG)
26
+ logger = log_utilities.Logger(
27
+ "EmailGroup",
28
+ "email_group.log",
29
+ stream_log_level=log_utilities.INFO,
30
+ file_log_level=log_utilities.DEBUG,
31
+ )
32
+
33
+
34
+ class EmailSender:
35
+ """Send emails to a configured list of recipients."""
26
36
 
27
- class EmailSender():
37
+ def __init__(self, sender_email: str, sender_password: str, receiver_emails: list[str]) -> None:
38
+ """Create SMTP client and connect immediately.
28
39
 
29
- def __init__(self, sender_email, sender_password, receiver_emails):
40
+ Args:
41
+ sender_email: Email address used to send emails.
42
+ sender_password: App password used to authenticate with SMTP.
43
+ receiver_emails: List of destination email addresses.
44
+ """
30
45
 
31
46
  self._sender_email = sender_email
32
47
  self._sender_password = sender_password
33
48
  self._receiver_emails = receiver_emails
34
- self._smtp = None
35
- self._connect_and_login() # Establish connection and login
49
+ self._smtp: smtplib.SMTP_SSL | None = None
36
50
 
37
- def __del__(self):
51
+ self._connect_and_login()
52
+
53
+ def __del__(self) -> None:
54
+ """Close SMTP connection when object is destroyed.
55
+
56
+ This method is best-effort and suppresses expected shutdown errors.
57
+ """
38
58
 
39
59
  if self._smtp and self._smtp.sock is not None:
40
60
  try:
@@ -42,72 +62,79 @@ class EmailSender():
42
62
  logger.info("SMTP connection closed.")
43
63
  except smtplib.SMTPServerDisconnected:
44
64
  logger.info("SMTP connection was already closed.")
45
- except Exception as e:
46
- logger.error(f"Error closing SMTP connection: {e}")
65
+ except Exception as error:
66
+ logger.error(f"Error closing SMTP connection: {error}")
47
67
 
48
- def _connect_and_login_with_retry(self):
68
+ def _connect_and_login_with_retry(self) -> None:
69
+ """Retry SMTP login for common transient network failures."""
49
70
 
50
71
  retries = 0
51
72
 
52
73
  while retries < 8:
53
-
54
74
  try:
55
75
  self._connect_and_login()
56
-
57
76
  except Exception as error:
58
-
59
77
  logger.warn(f"Error connecting and logging in: {error.__class__.__name__}({error}).")
60
78
 
61
- if isinstance(error, socket.timeout) or isinstance(error, socket.gaierror) or isinstance(error, ssl.SSLError):
62
-
63
- logger.warn(f"Function '{self._connect_and_login.__name__}' timed out. Retrying. Retry count: {retries + 1}/8. Curent delay: {2 ** (retries + 1)} seconds.")
79
+ if (
80
+ isinstance(error, socket.timeout)
81
+ or isinstance(error, socket.gaierror)
82
+ or isinstance(error, ssl.SSLError)
83
+ ):
84
+ logger.warn(
85
+ f"Function '{self._connect_and_login.__name__}' timed out. "
86
+ f"Retrying. Retry count: {retries + 1}/8. Curent delay: {2 ** (retries + 1)} seconds."
87
+ )
64
88
 
65
89
  retries += 1
66
- delay = 2 ** retries
90
+ delay = 2**retries
67
91
  time.sleep(delay)
68
-
69
92
  else:
70
93
  raise error from error
71
94
 
72
95
  raise TimeoutError("Exceeded maximum retries.")
73
96
 
74
- def _connect_and_login(self):
97
+ def _connect_and_login(self) -> None:
98
+ """Connect to Gmail SMTP over SSL and authenticate."""
75
99
 
76
100
  try:
77
-
78
101
  context = ssl.create_default_context()
79
102
  context.load_verify_locations(certifi.where())
103
+
80
104
  self._smtp = smtplib.SMTP_SSL("smtp.gmail.com", 465, context=context)
81
105
  self._smtp.login(self._sender_email, self._sender_password)
82
- logger.info("Connected and logged in to SMTP server.")
83
106
 
84
- except Exception as e:
85
-
86
- logger.error(f"Failed to connect and login: {e}")
87
- raise e
88
-
89
- def _reconnect_if_needed(self):
107
+ logger.info("Connected and logged in to SMTP server.")
108
+ except Exception as error:
109
+ logger.error(f"Failed to connect and login: {error}")
110
+ raise error
90
111
 
91
- # Reconnect to the SMTP server if the connection is lost.
112
+ def _reconnect_if_needed(self) -> None:
113
+ """Reconnect to SMTP server if current connection is not healthy."""
92
114
 
93
115
  try:
116
+ status = self._smtp.noop() if self._smtp else None
94
117
 
95
- status = self._smtp.noop()
96
-
97
- if status[0] != 250:
118
+ if not status or status[0] != 250:
98
119
  raise smtplib.SMTPException("SMTP connection is not healthy")
99
120
 
100
121
  except (smtplib.SMTPException, AttributeError):
101
-
102
122
  logger.warning("SMTP connection lost, reconnecting...")
103
123
  self._connect_and_login()
104
-
105
- def send_emails(self, subject, body):
124
+
125
+ def send_emails(self, subject: str, body: str) -> None:
126
+ """Send one message to every configured receiver.
127
+
128
+ Args:
129
+ subject: Subject line for the outgoing message.
130
+ body: Body text for the outgoing message.
131
+ """
106
132
 
107
133
  for receiver_email in self._receiver_emails:
108
134
  self._send_email(receiver_email, subject, body)
109
135
 
110
- def _send_email(self, receiver_email, subject, body):
136
+ def _send_email(self, receiver_email: str, subject: str, body: str) -> None:
137
+ """Send one email to one recipient."""
111
138
 
112
139
  em = EmailMessage()
113
140
  em["From"] = self._sender_email
@@ -115,12 +142,16 @@ class EmailSender():
115
142
  em["Subject"] = subject
116
143
  em.set_content(body)
117
144
 
118
- self._reconnect_if_needed() # Ensure connection is active before sending
145
+ self._reconnect_if_needed()
119
146
 
120
147
  try:
148
+ if self._smtp is None:
149
+ raise smtplib.SMTPException("SMTP connection is not initialized")
150
+
121
151
  start_time = time.time()
122
152
  self._smtp.send_message(em)
123
153
  seconds_elapsed = time.time() - start_time
154
+
124
155
  logger.info(f"Sent email to '{receiver_email}' in {seconds_elapsed:.2f} seconds.")
125
- except Exception as e:
126
- logger.error(f"Failed to send email to '{receiver_email}': {e}")
156
+ except Exception as error:
157
+ logger.error(f"Failed to send email to '{receiver_email}': {error}")
@@ -0,0 +1,35 @@
1
+ # This file is part of the "your-package-name" project.
2
+ # It is licensed under the "Custom Non-Commercial License".
3
+ # You may not use this file for commercial purposes without
4
+ # explicit permission from the author.
5
+
6
+
7
+ import os
8
+
9
+
10
+ def path_to_this_file(file: str) -> str:
11
+ """Return the absolute parent directory path for a file path.
12
+
13
+ Args:
14
+ file: File path that should be converted to an absolute directory path.
15
+
16
+ Returns:
17
+ The absolute directory path that contains ``file``.
18
+ """
19
+
20
+ absolute_file_path = os.path.abspath(file)
21
+
22
+ return os.path.dirname(absolute_file_path)
23
+
24
+
25
+ def join(*args: str) -> str:
26
+ """Join path parts into a single normalized path string.
27
+
28
+ Args:
29
+ *args: One or many path segments to join.
30
+
31
+ Returns:
32
+ A single path string built from all provided segments.
33
+ """
34
+
35
+ return os.path.join(*args)