python-missive 0.2.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.
Files changed (95) hide show
  1. pymissive/__init__.py +3 -0
  2. pymissive/__main__.py +9 -0
  3. pymissive/archives/__init__.py +142 -0
  4. pymissive/archives/__main__.py +9 -0
  5. pymissive/archives/address.py +272 -0
  6. pymissive/archives/address_backends/__init__.py +29 -0
  7. pymissive/archives/address_backends/base.py +610 -0
  8. pymissive/archives/address_backends/geoapify.py +221 -0
  9. pymissive/archives/address_backends/geocode_earth.py +210 -0
  10. pymissive/archives/address_backends/google_maps.py +371 -0
  11. pymissive/archives/address_backends/here.py +348 -0
  12. pymissive/archives/address_backends/locationiq.py +271 -0
  13. pymissive/archives/address_backends/mapbox.py +314 -0
  14. pymissive/archives/address_backends/maps_co.py +257 -0
  15. pymissive/archives/address_backends/nominatim.py +348 -0
  16. pymissive/archives/address_backends/opencage.py +292 -0
  17. pymissive/archives/address_backends/pelias_mixin.py +181 -0
  18. pymissive/archives/address_backends/photon.py +322 -0
  19. pymissive/archives/cli.py +42 -0
  20. pymissive/archives/helpers.py +45 -0
  21. pymissive/archives/missive.py +64 -0
  22. pymissive/archives/providers/__init__.py +167 -0
  23. pymissive/archives/providers/apn.py +171 -0
  24. pymissive/archives/providers/ar24.py +204 -0
  25. pymissive/archives/providers/base/__init__.py +203 -0
  26. pymissive/archives/providers/base/_attachments.py +166 -0
  27. pymissive/archives/providers/base/branded.py +341 -0
  28. pymissive/archives/providers/base/common.py +781 -0
  29. pymissive/archives/providers/base/email.py +422 -0
  30. pymissive/archives/providers/base/email_message.py +85 -0
  31. pymissive/archives/providers/base/monitoring.py +150 -0
  32. pymissive/archives/providers/base/notification.py +187 -0
  33. pymissive/archives/providers/base/postal.py +742 -0
  34. pymissive/archives/providers/base/postal_defaults.py +26 -0
  35. pymissive/archives/providers/base/sms.py +213 -0
  36. pymissive/archives/providers/base/voice_call.py +82 -0
  37. pymissive/archives/providers/brevo.py +363 -0
  38. pymissive/archives/providers/certeurope.py +249 -0
  39. pymissive/archives/providers/django_email.py +182 -0
  40. pymissive/archives/providers/fcm.py +91 -0
  41. pymissive/archives/providers/laposte.py +392 -0
  42. pymissive/archives/providers/maileva.py +511 -0
  43. pymissive/archives/providers/mailgun.py +118 -0
  44. pymissive/archives/providers/messenger.py +74 -0
  45. pymissive/archives/providers/notification.py +112 -0
  46. pymissive/archives/providers/sendgrid.py +160 -0
  47. pymissive/archives/providers/ses.py +185 -0
  48. pymissive/archives/providers/signal.py +68 -0
  49. pymissive/archives/providers/slack.py +80 -0
  50. pymissive/archives/providers/smtp.py +190 -0
  51. pymissive/archives/providers/teams.py +91 -0
  52. pymissive/archives/providers/telegram.py +69 -0
  53. pymissive/archives/providers/twilio.py +310 -0
  54. pymissive/archives/providers/vonage.py +208 -0
  55. pymissive/archives/sender.py +339 -0
  56. pymissive/archives/status.py +22 -0
  57. pymissive/cli.py +42 -0
  58. pymissive/config.py +397 -0
  59. pymissive/helpers.py +0 -0
  60. pymissive/providers/apn.py +8 -0
  61. pymissive/providers/ar24.py +8 -0
  62. pymissive/providers/base/__init__.py +64 -0
  63. pymissive/providers/base/acknowledgement.py +6 -0
  64. pymissive/providers/base/attachments.py +10 -0
  65. pymissive/providers/base/branded.py +16 -0
  66. pymissive/providers/base/email.py +2 -0
  67. pymissive/providers/base/notification.py +2 -0
  68. pymissive/providers/base/postal.py +2 -0
  69. pymissive/providers/base/sms.py +2 -0
  70. pymissive/providers/base/voice_call.py +2 -0
  71. pymissive/providers/brevo.py +420 -0
  72. pymissive/providers/certeurope.py +8 -0
  73. pymissive/providers/django_email.py +8 -0
  74. pymissive/providers/fcm.py +8 -0
  75. pymissive/providers/laposte.py +8 -0
  76. pymissive/providers/maileva.py +8 -0
  77. pymissive/providers/mailgun.py +8 -0
  78. pymissive/providers/messenger.py +8 -0
  79. pymissive/providers/notification.py +8 -0
  80. pymissive/providers/partner.py +650 -0
  81. pymissive/providers/scaleway.py +498 -0
  82. pymissive/providers/sendgrid.py +8 -0
  83. pymissive/providers/ses.py +8 -0
  84. pymissive/providers/signal.py +8 -0
  85. pymissive/providers/slack.py +8 -0
  86. pymissive/providers/smtp.py +8 -0
  87. pymissive/providers/teams.py +8 -0
  88. pymissive/providers/telegram.py +8 -0
  89. pymissive/providers/twilio.py +8 -0
  90. pymissive/providers/vonage.py +8 -0
  91. python_missive-0.2.0.dist-info/METADATA +152 -0
  92. python_missive-0.2.0.dist-info/RECORD +95 -0
  93. python_missive-0.2.0.dist-info/WHEEL +5 -0
  94. python_missive-0.2.0.dist-info/entry_points.txt +2 -0
  95. python_missive-0.2.0.dist-info/top_level.txt +1 -0
