fh-saas 0.9.5__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.
- fh_saas/__init__.py +1 -0
- fh_saas/_modidx.py +201 -0
- fh_saas/core.py +9 -0
- fh_saas/db_host.py +153 -0
- fh_saas/db_tenant.py +142 -0
- fh_saas/utils_api.py +109 -0
- fh_saas/utils_auth.py +647 -0
- fh_saas/utils_bgtsk.py +112 -0
- fh_saas/utils_blog.py +147 -0
- fh_saas/utils_db.py +151 -0
- fh_saas/utils_email.py +327 -0
- fh_saas/utils_graphql.py +257 -0
- fh_saas/utils_log.py +56 -0
- fh_saas/utils_polars_mapper.py +134 -0
- fh_saas/utils_seo.py +230 -0
- fh_saas/utils_sql.py +320 -0
- fh_saas/utils_sync.py +115 -0
- fh_saas/utils_webhook.py +216 -0
- fh_saas/utils_workflow.py +23 -0
- fh_saas-0.9.5.dist-info/METADATA +274 -0
- fh_saas-0.9.5.dist-info/RECORD +25 -0
- fh_saas-0.9.5.dist-info/WHEEL +5 -0
- fh_saas-0.9.5.dist-info/entry_points.txt +2 -0
- fh_saas-0.9.5.dist-info/licenses/LICENSE +201 -0
- fh_saas-0.9.5.dist-info/top_level.txt +1 -0
fh_saas/utils_email.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""Send emails via SMTP using markdown templates with markdown_merge."""
|
|
2
|
+
|
|
3
|
+
# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/05_utils_email.ipynb.
|
|
4
|
+
|
|
5
|
+
# %% ../nbs/05_utils_email.ipynb 2
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
from typing import Optional, List, Dict, Any
|
|
8
|
+
import os
|
|
9
|
+
import logging
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from nbdev.showdoc import show_doc
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
# %% auto 0
|
|
16
|
+
__all__ = ['logger', 'get_smtp_config', 'get_template_path', 'load_template', 'send_email', 'send_batch_emails',
|
|
17
|
+
'send_welcome_email', 'send_invitation_email', 'send_password_reset_email']
|
|
18
|
+
|
|
19
|
+
# %% ../nbs/05_utils_email.ipynb 5
|
|
20
|
+
def get_smtp_config() -> Dict[str, Any]:
|
|
21
|
+
"""Load SMTP configuration from environment variables."""
|
|
22
|
+
smtp_host = os.getenv('SMTP_HOST')
|
|
23
|
+
smtp_port = int(os.getenv('SMTP_PORT', '587'))
|
|
24
|
+
smtp_user = os.getenv('SMTP_USER')
|
|
25
|
+
smtp_pass = os.getenv('SMTP_PASSWORD')
|
|
26
|
+
from_email = os.getenv('SMTP_MAIL_FROM')
|
|
27
|
+
|
|
28
|
+
# SSL and TLS settings - check string and boolean values
|
|
29
|
+
smtp_ssl = os.getenv('SMTP_SSL', 'False')
|
|
30
|
+
use_ssl = smtp_ssl.lower() in ('true', '1', 'yes') if isinstance(smtp_ssl, str) else bool(smtp_ssl)
|
|
31
|
+
|
|
32
|
+
smtp_starttls = os.getenv('SMTP_STARTTLS', 'True')
|
|
33
|
+
use_tls = smtp_starttls.lower() in ('true', '1', 'yes') if isinstance(smtp_starttls, str) else bool(smtp_starttls)
|
|
34
|
+
|
|
35
|
+
# If SSL is enabled, disable TLS
|
|
36
|
+
if use_ssl:
|
|
37
|
+
use_tls = False
|
|
38
|
+
|
|
39
|
+
# Validate required fields
|
|
40
|
+
if not all([smtp_host, smtp_user, smtp_pass, from_email]):
|
|
41
|
+
raise ValueError(
|
|
42
|
+
"SMTP_HOST, SMTP_USER, SMTP_PASSWORD, and SMTP_MAIL_FROM "
|
|
43
|
+
"environment variables are required"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
'host': smtp_host,
|
|
48
|
+
'port': smtp_port,
|
|
49
|
+
'user': smtp_user,
|
|
50
|
+
'password': smtp_pass,
|
|
51
|
+
'from_email': from_email,
|
|
52
|
+
'from_name': '', # Optional, can be added as SMTP_FROM_NAME if needed
|
|
53
|
+
'use_ssl': use_ssl,
|
|
54
|
+
'use_tls': use_tls
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# %% ../nbs/05_utils_email.ipynb 8
|
|
58
|
+
def get_template_path(
|
|
59
|
+
template_name: str, # Template basename (e.g., 'welcome', 'invitation')
|
|
60
|
+
custom_template_path: Optional[str | Path] = None # Custom path overrides package templates
|
|
61
|
+
) -> Path:
|
|
62
|
+
"""Get absolute path to email template file."""
|
|
63
|
+
if custom_template_path:
|
|
64
|
+
custom_path = Path(custom_template_path)
|
|
65
|
+
if not custom_path.exists():
|
|
66
|
+
raise FileNotFoundError(f"Custom template not found: {custom_path}")
|
|
67
|
+
return custom_path
|
|
68
|
+
else:
|
|
69
|
+
package_dir = Path(__file__).parent if '__file__' in globals() else Path.cwd()
|
|
70
|
+
template_path = package_dir / 'templates' / f'{template_name}.md'
|
|
71
|
+
|
|
72
|
+
if not template_path.exists():
|
|
73
|
+
raise FileNotFoundError(
|
|
74
|
+
f"Template not found: {template_path}\n"
|
|
75
|
+
f"Available templates: welcome, invitation, password_reset\n"
|
|
76
|
+
f"Or provide custom_template_path parameter"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
return template_path
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def load_template(
|
|
83
|
+
template_name: str, # Template basename (e.g., 'welcome')
|
|
84
|
+
custom_template_path: Optional[str | Path] = None # Custom path overrides package templates
|
|
85
|
+
) -> str:
|
|
86
|
+
"""Load markdown email template as string."""
|
|
87
|
+
template_path = get_template_path(template_name, custom_template_path)
|
|
88
|
+
return template_path.read_text(encoding='utf-8')
|
|
89
|
+
|
|
90
|
+
# %% ../nbs/05_utils_email.ipynb 12
|
|
91
|
+
def send_email(
|
|
92
|
+
to_email: str, # Recipient email address
|
|
93
|
+
to_name: str, # Recipient display name
|
|
94
|
+
subject: str, # Email subject line
|
|
95
|
+
template_name: str, # Template name: 'welcome', 'invitation', 'password_reset'
|
|
96
|
+
template_vars: Dict[str, str], # Variables to substitute in template
|
|
97
|
+
test: bool = False, # If True, prints email instead of sending
|
|
98
|
+
smtp_config: Optional[Dict[str, Any]] = None, # Custom SMTP config (defaults to env vars)
|
|
99
|
+
custom_template_path: Optional[str | Path] = None # Custom template path
|
|
100
|
+
) -> Dict[str, Any]:
|
|
101
|
+
"""Send single email using markdown template with variable substitution."""
|
|
102
|
+
try:
|
|
103
|
+
from markdown_merge import MarkdownMerge, get_addr
|
|
104
|
+
except ImportError:
|
|
105
|
+
raise ImportError(
|
|
106
|
+
"markdown_merge not installed. Install with: pip install markdown_merge"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Load SMTP config
|
|
110
|
+
if smtp_config is None:
|
|
111
|
+
config = get_smtp_config()
|
|
112
|
+
else:
|
|
113
|
+
config = smtp_config
|
|
114
|
+
|
|
115
|
+
# Prepare SMTP config for markdown_merge
|
|
116
|
+
mm_smtp_cfg = {
|
|
117
|
+
'host': config['host'],
|
|
118
|
+
'port': config['port'],
|
|
119
|
+
'user': config['user'],
|
|
120
|
+
'password': config['password'],
|
|
121
|
+
'use_ssl': config.get('use_ssl', False),
|
|
122
|
+
'use_tls': config.get('use_tls', True)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
# Load template (supports custom path)
|
|
126
|
+
template = load_template(template_name, custom_template_path)
|
|
127
|
+
|
|
128
|
+
# Prepare addresses
|
|
129
|
+
from_addr = get_addr(config['from_email'], config.get('from_name', ''))
|
|
130
|
+
to_addr = get_addr(to_email, to_name)
|
|
131
|
+
|
|
132
|
+
# Create MarkdownMerge instance
|
|
133
|
+
mm = MarkdownMerge(
|
|
134
|
+
[to_addr],
|
|
135
|
+
from_addr,
|
|
136
|
+
subject,
|
|
137
|
+
template,
|
|
138
|
+
smtp_cfg=mm_smtp_cfg,
|
|
139
|
+
inserts=[template_vars],
|
|
140
|
+
test=test
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Send email
|
|
144
|
+
try:
|
|
145
|
+
mm.send_msgs()
|
|
146
|
+
|
|
147
|
+
if test:
|
|
148
|
+
logger.info(f"Test email printed for {to_email}")
|
|
149
|
+
return {
|
|
150
|
+
"status": "test",
|
|
151
|
+
"to_email": to_email,
|
|
152
|
+
"message": "Email printed (test mode)"
|
|
153
|
+
}
|
|
154
|
+
else:
|
|
155
|
+
logger.info(f"Email sent successfully to {to_email}")
|
|
156
|
+
return {
|
|
157
|
+
"status": "success",
|
|
158
|
+
"to_email": to_email,
|
|
159
|
+
"subject": subject
|
|
160
|
+
}
|
|
161
|
+
except Exception as e:
|
|
162
|
+
logger.error(f"Failed to send email to {to_email}: {str(e)}")
|
|
163
|
+
return {
|
|
164
|
+
"status": "error",
|
|
165
|
+
"to_email": to_email,
|
|
166
|
+
"error": str(e)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
# %% ../nbs/05_utils_email.ipynb 15
|
|
170
|
+
def send_batch_emails(
|
|
171
|
+
recipients: List[Dict[str, str]], # List of dicts with 'email' and 'name' keys
|
|
172
|
+
subject: str, # Email subject line
|
|
173
|
+
template_name: str, # Template name: 'welcome', 'invitation', 'password_reset'
|
|
174
|
+
template_vars_list: List[Dict[str, str]], # List of variable dicts, one per recipient
|
|
175
|
+
test: bool = False, # If True, prints emails instead of sending
|
|
176
|
+
pause: float = 0.2, # Seconds between emails (rate limiting)
|
|
177
|
+
smtp_config: Optional[Dict[str, Any]] = None, # Custom SMTP config
|
|
178
|
+
custom_template_path: Optional[str | Path] = None # Custom template path
|
|
179
|
+
) -> List[Dict[str, Any]]:
|
|
180
|
+
"""Send personalized emails to multiple recipients."""
|
|
181
|
+
try:
|
|
182
|
+
from markdown_merge import MarkdownMerge, get_addr
|
|
183
|
+
except ImportError:
|
|
184
|
+
raise ImportError(
|
|
185
|
+
"markdown_merge not installed. Install with: pip install markdown_merge"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Load SMTP config
|
|
189
|
+
if smtp_config is None:
|
|
190
|
+
config = get_smtp_config()
|
|
191
|
+
else:
|
|
192
|
+
config = smtp_config
|
|
193
|
+
|
|
194
|
+
# Prepare SMTP config for markdown_merge
|
|
195
|
+
mm_smtp_cfg = {
|
|
196
|
+
'host': config['host'],
|
|
197
|
+
'port': config['port'],
|
|
198
|
+
'user': config['user'],
|
|
199
|
+
'password': config['password'],
|
|
200
|
+
'use_ssl': config.get('use_ssl', False),
|
|
201
|
+
'use_tls': config.get('use_tls', True)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
# Load template (supports custom path)
|
|
205
|
+
template = load_template(template_name, custom_template_path)
|
|
206
|
+
|
|
207
|
+
# Prepare addresses
|
|
208
|
+
from_addr = get_addr(config['from_email'], config.get('from_name', ''))
|
|
209
|
+
to_addrs = [get_addr(r['email'], r['name']) for r in recipients]
|
|
210
|
+
|
|
211
|
+
# Create MarkdownMerge instance
|
|
212
|
+
mm = MarkdownMerge(
|
|
213
|
+
to_addrs,
|
|
214
|
+
from_addr,
|
|
215
|
+
subject,
|
|
216
|
+
template,
|
|
217
|
+
smtp_cfg=mm_smtp_cfg,
|
|
218
|
+
inserts=template_vars_list,
|
|
219
|
+
test=test
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
# Send emails
|
|
223
|
+
results = []
|
|
224
|
+
try:
|
|
225
|
+
mm.send_msgs(pause=pause)
|
|
226
|
+
|
|
227
|
+
for recipient in recipients:
|
|
228
|
+
if test:
|
|
229
|
+
results.append({
|
|
230
|
+
"status": "test",
|
|
231
|
+
"to_email": recipient['email'],
|
|
232
|
+
"message": "Email printed (test mode)"
|
|
233
|
+
})
|
|
234
|
+
else:
|
|
235
|
+
results.append({
|
|
236
|
+
"status": "success",
|
|
237
|
+
"to_email": recipient['email'],
|
|
238
|
+
"subject": subject
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
logger.info(f"Sent {len(recipients)} emails successfully")
|
|
242
|
+
except Exception as e:
|
|
243
|
+
logger.error(f"Batch send failed: {str(e)}")
|
|
244
|
+
for recipient in recipients:
|
|
245
|
+
results.append({
|
|
246
|
+
"status": "error",
|
|
247
|
+
"to_email": recipient['email'],
|
|
248
|
+
"error": str(e)
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
return results
|
|
252
|
+
|
|
253
|
+
# %% ../nbs/05_utils_email.ipynb 18
|
|
254
|
+
def send_welcome_email(
|
|
255
|
+
to_email: str, # Recipient email address
|
|
256
|
+
to_name: str, # Recipient display name
|
|
257
|
+
user_name: str, # User's name for personalization
|
|
258
|
+
tenant_name: str, # Tenant/organization name
|
|
259
|
+
dashboard_url: str, # URL to user's dashboard
|
|
260
|
+
test: bool = False, # If True, prints email instead of sending
|
|
261
|
+
custom_template_path: Optional[str | Path] = None # Custom welcome.md template path
|
|
262
|
+
) -> Dict[str, Any]:
|
|
263
|
+
"""Send welcome email to new user. Template vars: {user_name}, {tenant_name}, {dashboard_url}, {to_email}"""
|
|
264
|
+
return send_email(
|
|
265
|
+
to_email=to_email,
|
|
266
|
+
to_name=to_name,
|
|
267
|
+
subject=f"Welcome to {tenant_name}!",
|
|
268
|
+
template_name='welcome',
|
|
269
|
+
template_vars={
|
|
270
|
+
'user_name': user_name,
|
|
271
|
+
'tenant_name': tenant_name,
|
|
272
|
+
'dashboard_url': dashboard_url,
|
|
273
|
+
'to_email': to_email
|
|
274
|
+
},
|
|
275
|
+
test=test,
|
|
276
|
+
custom_template_path=custom_template_path
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
# %% ../nbs/05_utils_email.ipynb 20
|
|
280
|
+
def send_invitation_email(
|
|
281
|
+
to_email: str, # Recipient email address
|
|
282
|
+
to_name: str, # Recipient display name
|
|
283
|
+
inviter_name: str, # Name of person sending invitation
|
|
284
|
+
tenant_name: str, # Tenant/organization name
|
|
285
|
+
invitation_url: str, # URL to accept invitation
|
|
286
|
+
test: bool = False, # If True, prints email instead of sending
|
|
287
|
+
custom_template_path: Optional[str | Path] = None # Custom invitation.md template path
|
|
288
|
+
) -> Dict[str, Any]:
|
|
289
|
+
"""Send invitation email. Template vars: {inviter_name}, {tenant_name}, {invitation_url}, {to_email}"""
|
|
290
|
+
return send_email(
|
|
291
|
+
to_email=to_email,
|
|
292
|
+
to_name=to_name,
|
|
293
|
+
subject=f"{inviter_name} invited you to join {tenant_name}",
|
|
294
|
+
template_name='invitation',
|
|
295
|
+
template_vars={
|
|
296
|
+
'inviter_name': inviter_name,
|
|
297
|
+
'tenant_name': tenant_name,
|
|
298
|
+
'invitation_url': invitation_url,
|
|
299
|
+
'to_email': to_email
|
|
300
|
+
},
|
|
301
|
+
test=test,
|
|
302
|
+
custom_template_path=custom_template_path
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
# %% ../nbs/05_utils_email.ipynb 22
|
|
306
|
+
def send_password_reset_email(
|
|
307
|
+
to_email: str, # Recipient email address
|
|
308
|
+
to_name: str, # Recipient display name
|
|
309
|
+
user_name: str, # User's name for personalization
|
|
310
|
+
reset_url: str, # Secure password reset URL
|
|
311
|
+
test: bool = False, # If True, prints email instead of sending
|
|
312
|
+
custom_template_path: Optional[str | Path] = None # Custom password_reset.md template path
|
|
313
|
+
) -> Dict[str, Any]:
|
|
314
|
+
"""Send password reset email. Template vars: {user_name}, {reset_url}, {to_email}"""
|
|
315
|
+
return send_email(
|
|
316
|
+
to_email=to_email,
|
|
317
|
+
to_name=to_name,
|
|
318
|
+
subject="Reset Your Password",
|
|
319
|
+
template_name='password_reset',
|
|
320
|
+
template_vars={
|
|
321
|
+
'user_name': user_name,
|
|
322
|
+
'reset_url': reset_url,
|
|
323
|
+
'to_email': to_email
|
|
324
|
+
},
|
|
325
|
+
test=test,
|
|
326
|
+
custom_template_path=custom_template_path
|
|
327
|
+
)
|
fh_saas/utils_graphql.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""GraphQL client with streaming pagination for memory-efficient data fetching."""
|
|
2
|
+
|
|
3
|
+
# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/08_utils_graphql.ipynb.
|
|
4
|
+
|
|
5
|
+
# %% ../nbs/08_utils_graphql.ipynb 2
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
from .utils_api import AsyncAPIClient, bearer_token_auth
|
|
8
|
+
from typing import AsyncGenerator, Dict, Any, List, Optional
|
|
9
|
+
from contextlib import asynccontextmanager
|
|
10
|
+
import logging
|
|
11
|
+
from nbdev.showdoc import show_doc
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
# %% auto 0
|
|
16
|
+
__all__ = ['logger', 'GraphQLClient', 'execute_graphql']
|
|
17
|
+
|
|
18
|
+
# %% ../nbs/08_utils_graphql.ipynb 5
|
|
19
|
+
class GraphQLClient:
|
|
20
|
+
"""GraphQL client with streaming pagination for memory-efficient data fetching."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
api_client: AsyncAPIClient, # Initialized AsyncAPIClient instance
|
|
25
|
+
endpoint: str = '' # GraphQL endpoint path (default: '' for base URL)
|
|
26
|
+
):
|
|
27
|
+
self.api_client = api_client
|
|
28
|
+
self.endpoint = endpoint
|
|
29
|
+
self._owns_api = False # Track if we created the API client
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
@asynccontextmanager
|
|
33
|
+
async def from_url(
|
|
34
|
+
cls,
|
|
35
|
+
url: str, # GraphQL endpoint URL
|
|
36
|
+
bearer_token: str | None = None, # Optional bearer token for Authorization header
|
|
37
|
+
headers: dict | None = None # Optional additional headers
|
|
38
|
+
):
|
|
39
|
+
"""Create GraphQL client directly from URL with optional bearer token.
|
|
40
|
+
|
|
41
|
+
Usage:
|
|
42
|
+
async with GraphQLClient.from_url(url, bearer_token=token) as gql:
|
|
43
|
+
result = await gql.execute(query)
|
|
44
|
+
"""
|
|
45
|
+
# Merge auth with any additional headers
|
|
46
|
+
auth_headers = {}
|
|
47
|
+
if bearer_token:
|
|
48
|
+
auth_headers.update(bearer_token_auth(bearer_token))
|
|
49
|
+
if headers:
|
|
50
|
+
auth_headers.update(headers)
|
|
51
|
+
|
|
52
|
+
async with AsyncAPIClient(
|
|
53
|
+
url,
|
|
54
|
+
auth_headers=auth_headers or None
|
|
55
|
+
) as api:
|
|
56
|
+
client = cls(api)
|
|
57
|
+
client._owns_api = True
|
|
58
|
+
yield client
|
|
59
|
+
|
|
60
|
+
async def execute(
|
|
61
|
+
self,
|
|
62
|
+
query: str, # GraphQL query or mutation string
|
|
63
|
+
variables: dict = None # Query/mutation variables
|
|
64
|
+
) -> Dict[str, Any]:
|
|
65
|
+
"""Execute a GraphQL query or mutation and return the data portion.
|
|
66
|
+
|
|
67
|
+
This is a unified method that works for both queries and mutations.
|
|
68
|
+
Returns only the 'data' portion of the response for convenience.
|
|
69
|
+
"""
|
|
70
|
+
payload = {'query': query}
|
|
71
|
+
if variables:
|
|
72
|
+
payload['variables'] = variables
|
|
73
|
+
|
|
74
|
+
response = await self.api_client.request(
|
|
75
|
+
method='POST',
|
|
76
|
+
endpoint=self.endpoint,
|
|
77
|
+
json=payload
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
data = response.json()
|
|
81
|
+
|
|
82
|
+
# Check for GraphQL errors
|
|
83
|
+
if 'errors' in data:
|
|
84
|
+
error_msg = data['errors'][0].get('message', 'Unknown GraphQL error')
|
|
85
|
+
raise ValueError(f"GraphQL error: {error_msg}")
|
|
86
|
+
|
|
87
|
+
return data.get('data', {})
|
|
88
|
+
|
|
89
|
+
async def execute_query(
|
|
90
|
+
self,
|
|
91
|
+
query: str, # GraphQL query string
|
|
92
|
+
variables: dict = None # Query variables
|
|
93
|
+
) -> Dict[str, Any]:
|
|
94
|
+
"""Execute a single GraphQL query and return the JSON response."""
|
|
95
|
+
payload = {'query': query}
|
|
96
|
+
if variables:
|
|
97
|
+
payload['variables'] = variables
|
|
98
|
+
|
|
99
|
+
response = await self.api_client.request(
|
|
100
|
+
method='POST',
|
|
101
|
+
endpoint=self.endpoint,
|
|
102
|
+
json=payload
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
data = response.json()
|
|
106
|
+
|
|
107
|
+
# Check for GraphQL errors
|
|
108
|
+
if 'errors' in data:
|
|
109
|
+
error_msg = data['errors'][0].get('message', 'Unknown GraphQL error')
|
|
110
|
+
raise ValueError(f"GraphQL error: {error_msg}")
|
|
111
|
+
|
|
112
|
+
return data
|
|
113
|
+
|
|
114
|
+
async def execute_mutation(
|
|
115
|
+
self,
|
|
116
|
+
mutation: str, # GraphQL mutation string
|
|
117
|
+
variables: dict = None # Mutation variables
|
|
118
|
+
) -> Dict[str, Any]:
|
|
119
|
+
"""Execute a GraphQL mutation (alias for execute_query)."""
|
|
120
|
+
return await self.execute_query(mutation, variables)
|
|
121
|
+
|
|
122
|
+
async def fetch_pages_relay(
|
|
123
|
+
self,
|
|
124
|
+
query: str, # GraphQL query with $first and $after variables
|
|
125
|
+
connection_path: str, # Dot-notation path to connection object (e.g., "transactionsConnection")
|
|
126
|
+
variables: dict | None = None, # Base variables for the query (excluding first/after)
|
|
127
|
+
page_size: int = 100, # Number of items per page
|
|
128
|
+
max_pages: int | None = None # Maximum pages to fetch (None for unlimited)
|
|
129
|
+
) -> list[dict]:
|
|
130
|
+
"""Fetch all pages from a Relay-style paginated GraphQL query.
|
|
131
|
+
|
|
132
|
+
Example query structure:
|
|
133
|
+
query($first: Int, $after: String, $filter: TransactionFilter) {
|
|
134
|
+
transactionsConnection(first: $first, after: $after, filter: $filter) {
|
|
135
|
+
edges { node { id amount } }
|
|
136
|
+
pageInfo { hasNextPage endCursor }
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
List of all nodes from all pages
|
|
142
|
+
"""
|
|
143
|
+
all_nodes = []
|
|
144
|
+
cursor = None
|
|
145
|
+
page_count = 0
|
|
146
|
+
base_vars = variables or {}
|
|
147
|
+
|
|
148
|
+
while True:
|
|
149
|
+
if max_pages and page_count >= max_pages:
|
|
150
|
+
logger.info(f"Reached max_pages limit of {max_pages}")
|
|
151
|
+
break
|
|
152
|
+
|
|
153
|
+
page_vars = {**base_vars, "first": page_size, "after": cursor}
|
|
154
|
+
result = await self.execute(query, page_vars)
|
|
155
|
+
|
|
156
|
+
# Navigate to connection using dot-notation path
|
|
157
|
+
connection = result
|
|
158
|
+
for key in connection_path.split('.'):
|
|
159
|
+
connection = connection.get(key, {})
|
|
160
|
+
|
|
161
|
+
edges = connection.get('edges', [])
|
|
162
|
+
page_info = connection.get('pageInfo', {})
|
|
163
|
+
|
|
164
|
+
for edge in edges:
|
|
165
|
+
all_nodes.append(edge['node'])
|
|
166
|
+
|
|
167
|
+
page_count += 1
|
|
168
|
+
logger.info(f"Fetched page {page_count} with {len(edges)} edges")
|
|
169
|
+
|
|
170
|
+
if not page_info.get('hasNextPage'):
|
|
171
|
+
break
|
|
172
|
+
|
|
173
|
+
cursor = page_info.get('endCursor')
|
|
174
|
+
|
|
175
|
+
return all_nodes
|
|
176
|
+
|
|
177
|
+
async def fetch_pages_generator(
|
|
178
|
+
self,
|
|
179
|
+
query_template: str, # GraphQL query with $variables placeholders
|
|
180
|
+
variables: dict, # Initial variables (must include cursor key)
|
|
181
|
+
items_path: list[str], # JSONPath to list of items (e.g., ['data', 'users', 'nodes'])
|
|
182
|
+
cursor_path: list[str], # JSONPath to next cursor (e.g., ['data', 'users', 'pageInfo', 'endCursor'])
|
|
183
|
+
has_next_path: list[str] = None, # Optional path to hasNextPage boolean (if None, checks cursor != None)
|
|
184
|
+
cursor_var: str = 'cursor' # Variable name for cursor in query (default: 'cursor')
|
|
185
|
+
) -> AsyncGenerator[List[Dict], None]:
|
|
186
|
+
"""Stream paginated GraphQL data page-by-page using async generator."""
|
|
187
|
+
has_next = True
|
|
188
|
+
page_count = 0
|
|
189
|
+
|
|
190
|
+
while has_next:
|
|
191
|
+
# Execute query with current cursor
|
|
192
|
+
response = await self.execute_query(query_template, variables)
|
|
193
|
+
|
|
194
|
+
# Extract items using path
|
|
195
|
+
items = self._get_nested_value(response, items_path)
|
|
196
|
+
if not items:
|
|
197
|
+
logger.warning(f"No items found at path {items_path}")
|
|
198
|
+
break
|
|
199
|
+
|
|
200
|
+
page_count += 1
|
|
201
|
+
logger.info(f"Fetched page {page_count} with {len(items)} items")
|
|
202
|
+
|
|
203
|
+
# Yield batch (CRITICAL: do not accumulate)
|
|
204
|
+
yield items
|
|
205
|
+
|
|
206
|
+
# Check if there's a next page
|
|
207
|
+
if has_next_path:
|
|
208
|
+
has_next = self._get_nested_value(response, has_next_path)
|
|
209
|
+
else:
|
|
210
|
+
# Fallback: check if cursor exists
|
|
211
|
+
next_cursor = self._get_nested_value(response, cursor_path)
|
|
212
|
+
has_next = next_cursor is not None
|
|
213
|
+
|
|
214
|
+
# Update cursor for next iteration
|
|
215
|
+
if has_next:
|
|
216
|
+
next_cursor = self._get_nested_value(response, cursor_path)
|
|
217
|
+
variables[cursor_var] = next_cursor
|
|
218
|
+
|
|
219
|
+
def _get_nested_value(
|
|
220
|
+
self,
|
|
221
|
+
data: dict,
|
|
222
|
+
path: list[str]
|
|
223
|
+
) -> Any:
|
|
224
|
+
"""Extract nested value from dict using path list."""
|
|
225
|
+
result = data
|
|
226
|
+
for key in path:
|
|
227
|
+
if isinstance(result, dict) and key in result:
|
|
228
|
+
result = result[key]
|
|
229
|
+
else:
|
|
230
|
+
return None
|
|
231
|
+
return result
|
|
232
|
+
|
|
233
|
+
# %% ../nbs/08_utils_graphql.ipynb 14
|
|
234
|
+
async def execute_graphql(
|
|
235
|
+
url: str, # GraphQL endpoint URL
|
|
236
|
+
query: str, # GraphQL query string
|
|
237
|
+
variables: dict | None = None, # Optional query variables
|
|
238
|
+
bearer_token: str | None = None, # Optional bearer token for Authorization header
|
|
239
|
+
headers: dict | None = None # Optional additional headers
|
|
240
|
+
) -> dict:
|
|
241
|
+
"""Execute a single GraphQL query without managing client lifecycle.
|
|
242
|
+
|
|
243
|
+
This is a convenience function for one-off queries. For multiple queries,
|
|
244
|
+
use GraphQLClient.from_url() to reuse the connection.
|
|
245
|
+
|
|
246
|
+
Usage:
|
|
247
|
+
result = await execute_graphql(
|
|
248
|
+
url="https://api.example.com/graphql",
|
|
249
|
+
query="query { users { id name } }",
|
|
250
|
+
bearer_token="your-token"
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
Raises:
|
|
254
|
+
ValueError: If the response contains GraphQL errors
|
|
255
|
+
"""
|
|
256
|
+
async with GraphQLClient.from_url(url, bearer_token=bearer_token, headers=headers) as gql:
|
|
257
|
+
return await gql.execute(query, variables)
|
fh_saas/utils_log.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""One-time logging configuration for all fh_saas modules."""
|
|
2
|
+
|
|
3
|
+
# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/06_utils_log.ipynb.
|
|
4
|
+
|
|
5
|
+
# %% auto 0
|
|
6
|
+
__all__ = ['configure_logging']
|
|
7
|
+
|
|
8
|
+
# %% ../nbs/06_utils_log.ipynb 2
|
|
9
|
+
import os
|
|
10
|
+
import logging
|
|
11
|
+
import logging.handlers
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
# %% ../nbs/06_utils_log.ipynb 5
|
|
15
|
+
def configure_logging(
|
|
16
|
+
log_file: str = None,
|
|
17
|
+
level: str = None,
|
|
18
|
+
max_bytes: int = 10_000_000,
|
|
19
|
+
backup_count: int = 5,
|
|
20
|
+
):
|
|
21
|
+
"""Configure logging for all fh_saas modules - call once at app startup."""
|
|
22
|
+
# Resolve level from env var or parameter
|
|
23
|
+
if level is None:
|
|
24
|
+
level = os.getenv('FH_SAAS_LOG_LEVEL', 'WARNING')
|
|
25
|
+
log_level = getattr(logging, level.upper(), logging.WARNING)
|
|
26
|
+
|
|
27
|
+
# Resolve log file from env var or parameter
|
|
28
|
+
if log_file is None:
|
|
29
|
+
log_file = os.getenv('FH_SAAS_LOG_FILE')
|
|
30
|
+
|
|
31
|
+
# Create formatter
|
|
32
|
+
formatter = logging.Formatter(
|
|
33
|
+
'%(asctime)s | %(name)s | %(levelname)s | %(message)s',
|
|
34
|
+
datefmt='%Y-%m-%d %H:%M:%S'
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Create handlers
|
|
38
|
+
handlers = []
|
|
39
|
+
handlers.append(logging.StreamHandler()) # Always console
|
|
40
|
+
|
|
41
|
+
# File handler (if specified)
|
|
42
|
+
if log_file:
|
|
43
|
+
Path(log_file).parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
handlers.append(logging.handlers.RotatingFileHandler(
|
|
45
|
+
log_file, maxBytes=max_bytes, backupCount=backup_count
|
|
46
|
+
))
|
|
47
|
+
|
|
48
|
+
# Apply formatter to all handlers
|
|
49
|
+
for handler in handlers:
|
|
50
|
+
handler.setFormatter(formatter)
|
|
51
|
+
|
|
52
|
+
# Configure fh_saas package logger
|
|
53
|
+
package_logger = logging.getLogger('fh_saas')
|
|
54
|
+
package_logger.setLevel(log_level)
|
|
55
|
+
package_logger.handlers = handlers
|
|
56
|
+
package_logger.propagate = False
|