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/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
+ )
@@ -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