pintos-utils 1.0.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.
- pintos_utils/__init__.py +1 -0
- pintos_utils/email/__init__.py +29 -0
- pintos_utils/email/contracts.py +15 -0
- pintos_utils/email/exceptions.py +32 -0
- pintos_utils/email/models.py +119 -0
- pintos_utils/email/providers/__init__.py +1 -0
- pintos_utils/email/providers/gmail/__init__.py +16 -0
- pintos_utils/email/providers/gmail/_mime.py +46 -0
- pintos_utils/email/providers/gmail/sender.py +153 -0
- pintos_utils/py.typed +0 -0
- pintos_utils-1.0.0.dist-info/METADATA +78 -0
- pintos_utils-1.0.0.dist-info/RECORD +14 -0
- pintos_utils-1.0.0.dist-info/WHEEL +4 -0
- pintos_utils-1.0.0.dist-info/licenses/LICENSE +21 -0
pintos_utils/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Reusable utilities for Python applications."""
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Provider-neutral email models, contracts, and exceptions."""
|
|
2
|
+
|
|
3
|
+
from pintos_utils.email.contracts import EmailSender
|
|
4
|
+
from pintos_utils.email.exceptions import (
|
|
5
|
+
EmailAuthenticationError,
|
|
6
|
+
EmailConfigurationError,
|
|
7
|
+
EmailError,
|
|
8
|
+
EmailProviderError,
|
|
9
|
+
EmailValidationError,
|
|
10
|
+
)
|
|
11
|
+
from pintos_utils.email.models import (
|
|
12
|
+
EmailAddress,
|
|
13
|
+
EmailAttachment,
|
|
14
|
+
EmailMessage,
|
|
15
|
+
SendResult,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"EmailAddress",
|
|
20
|
+
"EmailAttachment",
|
|
21
|
+
"EmailAuthenticationError",
|
|
22
|
+
"EmailConfigurationError",
|
|
23
|
+
"EmailError",
|
|
24
|
+
"EmailMessage",
|
|
25
|
+
"EmailProviderError",
|
|
26
|
+
"EmailSender",
|
|
27
|
+
"EmailValidationError",
|
|
28
|
+
"SendResult",
|
|
29
|
+
]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Provider-neutral email service contracts."""
|
|
2
|
+
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
from pintos_utils.email.models import EmailMessage, SendResult
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class EmailSender(Protocol):
|
|
9
|
+
"""Structural contract implemented by each email provider adapter."""
|
|
10
|
+
|
|
11
|
+
def send(self, message: EmailMessage, /) -> SendResult:
|
|
12
|
+
"""Submit one message and return the provider acceptance result."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
__all__ = ["EmailSender"]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Public exceptions raised by the email module."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class EmailError(Exception):
|
|
5
|
+
"""Base class for errors exposed by ``pintos_utils.email``."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class EmailValidationError(EmailError):
|
|
9
|
+
"""Raised when an email model contains invalid or unsafe values."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EmailConfigurationError(EmailError):
|
|
13
|
+
"""Raised when provider configuration is missing or unusable."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EmailAuthenticationError(EmailError):
|
|
17
|
+
"""Raised when provider credentials are rejected or cannot be refreshed."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class EmailProviderError(EmailError):
|
|
21
|
+
"""Raised when a provider request fails or has an indeterminate outcome."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
message: str,
|
|
26
|
+
*,
|
|
27
|
+
provider: str,
|
|
28
|
+
retryable: bool | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
super().__init__(message)
|
|
31
|
+
self.provider = provider
|
|
32
|
+
self.retryable = retryable
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Immutable provider-neutral email models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TypeVar
|
|
8
|
+
|
|
9
|
+
from pintos_utils.email.exceptions import EmailValidationError
|
|
10
|
+
|
|
11
|
+
_T = TypeVar("_T")
|
|
12
|
+
_MEDIA_TYPE_PATTERN = re.compile(
|
|
13
|
+
r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+/[!#$%&'*+\-.^_`|~0-9A-Za-z]+$"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _reject_line_breaks(value: str, field_name: str) -> None:
|
|
18
|
+
if "\r" in value or "\n" in value:
|
|
19
|
+
raise EmailValidationError(f"{field_name} must not contain line breaks")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _immutable_tuple(values: tuple[_T, ...], field_name: str) -> tuple[_T, ...]:
|
|
23
|
+
try:
|
|
24
|
+
return tuple(values)
|
|
25
|
+
except TypeError as error:
|
|
26
|
+
raise EmailValidationError(f"{field_name} must be an iterable") from error
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class EmailAddress:
|
|
31
|
+
"""One email address with an optional display name."""
|
|
32
|
+
|
|
33
|
+
address: str
|
|
34
|
+
display_name: str | None = None
|
|
35
|
+
|
|
36
|
+
def __post_init__(self) -> None:
|
|
37
|
+
if not isinstance(self.address, str) or not self.address.strip():
|
|
38
|
+
raise EmailValidationError("address must be a non-empty string")
|
|
39
|
+
_reject_line_breaks(self.address, "address")
|
|
40
|
+
if self.display_name is not None:
|
|
41
|
+
if not isinstance(self.display_name, str):
|
|
42
|
+
raise EmailValidationError("display_name must be a string or None")
|
|
43
|
+
_reject_line_breaks(self.display_name, "display_name")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True)
|
|
47
|
+
class EmailAttachment:
|
|
48
|
+
"""An attachment whose bytes have already been read by the caller."""
|
|
49
|
+
|
|
50
|
+
filename: str
|
|
51
|
+
content: bytes
|
|
52
|
+
content_type: str | None = None
|
|
53
|
+
|
|
54
|
+
def __post_init__(self) -> None:
|
|
55
|
+
if not isinstance(self.filename, str) or not self.filename.strip():
|
|
56
|
+
raise EmailValidationError("filename must be a non-empty string")
|
|
57
|
+
_reject_line_breaks(self.filename, "filename")
|
|
58
|
+
if not isinstance(self.content, bytes):
|
|
59
|
+
raise EmailValidationError("content must be bytes")
|
|
60
|
+
if self.content_type is not None:
|
|
61
|
+
if not isinstance(
|
|
62
|
+
self.content_type, str
|
|
63
|
+
) or not _MEDIA_TYPE_PATTERN.fullmatch(self.content_type):
|
|
64
|
+
raise EmailValidationError("content_type must be a valid MIME type")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, slots=True)
|
|
68
|
+
class EmailMessage:
|
|
69
|
+
"""Immutable message input shared by all provider implementations."""
|
|
70
|
+
|
|
71
|
+
sender: EmailAddress
|
|
72
|
+
subject: str
|
|
73
|
+
to: tuple[EmailAddress, ...] = ()
|
|
74
|
+
cc: tuple[EmailAddress, ...] = ()
|
|
75
|
+
bcc: tuple[EmailAddress, ...] = ()
|
|
76
|
+
text_body: str | None = None
|
|
77
|
+
html_body: str | None = None
|
|
78
|
+
attachments: tuple[EmailAttachment, ...] = ()
|
|
79
|
+
|
|
80
|
+
def __post_init__(self) -> None:
|
|
81
|
+
if not isinstance(self.sender, EmailAddress):
|
|
82
|
+
raise EmailValidationError("sender must be an EmailAddress")
|
|
83
|
+
if not isinstance(self.subject, str):
|
|
84
|
+
raise EmailValidationError("subject must be a string")
|
|
85
|
+
_reject_line_breaks(self.subject, "subject")
|
|
86
|
+
|
|
87
|
+
for field_name in ("to", "cc", "bcc", "attachments"):
|
|
88
|
+
values = _immutable_tuple(getattr(self, field_name), field_name)
|
|
89
|
+
object.__setattr__(self, field_name, values)
|
|
90
|
+
|
|
91
|
+
recipients = (*self.to, *self.cc, *self.bcc)
|
|
92
|
+
if not recipients:
|
|
93
|
+
raise EmailValidationError("message must have at least one recipient")
|
|
94
|
+
if any(not isinstance(recipient, EmailAddress) for recipient in recipients):
|
|
95
|
+
raise EmailValidationError("recipients must be EmailAddress instances")
|
|
96
|
+
if any(not isinstance(item, EmailAttachment) for item in self.attachments):
|
|
97
|
+
raise EmailValidationError("attachments must be EmailAttachment instances")
|
|
98
|
+
if self.text_body is None and self.html_body is None:
|
|
99
|
+
raise EmailValidationError("message must have a text_body or html_body")
|
|
100
|
+
if self.text_body is not None and not isinstance(self.text_body, str):
|
|
101
|
+
raise EmailValidationError("text_body must be a string or None")
|
|
102
|
+
if self.html_body is not None and not isinstance(self.html_body, str):
|
|
103
|
+
raise EmailValidationError("html_body must be a string or None")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True, slots=True)
|
|
107
|
+
class SendResult:
|
|
108
|
+
"""Provider acceptance result; it does not imply recipient delivery."""
|
|
109
|
+
|
|
110
|
+
provider: str
|
|
111
|
+
provider_message_id: str | None = None
|
|
112
|
+
|
|
113
|
+
def __post_init__(self) -> None:
|
|
114
|
+
if not isinstance(self.provider, str) or not self.provider.strip():
|
|
115
|
+
raise EmailValidationError("provider must be a non-empty string")
|
|
116
|
+
if self.provider_message_id is not None and not isinstance(
|
|
117
|
+
self.provider_message_id, str
|
|
118
|
+
):
|
|
119
|
+
raise EmailValidationError("provider_message_id must be a string or None")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Optional email provider implementations."""
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Optional Gmail email provider adapter."""
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
from pintos_utils.email.providers.gmail.sender import (
|
|
5
|
+
GMAIL_SEND_SCOPE,
|
|
6
|
+
GmailEmailSender,
|
|
7
|
+
)
|
|
8
|
+
except ModuleNotFoundError as error:
|
|
9
|
+
if error.name is None or not error.name.startswith("google"):
|
|
10
|
+
raise
|
|
11
|
+
raise ImportError(
|
|
12
|
+
"Gmail support requires the optional dependencies; "
|
|
13
|
+
'install pintos-utils with the "gmail" extra.'
|
|
14
|
+
) from error
|
|
15
|
+
|
|
16
|
+
__all__ = ["GMAIL_SEND_SCOPE", "GmailEmailSender"]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Private MIME serialization for the Gmail adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
from email import policy
|
|
7
|
+
from email.message import EmailMessage as MimeMessage
|
|
8
|
+
from email.utils import formataddr
|
|
9
|
+
|
|
10
|
+
from pintos_utils.email.models import EmailAddress, EmailMessage
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _format_address(value: EmailAddress) -> str:
|
|
14
|
+
return formataddr((value.display_name or "", value.address))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def serialize_message(message: EmailMessage) -> str:
|
|
18
|
+
"""Return an RFC-compliant message encoded for Gmail's ``raw`` field."""
|
|
19
|
+
mime = MimeMessage(policy=policy.SMTP)
|
|
20
|
+
mime["From"] = _format_address(message.sender)
|
|
21
|
+
if message.to:
|
|
22
|
+
mime["To"] = ", ".join(_format_address(item) for item in message.to)
|
|
23
|
+
if message.cc:
|
|
24
|
+
mime["Cc"] = ", ".join(_format_address(item) for item in message.cc)
|
|
25
|
+
if message.bcc:
|
|
26
|
+
mime["Bcc"] = ", ".join(_format_address(item) for item in message.bcc)
|
|
27
|
+
mime["Subject"] = message.subject
|
|
28
|
+
|
|
29
|
+
if message.text_body is not None:
|
|
30
|
+
mime.set_content(message.text_body)
|
|
31
|
+
if message.html_body is not None:
|
|
32
|
+
mime.add_alternative(message.html_body, subtype="html")
|
|
33
|
+
else:
|
|
34
|
+
mime.set_content(message.html_body or "", subtype="html")
|
|
35
|
+
|
|
36
|
+
for attachment in message.attachments:
|
|
37
|
+
content_type = attachment.content_type or "application/octet-stream"
|
|
38
|
+
maintype, subtype = content_type.split("/", maxsplit=1)
|
|
39
|
+
mime.add_attachment(
|
|
40
|
+
attachment.content,
|
|
41
|
+
maintype=maintype,
|
|
42
|
+
subtype=subtype,
|
|
43
|
+
filename=attachment.filename,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
return base64.urlsafe_b64encode(mime.as_bytes()).decode("ascii")
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Optional Gmail API implementation of the provider-neutral email contract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, Protocol, cast
|
|
7
|
+
|
|
8
|
+
from google.auth.credentials import Credentials
|
|
9
|
+
from google.auth.transport.requests import Request
|
|
10
|
+
from googleapiclient.discovery import build # type: ignore[import-untyped]
|
|
11
|
+
from googleapiclient.errors import HttpError # type: ignore[import-untyped]
|
|
12
|
+
|
|
13
|
+
from pintos_utils.email.contracts import EmailSender
|
|
14
|
+
from pintos_utils.email.exceptions import (
|
|
15
|
+
EmailAuthenticationError,
|
|
16
|
+
EmailConfigurationError,
|
|
17
|
+
EmailProviderError,
|
|
18
|
+
)
|
|
19
|
+
from pintos_utils.email.models import EmailMessage, SendResult
|
|
20
|
+
from pintos_utils.email.providers.gmail._mime import serialize_message
|
|
21
|
+
|
|
22
|
+
GMAIL_SEND_SCOPE = "https://www.googleapis.com/auth/gmail.send"
|
|
23
|
+
_PROVIDER = "gmail"
|
|
24
|
+
_TRANSIENT_STATUSES = {429, 500, 502, 503, 504}
|
|
25
|
+
_AUTH_REASONS = {"authError", "invalidCredentials", "insufficientPermissions"}
|
|
26
|
+
_PERMANENT_REASONS = {
|
|
27
|
+
"badRequest",
|
|
28
|
+
"invalidArgument",
|
|
29
|
+
"invalidParameter",
|
|
30
|
+
"notFound",
|
|
31
|
+
"required",
|
|
32
|
+
"requestTooLarge",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _RefreshableCredentials(Protocol):
|
|
37
|
+
@property
|
|
38
|
+
def valid(self) -> bool: ...
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def refresh_token(self) -> str | None: ...
|
|
42
|
+
|
|
43
|
+
def refresh(self, request: Request) -> None: ...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _build_service(credentials: Credentials) -> Any:
|
|
47
|
+
"""Create the discovery client at the optional dependency boundary."""
|
|
48
|
+
return build(
|
|
49
|
+
"gmail",
|
|
50
|
+
"v1",
|
|
51
|
+
credentials=credentials,
|
|
52
|
+
cache_discovery=False,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _refresh_request() -> Request:
|
|
57
|
+
return Request()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _error_reason(error: HttpError) -> str | None:
|
|
61
|
+
try:
|
|
62
|
+
payload = json.loads(error.content.decode("utf-8"))
|
|
63
|
+
entries = payload.get("error", {}).get("errors", [])
|
|
64
|
+
if entries and isinstance(entries[0], dict):
|
|
65
|
+
reason = entries[0].get("reason")
|
|
66
|
+
return reason if isinstance(reason, str) else None
|
|
67
|
+
except (AttributeError, UnicodeDecodeError, json.JSONDecodeError, TypeError):
|
|
68
|
+
return None
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _http_status(error: HttpError) -> int | None:
|
|
73
|
+
try:
|
|
74
|
+
return int(error.resp.status)
|
|
75
|
+
except (AttributeError, TypeError, ValueError):
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _retryability(status: int | None, reason: str | None) -> bool | None:
|
|
80
|
+
if status in _TRANSIENT_STATUSES:
|
|
81
|
+
return True
|
|
82
|
+
if status == 403:
|
|
83
|
+
if reason in {"rateLimitExceeded", "userRateLimitExceeded"}:
|
|
84
|
+
return True
|
|
85
|
+
if reason in {"dailyLimitExceeded", "domainPolicy"}:
|
|
86
|
+
return False
|
|
87
|
+
return None
|
|
88
|
+
if reason in _PERMANENT_REASONS:
|
|
89
|
+
return False
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class GmailEmailSender(EmailSender):
|
|
94
|
+
"""Send provider-neutral messages through Gmail using consumer credentials."""
|
|
95
|
+
|
|
96
|
+
def __init__(self, credentials: Credentials) -> None:
|
|
97
|
+
if credentials is None:
|
|
98
|
+
raise EmailConfigurationError("Gmail credentials are required")
|
|
99
|
+
self._credentials = credentials
|
|
100
|
+
|
|
101
|
+
def _refresh_credentials(self) -> None:
|
|
102
|
+
credentials = cast(_RefreshableCredentials, self._credentials)
|
|
103
|
+
if credentials.valid:
|
|
104
|
+
return
|
|
105
|
+
if not credentials.refresh_token:
|
|
106
|
+
raise EmailAuthenticationError(
|
|
107
|
+
"Gmail credentials are invalid and cannot be refreshed"
|
|
108
|
+
)
|
|
109
|
+
try:
|
|
110
|
+
credentials.refresh(_refresh_request())
|
|
111
|
+
except Exception as error:
|
|
112
|
+
raise EmailAuthenticationError("Gmail credential refresh failed") from error
|
|
113
|
+
if not credentials.valid:
|
|
114
|
+
raise EmailAuthenticationError(
|
|
115
|
+
"Gmail credentials remain invalid after refresh"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def send(self, message: EmailMessage, /) -> SendResult:
|
|
119
|
+
"""Submit one message and return Gmail's acceptance identifier."""
|
|
120
|
+
self._refresh_credentials()
|
|
121
|
+
try:
|
|
122
|
+
service = _build_service(self._credentials)
|
|
123
|
+
response = (
|
|
124
|
+
service.users()
|
|
125
|
+
.messages()
|
|
126
|
+
.send(userId="me", body={"raw": serialize_message(message)})
|
|
127
|
+
.execute()
|
|
128
|
+
)
|
|
129
|
+
except HttpError as error:
|
|
130
|
+
status = _http_status(error)
|
|
131
|
+
reason = _error_reason(error)
|
|
132
|
+
if status == 401 or reason in _AUTH_REASONS:
|
|
133
|
+
raise EmailAuthenticationError("Gmail authentication failed") from error
|
|
134
|
+
raise EmailProviderError(
|
|
135
|
+
"Gmail provider request failed",
|
|
136
|
+
provider=_PROVIDER,
|
|
137
|
+
retryable=_retryability(status, reason),
|
|
138
|
+
) from error
|
|
139
|
+
except Exception as error:
|
|
140
|
+
raise EmailProviderError(
|
|
141
|
+
"Gmail provider request failed",
|
|
142
|
+
provider=_PROVIDER,
|
|
143
|
+
retryable=None,
|
|
144
|
+
) from error
|
|
145
|
+
|
|
146
|
+
message_id = response.get("id") if isinstance(response, dict) else None
|
|
147
|
+
return SendResult(
|
|
148
|
+
provider=_PROVIDER,
|
|
149
|
+
provider_message_id=message_id if isinstance(message_id, str) else None,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
__all__ = ["GMAIL_SEND_SCOPE", "GmailEmailSender"]
|
pintos_utils/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pintos-utils
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Reusable, provider-neutral utilities for Python applications.
|
|
5
|
+
Project-URL: Source, https://github.com/PINTOS-LTDA/python-utils
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Provides-Extra: gmail
|
|
10
|
+
Requires-Dist: google-api-python-client<3,>=2.150; extra == 'gmail'
|
|
11
|
+
Requires-Dist: google-auth-httplib2<1,>=0.2; extra == 'gmail'
|
|
12
|
+
Requires-Dist: google-auth<3,>=2.35; extra == 'gmail'
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# pintos-utils
|
|
16
|
+
|
|
17
|
+
Reusable Python utilities for application projects. The package is framework
|
|
18
|
+
independent and keeps provider-specific integrations optional.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
Install the published base package from PyPI:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
python -m pip install pintos-utils
|
|
26
|
+
uv add pintos-utils
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
For Gmail support and its optional Google dependencies:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
python -m pip install "pintos-utils[gmail]"
|
|
33
|
+
uv add "pintos-utils[gmail]"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Install the base package from a local checkout:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
uv pip install .
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
For editable development:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
uv pip install -e .
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Install the Gmail integration and its optional dependencies when needed:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
uv pip install "pintos-utils[gmail] @ git+ssh://git@<git-host>/<org>/pintos-utils.git@<tag-or-commit>"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The base package has no third-party runtime dependencies. Configure SSH access
|
|
55
|
+
using the consuming environment; do not put credentials in the Git URL.
|
|
56
|
+
|
|
57
|
+
## Email module
|
|
58
|
+
|
|
59
|
+
See [docs/email.md](docs/email.md) for the provider-neutral API, Gmail OAuth
|
|
60
|
+
ownership, error behavior, and installation examples.
|
|
61
|
+
|
|
62
|
+
## Development
|
|
63
|
+
|
|
64
|
+
Use Python 3.11 and uv to create the environment and install development tools:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
uv sync --group dev
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Run the tests, lint, formatting check, type check, and package build:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
uv run pytest --cov=pintos_utils --cov-report=term-missing
|
|
74
|
+
uv run ruff check .
|
|
75
|
+
uv run ruff format --check .
|
|
76
|
+
uv run mypy src
|
|
77
|
+
uv build
|
|
78
|
+
```
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
pintos_utils/__init__.py,sha256=vQ0eUARsX_kkD0RNaRTniIJ3ayrWIhrU1xCotAHvRaI,50
|
|
2
|
+
pintos_utils/email/__init__.py,sha256=aCeS8MVLavuHHNpZihTUDRFj6lYgkoNe3TYfN4MpWDc,654
|
|
3
|
+
pintos_utils/email/contracts.py,sha256=sI9SimyQ5Q1Z1etXAcrzUjfxlwla2Je9B94BEoiKlq8,411
|
|
4
|
+
pintos_utils/email/exceptions.py,sha256=nT9yGWJCG0uOk2t47_yetQNhOBtChOQirTrt6qp9hrQ,869
|
|
5
|
+
pintos_utils/email/models.py,sha256=j4BTSLaRLB0ANmW4ZxZbSDYCxq2czqtpyEtkHabog5E,4750
|
|
6
|
+
pintos_utils/email/providers/__init__.py,sha256=iS3OuQ3fpi2Rnwivbu24224VY4gG45qtZdkhkRurctw,47
|
|
7
|
+
pintos_utils/email/providers/gmail/__init__.py,sha256=h9Mcsk8qAPCXggFqE-s-AL-K5VjA4yf2bimnibrE4q0,494
|
|
8
|
+
pintos_utils/email/providers/gmail/_mime.py,sha256=4vrK7n_RIQ7I9eZK_p-Vr0cwF9q63orR8hveVVpbwrc,1655
|
|
9
|
+
pintos_utils/email/providers/gmail/sender.py,sha256=ovL-_se7nBe7Z7tvjBxzTKSFxr7vXOyA0qJBZICY7cU,5099
|
|
10
|
+
pintos_utils/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
pintos_utils-1.0.0.dist-info/METADATA,sha256=zD3X5duVhd5dTsu7kFx1yjgBmH79H2l4HABGXhVyGWw,1917
|
|
12
|
+
pintos_utils-1.0.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
13
|
+
pintos_utils-1.0.0.dist-info/licenses/LICENSE,sha256=rFZDgtLF5UO5xW0DOTxt3udLxNjbUYrbc9okVWo5eMs,1079
|
|
14
|
+
pintos_utils-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ivaney Vieira de Sales
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|