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.
- julien_python_toolkit-0.2.5/PKG-INFO +160 -0
- julien_python_toolkit-0.2.5/README.md +131 -0
- {julien_python_toolkit-0.2.2 → julien_python_toolkit-0.2.5}/setup.py +12 -7
- julien_python_toolkit-0.2.5/src/julien_python_toolkit/email_logger.py +150 -0
- {julien_python_toolkit-0.2.2 → julien_python_toolkit-0.2.5/src}/julien_python_toolkit/email_sender.py +70 -39
- julien_python_toolkit-0.2.5/src/julien_python_toolkit/file_utilities.py +35 -0
- julien_python_toolkit-0.2.5/src/julien_python_toolkit/google_services.py +700 -0
- julien_python_toolkit-0.2.5/src/julien_python_toolkit/log_utilities.py +164 -0
- julien_python_toolkit-0.2.5/src/julien_python_toolkit.egg-info/PKG-INFO +160 -0
- julien_python_toolkit-0.2.5/src/julien_python_toolkit.egg-info/SOURCES.txt +19 -0
- julien_python_toolkit-0.2.5/tests/test_email_logger_behavior.py +74 -0
- julien_python_toolkit-0.2.5/tests/test_email_sender_behavior.py +83 -0
- julien_python_toolkit-0.2.5/tests/test_file_utilities_behavior.py +32 -0
- julien_python_toolkit-0.2.5/tests/test_google_services_behavior.py +270 -0
- julien_python_toolkit-0.2.5/tests/test_log_utilities.py +143 -0
- julien_python_toolkit-0.2.2/PKG-INFO +0 -31
- julien_python_toolkit-0.2.2/README.md +0 -3
- julien_python_toolkit-0.2.2/julien_python_toolkit/email_logger.py +0 -67
- julien_python_toolkit-0.2.2/julien_python_toolkit/file_utilities.py +0 -13
- julien_python_toolkit-0.2.2/julien_python_toolkit/google_services.py +0 -522
- julien_python_toolkit-0.2.2/julien_python_toolkit/log_utilities.py +0 -101
- julien_python_toolkit-0.2.2/julien_python_toolkit.egg-info/PKG-INFO +0 -31
- julien_python_toolkit-0.2.2/julien_python_toolkit.egg-info/SOURCES.txt +0 -14
- {julien_python_toolkit-0.2.2 → julien_python_toolkit-0.2.5}/LICENSE.txt +0 -0
- {julien_python_toolkit-0.2.2 → julien_python_toolkit-0.2.5}/setup.cfg +0 -0
- {julien_python_toolkit-0.2.2 → julien_python_toolkit-0.2.5/src}/julien_python_toolkit/__init__.py +0 -0
- {julien_python_toolkit-0.2.2 → julien_python_toolkit-0.2.5/src}/julien_python_toolkit.egg-info/dependency_links.txt +0 -0
- {julien_python_toolkit-0.2.2 → julien_python_toolkit-0.2.5/src}/julien_python_toolkit.egg-info/requires.txt +0 -0
- {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.
|
|
15
|
-
|
|
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=
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
46
|
-
logger.error(f"Error closing SMTP connection: {
|
|
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
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
logger.error(f"Failed to connect and login: {
|
|
87
|
-
raise
|
|
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
|
-
|
|
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
|
|
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()
|
|
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
|
|
126
|
-
logger.error(f"Failed to send email to '{receiver_email}': {
|
|
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)
|