pymissive/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Python Missive - Framework-agnostic messaging library."""
2
+
3
+ __version__ = "0.1.0"
pymissive/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ import sys
7
+
8
+ sys.exit(main())
9
+
@@ -0,0 +1,142 @@
1
+ """Lightweight multi-channel messaging helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, Optional, Sequence, Union
6
+
7
+ from .address import Address
8
+ from .address_backends import (BaseAddressBackend, GoogleMapsAddressBackend,
9
+ HereAddressBackend, MapboxAddressBackend,
10
+ NominatimAddressBackend, PhotonAddressBackend)
11
+ from .helpers import (
12
+ DEFAULT_MIN_ADDRESS_CONFIDENCE,
13
+ describe_address_backends,
14
+ format_phone_international,
15
+ get_address_backend_by_attribute,
16
+ get_address_backends_from_config,
17
+ get_address_by_reference,
18
+ get_provider_by_attribute,
19
+ search_addresses,
20
+ )
21
+ from .missive import Missive
22
+ from .providers.base.common import BaseProviderCommon
23
+ from .sender import MissiveSender
24
+ from .status import MissiveStatus
25
+
26
+ __all__ = [
27
+ "MissiveStatus",
28
+ "BaseProviderCommon",
29
+ "Address",
30
+ "Missive",
31
+ "MissiveSender",
32
+ "send_missive",
33
+ "format_phone_international",
34
+ "get_address_backends_from_config",
35
+ "get_address_by_reference",
36
+ "search_addresses",
37
+ "get_address_backend_by_attribute",
38
+ "describe_address_backends",
39
+ "get_provider_by_attribute",
40
+ "DEFAULT_MIN_ADDRESS_CONFIDENCE",
41
+ "BaseAddressBackend",
42
+ "GoogleMapsAddressBackend",
43
+ "HereAddressBackend",
44
+ "MapboxAddressBackend",
45
+ "NominatimAddressBackend",
46
+ "PhotonAddressBackend",
47
+ ]
48
+
49
+
50
+ def send_missive(
51
+ missive_type: str,
52
+ body: str,
53
+ subject: Optional[str] = None,
54
+ recipient_email: Optional[str] = None,
55
+ recipient_phone: Optional[str] = None,
56
+ recipient: Optional[Any] = None,
57
+ providers_config: Optional[Union[Sequence[str], Dict[str, Dict[str, Any]]]] = None,
58
+ config: Optional[Dict[str, Any]] = None,
59
+ sandbox: bool = False,
60
+ enable_fallback: bool = True,
61
+ **kwargs: Any,
62
+ ) -> Missive:
63
+ """Send a missive with automatic provider selection and fallback.
64
+
65
+ Args:
66
+ missive_type: Type of missive (EMAIL, SMS, POSTAL, etc.)
67
+ body: Message body/content
68
+ subject: Message subject (required for EMAIL)
69
+ recipient_email: Recipient email address (for EMAIL)
70
+ recipient_phone: Recipient phone number (for SMS, VOICE_CALL)
71
+ recipient: Complex recipient object with metadata (for PUSH_NOTIFICATION, etc.)
72
+ providers_config: Either:
73
+ - List of provider import paths: ["pymissive.providers.brevo.BrevoProvider"]
74
+ - Dict mapping paths to configs: {"path": {"API_KEY": "value"}}
75
+ config: Default configuration dict merged with provider-specific configs
76
+ sandbox: If True, forces sandbox mode for all providers (no real sends)
77
+ enable_fallback: If True, try next provider on failure
78
+ **kwargs: Additional options (provider_options, is_registered, etc.)
79
+
80
+ Returns:
81
+ Missive object with updated status
82
+
83
+ Raises:
84
+ RuntimeError: If all providers fail
85
+ ValueError: If required fields are missing
86
+
87
+ Example:
88
+ >>> missive = send_missive(
89
+ ... "EMAIL",
90
+ ... body="Hello world",
91
+ ... subject="Test",
92
+ ... recipient_email="user@example.com",
93
+ ... )
94
+ >>> print(missive.status)
95
+ MissiveStatus.SENT
96
+ """
97
+ missive_type = missive_type.upper()
98
+
99
+ # Validate required fields based on type
100
+ if missive_type == "EMAIL" and not recipient_email:
101
+ raise ValueError("recipient_email required for EMAIL missives")
102
+ if missive_type == "EMAIL" and not subject:
103
+ raise ValueError("subject required for EMAIL missives")
104
+ if missive_type in ("SMS", "VOICE_CALL") and not recipient_phone:
105
+ raise ValueError(f"recipient_phone required for {missive_type} missives")
106
+ if (
107
+ missive_type in ("POSTAL", "POSTAL_REGISTERED")
108
+ and not recipient
109
+ and not recipient_email
110
+ ):
111
+ raise ValueError(
112
+ f"recipient or recipient_email required for {missive_type} missives"
113
+ )
114
+
115
+ # Create missive object
116
+ missive = Missive(
117
+ missive_type=missive_type,
118
+ body=body,
119
+ subject=subject,
120
+ recipient_email=recipient_email,
121
+ recipient_phone=recipient_phone,
122
+ recipient=recipient,
123
+ provider_options=kwargs.get("provider_options", {}),
124
+ is_registered=kwargs.get("is_registered", missive_type == "POSTAL_REGISTERED"),
125
+ requires_signature=kwargs.get("requires_signature", False),
126
+ )
127
+
128
+ # Create sender and send
129
+ sender = MissiveSender(
130
+ providers_config=providers_config,
131
+ default_config=config,
132
+ sandbox=sandbox,
133
+ )
134
+
135
+ try:
136
+ sender.send(missive, enable_fallback=enable_fallback)
137
+ except Exception as e:
138
+ missive.status = MissiveStatus.FAILED
139
+ missive.error_message = str(e)
140
+ raise
141
+
142
+ return missive
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ import sys
7
+
8
+ sys.exit(main())
9
+
@@ -0,0 +1,272 @@
1
+ """Structured address representation with optional backend normalization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Dict, Mapping, Optional, Sequence, Tuple
7
+
8
+ _ADDRESS_KEYS = (
9
+ "address_line1",
10
+ "address_line2",
11
+ "address_line3",
12
+ "recipient_address_line1",
13
+ "recipient_address_line2",
14
+ "recipient_address_line3",
15
+ "sender_address_line1",
16
+ "sender_address_line2",
17
+ "sender_address_line3",
18
+ )
19
+
20
+
21
+ @dataclass(slots=True)
22
+ class Address:
23
+ """Lightweight container for structured postal addresses."""
24
+
25
+ line1: str = ""
26
+ line2: str = ""
27
+ line3: str = ""
28
+ postal_code: str = ""
29
+ city: str = ""
30
+ state: str = ""
31
+ country: str = ""
32
+ latitude: Optional[float] = None
33
+ longitude: Optional[float] = None
34
+ formatted: str = ""
35
+ backend_used: Optional[str] = None
36
+ backend_reference: Optional[str] = None
37
+ confidence: Optional[float] = None
38
+ suggestions: Sequence[Mapping[str, Any]] = field(default_factory=tuple)
39
+ warnings: Sequence[str] = field(default_factory=tuple)
40
+ errors: Sequence[str] = field(default_factory=tuple)
41
+ extras: Dict[str, Any] = field(default_factory=dict)
42
+
43
+ def is_empty(self) -> bool:
44
+ """Return True when no user-level field is populated."""
45
+ return not any(
46
+ [
47
+ self.line1,
48
+ self.line2,
49
+ self.line3,
50
+ self.postal_code,
51
+ self.city,
52
+ self.state,
53
+ self.country,
54
+ ]
55
+ )
56
+
57
+ def to_dict(self, *, include_empty: bool = False) -> Dict[str, Any]:
58
+ """Serialize the address to a JSON-friendly dictionary."""
59
+ data = {
60
+ "line1": self.line1,
61
+ "line2": self.line2,
62
+ "line3": self.line3,
63
+ "postal_code": self.postal_code,
64
+ "city": self.city,
65
+ "state": self.state,
66
+ "country": self.country,
67
+ "latitude": self.latitude,
68
+ "longitude": self.longitude,
69
+ "formatted": self.formatted,
70
+ "backend_used": self.backend_used,
71
+ "backend_reference": self.backend_reference,
72
+ "confidence": self.confidence,
73
+ "suggestions": list(self.suggestions),
74
+ "warnings": list(self.warnings),
75
+ "errors": list(self.errors),
76
+ "extras": dict(self.extras),
77
+ }
78
+ if not include_empty:
79
+ data = {
80
+ key: value
81
+ for key, value in data.items()
82
+ if value not in ("", None, [], {})
83
+ }
84
+ return data
85
+
86
+ @classmethod
87
+ def from_dict(cls, payload: Mapping[str, Any] | None) -> "Address":
88
+ """Build an Address instance from a dictionary payload."""
89
+ if not payload:
90
+ return cls()
91
+
92
+ def _extract_line(key: str) -> str:
93
+ for alias in (
94
+ key,
95
+ key.replace("line", "_line"),
96
+ f"recipient_{key}",
97
+ f"sender_{key}",
98
+ ):
99
+ if alias in payload and payload[alias]:
100
+ return str(payload[alias])
101
+ return ""
102
+
103
+ return cls(
104
+ line1=_extract_line("line1"),
105
+ line2=_extract_line("line2"),
106
+ line3=_extract_line("line3"),
107
+ postal_code=str(payload.get("postal_code") or payload.get("zip", "")),
108
+ city=str(payload.get("city") or payload.get("town") or ""),
109
+ state=str(payload.get("state") or payload.get("region") or ""),
110
+ country=str(payload.get("country") or payload.get("country_code") or ""),
111
+ latitude=_safe_float(payload.get("latitude")),
112
+ longitude=_safe_float(payload.get("longitude")),
113
+ formatted=str(
114
+ payload.get("formatted_address") or payload.get("formatted") or ""
115
+ ),
116
+ backend_used=payload.get("backend_used") or payload.get("backend"),
117
+ backend_reference=payload.get("backend_reference")
118
+ or payload.get("reference_id")
119
+ or payload.get("address_reference"),
120
+ confidence=_safe_float(payload.get("confidence")),
121
+ suggestions=tuple(payload.get("suggestions") or ()),
122
+ warnings=tuple(payload.get("warnings") or ()),
123
+ errors=tuple(payload.get("errors") or ()),
124
+ extras=_extract_extras(payload),
125
+ )
126
+
127
+ def merge(self, other: "Address", *, prefer_other: bool = True) -> "Address":
128
+ """Merge two addresses, optionally preferring `other` values when provided."""
129
+
130
+ def _select(current: Any, new_value: Any) -> Any:
131
+ if prefer_other and new_value not in ("", None):
132
+ return new_value
133
+ if not prefer_other and current not in ("", None):
134
+ return current
135
+ return new_value if new_value not in ("", None) else current
136
+
137
+ merged = Address(
138
+ line1=_select(self.line1, other.line1),
139
+ line2=_select(self.line2, other.line2),
140
+ line3=_select(self.line3, other.line3),
141
+ postal_code=_select(self.postal_code, other.postal_code),
142
+ city=_select(self.city, other.city),
143
+ state=_select(self.state, other.state),
144
+ country=_select(self.country, other.country),
145
+ latitude=_select(self.latitude, other.latitude),
146
+ longitude=_select(self.longitude, other.longitude),
147
+ formatted=_select(self.formatted, other.formatted),
148
+ backend_used=other.backend_used or self.backend_used,
149
+ backend_reference=other.backend_reference or self.backend_reference,
150
+ confidence=_select(self.confidence, other.confidence),
151
+ suggestions=other.suggestions or self.suggestions,
152
+ warnings=other.warnings or self.warnings,
153
+ errors=other.errors or self.errors,
154
+ extras={**self.extras, **other.extras},
155
+ )
156
+ return merged
157
+
158
+ @classmethod
159
+ def normalize_with_backends(
160
+ cls,
161
+ backends_config: Sequence[Dict[str, Any]] | None,
162
+ *,
163
+ _operation: str = "validate",
164
+ min_confidence: Optional[float] = None,
165
+ **address_kwargs: Any,
166
+ ) -> Tuple["Address", Dict[str, Any]]:
167
+ """Call configured backends and return a normalized Address plus raw payload."""
168
+ if not backends_config:
169
+ return cls.from_dict(address_kwargs), {
170
+ "error": "No address backends configured"
171
+ }
172
+
173
+ from .helpers import search_addresses
174
+
175
+ # Build query string from address components
176
+ address_parts = [
177
+ address_kwargs.get("address_line1"),
178
+ address_kwargs.get("address_line2"),
179
+ address_kwargs.get("address_line3"),
180
+ address_kwargs.get("postal_code"),
181
+ address_kwargs.get("city"),
182
+ address_kwargs.get("state"),
183
+ ]
184
+ query = ", ".join(filter(None, address_parts)) or ""
185
+
186
+ # If query is empty but we have components, try a simpler query
187
+ if not query:
188
+ query = address_kwargs.get("address_line1") or ""
189
+
190
+ search_result = search_addresses(
191
+ backends_config=backends_config,
192
+ query=query,
193
+ country=address_kwargs.get("country"),
194
+ min_confidence=min_confidence,
195
+ limit=1,
196
+ )
197
+
198
+ # Get first result or use error payload
199
+ results = search_result.get("results", [])
200
+ if results:
201
+ payload = results[0]
202
+ normalized_block = payload.get("normalized_address") or payload
203
+ else:
204
+ payload = search_result
205
+ normalized_block = {}
206
+ normalized = cls.from_dict(
207
+ {
208
+ **address_kwargs,
209
+ **_flatten_address_dict(normalized_block),
210
+ "backend_used": payload.get("backend_used"),
211
+ "confidence": payload.get("confidence"),
212
+ "warnings": payload.get("warnings"),
213
+ "errors": payload.get("errors"),
214
+ "suggestions": payload.get("suggestions"),
215
+ "backend_reference": payload.get("backend_reference")
216
+ or payload.get("reference_id")
217
+ or payload.get("address_reference"),
218
+ }
219
+ )
220
+ return normalized, payload
221
+
222
+
223
+ def _extract_extras(payload: Mapping[str, Any]) -> Dict[str, Any]:
224
+ extras = dict(payload.get("extras") or {})
225
+ for key in ("latitude", "longitude", "formatted_address"):
226
+ if key in payload and key not in extras:
227
+ extras[key] = payload[key]
228
+ for key in _ADDRESS_KEYS:
229
+ if key in payload and payload[key]:
230
+ extras.setdefault(key, payload[key])
231
+ return extras
232
+
233
+
234
+ def _flatten_address_dict(payload: Mapping[str, Any]) -> Dict[str, Any]:
235
+ flat: Dict[str, Any] = {}
236
+ for key in ("line1", "line2", "line3", "postal_code", "city", "state", "country"):
237
+ if key in payload and payload[key]:
238
+ flat[key] = payload[key]
239
+ suffixes = (
240
+ "line1",
241
+ "line2",
242
+ "line3",
243
+ "postal_code",
244
+ "city",
245
+ "state",
246
+ "country",
247
+ )
248
+ for key in _ADDRESS_KEYS:
249
+ value = payload.get(key)
250
+ if value and key.endswith(suffixes):
251
+ alias = key.split("_")[-1]
252
+ flat.setdefault(alias, value)
253
+ if "formatted_address" in payload and payload["formatted_address"]:
254
+ flat["formatted"] = payload["formatted_address"]
255
+ if "latitude" in payload:
256
+ flat["latitude"] = payload.get("latitude")
257
+ if "longitude" in payload:
258
+ flat["longitude"] = payload.get("longitude")
259
+ if "backend_reference" in payload and payload["backend_reference"]:
260
+ flat["backend_reference"] = payload["backend_reference"]
261
+ elif "address_reference" in payload and payload["address_reference"]:
262
+ flat["backend_reference"] = payload["address_reference"]
263
+ return flat
264
+
265
+
266
+ def _safe_float(value: Any) -> Optional[float]:
267
+ if value in (None, ""):
268
+ return None
269
+ try:
270
+ return float(value)
271
+ except (TypeError, ValueError):
272
+ return None
@@ -0,0 +1,29 @@
1
+ """Address verification backends."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .base import BaseAddressBackend
6
+ from .geocode_earth import GeocodeEarthAddressBackend
7
+ from .geoapify import GeoapifyAddressBackend
8
+ from .google_maps import GoogleMapsAddressBackend
9
+ from .here import HereAddressBackend
10
+ from .locationiq import LocationIQAddressBackend
11
+ from .maps_co import MapsCoAddressBackend
12
+ from .mapbox import MapboxAddressBackend
13
+ from .nominatim import NominatimAddressBackend
14
+ from .opencage import OpenCageAddressBackend
15
+ from .photon import PhotonAddressBackend
16
+
17
+ __all__ = [
18
+ "BaseAddressBackend",
19
+ "GeocodeEarthAddressBackend",
20
+ "GeoapifyAddressBackend",
21
+ "GoogleMapsAddressBackend",
22
+ "HereAddressBackend",
23
+ "LocationIQAddressBackend",
24
+ "MapsCoAddressBackend",
25
+ "MapboxAddressBackend",
26
+ "NominatimAddressBackend",
27
+ "OpenCageAddressBackend",
28
+ "PhotonAddressBackend",
29
+ ]