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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.