Coverage for src/config_parser.py: 100%
272 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-06 02:49 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-06 02:49 +0000
1"""Config file parser.
3This module provides high-level configuration retrieval functions.
4Low-level utilities are in config_utils.py, logging in config_logging.py,
5and filesystem operations in filesystem_utils.py per SRP.
6"""
8__author__ = "Mandar Patil (mandarons@pm.me)"
10import multiprocessing
11from typing import Any
13from icloudpy.services.photos import PhotoAsset
15from src import (
16 DEFAULT_DRIVE_DESTINATION,
17 DEFAULT_PHOTOS_DESTINATION,
18 DEFAULT_REQUEST_TIMEOUT_SEC,
19 DEFAULT_RETRY_LOGIN_INTERVAL_SEC,
20 DEFAULT_ROOT_DESTINATION,
21 DEFAULT_SYNC_INTERVAL_SEC,
22 configure_icloudpy_logging,
23 get_logger,
24)
25from src.config_logging import (
26 log_config_debug,
27 log_config_error,
28 log_config_found_info,
29 log_config_not_found_warning,
30 log_invalid_config_value,
31)
32from src.config_utils import (
33 config_path_to_string,
34 get_config_value,
35 get_config_value_or_default,
36 get_config_value_or_none,
37 traverse_config_path,
38)
39from src.filesystem_utils import ensure_directory_exists, join_and_ensure_path
41# Configure icloudpy logging immediately after import
42configure_icloudpy_logging()
44LOGGER = get_logger()
46# Cache for config values to prevent repeated warnings
47_config_warning_cache = set()
50def _log_config_warning_once(config_path: list, message: str) -> None:
51 """Log a configuration warning only once for the given config path.
53 Args:
54 config_path: Configuration path as list
55 message: Warning message to log
56 """
57 config_path_key = config_path_to_string(config_path)
58 if config_path_key not in _config_warning_cache:
59 _config_warning_cache.add(config_path_key)
60 log_config_not_found_warning(config_path, message)
63def clear_config_warning_cache() -> None:
64 """Clear the configuration warning cache.
66 This function is primarily intended for testing purposes to ensure
67 clean test isolation.
68 """
69 _config_warning_cache.clear()
72# =============================================================================
73# String Processing Functions
74# =============================================================================
77def validate_and_strip_username(username: str, config_path: list[str]) -> str | None:
78 """Validate and strip username string.
80 Args:
81 username: Raw username string from config
82 config_path: Config path for error logging
84 Returns:
85 Stripped username if valid, None if empty
86 """
87 username = username.strip()
88 if len(username) == 0:
89 log_config_error(config_path, "username is empty")
90 return None
91 return username
94# =============================================================================
95# Credential Configuration Functions
96# =============================================================================
99def get_username(config: dict) -> str | None:
100 """Get username from config.
102 Args:
103 config: Configuration dictionary
105 Returns:
106 Username string if found and valid, None otherwise
107 """
108 config_path = ["app", "credentials", "username"]
109 if not traverse_config_path(config=config, config_path=config_path):
110 log_config_error(config_path, "username is missing. Please set the username.")
111 return None
113 username = get_config_value(config=config, config_path=config_path)
114 return validate_and_strip_username(username, config_path)
117def get_retry_login_interval(config: dict) -> int:
118 """Return retry login interval from config.
120 Args:
121 config: Configuration dictionary
123 Returns:
124 Retry login interval in seconds
125 """
126 config_path = ["app", "credentials", "retry_login_interval"]
128 if not traverse_config_path(config=config, config_path=config_path):
129 retry_login_interval = DEFAULT_RETRY_LOGIN_INTERVAL_SEC
130 log_config_not_found_warning(
131 config_path,
132 f"not found. Using default {retry_login_interval} seconds ...",
133 )
134 else:
135 retry_login_interval = get_config_value(config=config, config_path=config_path)
136 log_config_found_info(f"Retrying login every {retry_login_interval} seconds.")
138 return retry_login_interval
141def get_region(config: dict) -> str:
142 """Return region from config.
144 Args:
145 config: Configuration dictionary
147 Returns:
148 Region string ('global' or 'china')
149 """
150 config_path = ["app", "region"]
151 region = get_config_value_or_default(config=config, config_path=config_path, default="global")
153 if region == "global" and not traverse_config_path(config=config, config_path=config_path):
154 log_config_not_found_warning(config_path, "not found. Using default value - global ...")
155 elif region not in ["global", "china"]:
156 log_config_error(
157 config_path,
158 "is invalid. Valid values are - global or china. Using default value - global ...",
159 )
160 region = "global"
162 return region
165# =============================================================================
166# Sync Interval Configuration Functions
167# =============================================================================
170def get_sync_interval(config: dict, config_path: list[str], service_name: str, log_messages: bool = True) -> int:
171 """Get sync interval for a service (drive or photos).
173 Extracted common logic for retrieving sync intervals.
175 Args:
176 config: Configuration dictionary
177 config_path: Path to sync_interval config
178 service_name: Name of service for logging ("drive" or "photos")
179 log_messages: Whether to log informational messages (default: True)
181 Returns:
182 Sync interval in seconds
183 """
184 sync_interval = get_config_value_or_default(
185 config=config,
186 config_path=config_path,
187 default=DEFAULT_SYNC_INTERVAL_SEC,
188 )
190 if log_messages:
191 if sync_interval == DEFAULT_SYNC_INTERVAL_SEC:
192 log_config_not_found_warning(
193 config_path,
194 f"is not found. Using default sync_interval: {sync_interval} seconds ...",
195 )
196 else:
197 log_config_found_info(f"Syncing {service_name} every {sync_interval} seconds.")
199 return sync_interval
202def get_drive_sync_interval(config: dict, log_messages: bool = True) -> int:
203 """Return drive sync interval from config.
205 Args:
206 config: Configuration dictionary
207 log_messages: Whether to log informational messages (default: True)
209 Returns:
210 Drive sync interval in seconds
211 """
212 config_path = ["drive", "sync_interval"]
213 return get_sync_interval(config=config, config_path=config_path, service_name="drive", log_messages=log_messages)
216def get_drive_request_timeout(config: dict) -> int:
217 """Return drive request timeout from config.
219 Args:
220 config: Configuration dictionary
222 Returns:
223 Request timeout in seconds (default: DEFAULT_REQUEST_TIMEOUT_SEC)
224 """
225 config_path = ["drive", "request_timeout"]
226 return get_config_value_or_default(
227 config=config,
228 config_path=config_path,
229 default=DEFAULT_REQUEST_TIMEOUT_SEC,
230 )
233def get_photos_sync_interval(config: dict, log_messages: bool = True) -> int:
234 """Return photos sync interval from config.
236 Args:
237 config: Configuration dictionary
238 log_messages: Whether to log informational messages (default: True)
240 Returns:
241 Photos sync interval in seconds
242 """
243 config_path = ["photos", "sync_interval"]
244 return get_sync_interval(config=config, config_path=config_path, service_name="photos", log_messages=log_messages)
247# =============================================================================
248# Thread Configuration Functions
249# =============================================================================
252def calculate_default_max_threads() -> int:
253 """Calculate default maximum threads based on CPU cores.
255 Returns:
256 Default max threads (min of CPU count and 8)
257 """
258 return min(multiprocessing.cpu_count(), 8)
261def parse_max_threads_value(max_threads_config: Any, default_max_threads: int) -> int:
262 """Parse and validate max_threads configuration value.
264 Args:
265 max_threads_config: Raw config value (string "auto" or integer)
266 default_max_threads: Default value to use
268 Returns:
269 Validated max threads value (capped at 16)
270 """
271 # Handle "auto" value
272 if isinstance(max_threads_config, str) and max_threads_config.lower() == "auto":
273 max_threads = default_max_threads
274 log_config_found_info(f"Using automatic thread count: {max_threads} threads (based on CPU cores).")
275 elif isinstance(max_threads_config, int) and max_threads_config >= 1:
276 max_threads = min(max_threads_config, 16) # Cap at 16 to avoid overwhelming servers
277 log_config_found_info(f"Using configured max_threads: {max_threads}.")
278 else:
279 log_invalid_config_value(
280 ["app", "max_threads"],
281 max_threads_config,
282 "'auto' or integer >= 1",
283 )
284 max_threads = default_max_threads
286 return max_threads
289def get_app_max_threads(config: dict) -> int:
290 """Return app-level max threads from config with support for 'auto' value.
292 Args:
293 config: Configuration dictionary
295 Returns:
296 Maximum number of threads for parallel operations
297 """
298 default_max_threads = calculate_default_max_threads()
299 config_path = ["app", "max_threads"]
301 if not traverse_config_path(config=config, config_path=config_path):
302 log_config_debug(
303 f"max_threads is not found in {config_path_to_string(config_path=config_path)}. "
304 f"Using default max_threads: {default_max_threads} (auto) ...",
305 )
306 return default_max_threads
308 max_threads_config = get_config_value(config=config, config_path=config_path)
309 return parse_max_threads_value(max_threads_config, default_max_threads)
312def get_usage_tracking_enabled(config: dict) -> bool:
313 """Get usage tracking enabled setting from configuration.
315 Args:
316 config: Configuration dictionary
318 Returns:
319 True if usage tracking is enabled (default), False if disabled
320 """
321 config_path = ["app", "usage_tracking", "enabled"]
322 if not traverse_config_path(config=config, config_path=config_path):
323 return True # Default to enabled if not configured
325 value = get_config_value(config=config, config_path=config_path)
326 if isinstance(value, bool):
327 return value
329 # Handle string values for backwards compatibility
330 if isinstance(value, str):
331 return value.lower() not in ("false", "no", "0", "disabled", "off")
333 # Default to enabled for any other type
334 return True
337# =============================================================================
338# Root Destination Functions
339# =============================================================================
342def get_root_destination_path(config: dict) -> str:
343 """Get root destination path from config without creating directory.
345 Args:
346 config: Configuration dictionary
348 Returns:
349 Root destination path string
350 """
351 config_path = ["app", "root"]
352 root_destination = get_config_value_or_default(
353 config=config,
354 config_path=config_path,
355 default=DEFAULT_ROOT_DESTINATION,
356 )
358 if not traverse_config_path(config=config, config_path=config_path):
359 log_config_not_found_warning(
360 config_path,
361 f"root destination is missing. Using default root destination: {root_destination}",
362 )
364 return root_destination
367def prepare_root_destination(config: dict) -> str:
368 """Prepare root destination by creating directory if needed.
370 Args:
371 config: Configuration dictionary
373 Returns:
374 Absolute path to root destination directory
375 """
376 log_config_debug("Checking root destination ...")
377 root_destination = get_root_destination_path(config)
378 return ensure_directory_exists(root_destination)
381# =============================================================================
382# Drive Configuration Functions
383# =============================================================================
386def get_drive_destination_path(config: dict) -> str:
387 """Get drive destination path from config without creating directory.
389 Args:
390 config: Configuration dictionary
392 Returns:
393 Drive destination path string
394 """
395 config_path = ["drive", "destination"]
396 drive_destination = get_config_value_or_default(
397 config=config,
398 config_path=config_path,
399 default=DEFAULT_DRIVE_DESTINATION,
400 )
402 if not traverse_config_path(config=config, config_path=config_path):
403 log_config_not_found_warning(
404 config_path,
405 f"destination is missing. Using default drive destination: {drive_destination}.",
406 )
408 return drive_destination
411def prepare_drive_destination(config: dict) -> str:
412 """Prepare drive destination path by creating directory if needed.
414 Args:
415 config: Configuration dictionary
417 Returns:
418 Absolute path to drive destination directory
419 """
420 log_config_debug("Checking drive destination ...")
421 root_path = prepare_root_destination(config=config)
422 drive_destination = get_drive_destination_path(config)
423 return join_and_ensure_path(root_path, drive_destination)
426def get_drive_remove_obsolete(config: dict) -> bool:
427 """Return drive remove obsolete flag from config.
429 Args:
430 config: Configuration dictionary
432 Returns:
433 True if obsolete files should be removed, False otherwise
434 """
435 config_path = ["drive", "remove_obsolete"]
436 drive_remove_obsolete = get_config_value_or_default(config=config, config_path=config_path, default=False)
438 if not drive_remove_obsolete:
439 _log_config_warning_once(
440 config_path,
441 "remove_obsolete is not found. Not removing the obsolete files and folders.",
442 )
443 else:
444 log_config_debug(f"{'R' if drive_remove_obsolete else 'Not R'}emoving obsolete files and folders ...")
446 return drive_remove_obsolete
449# =============================================================================
450# Photos Configuration Functions
451# =============================================================================
454def get_photos_destination_path(config: dict) -> str:
455 """Get photos destination path from config without creating directory.
457 Args:
458 config: Configuration dictionary
460 Returns:
461 Photos destination path string
462 """
463 config_path = ["photos", "destination"]
464 photos_destination = get_config_value_or_default(
465 config=config,
466 config_path=config_path,
467 default=DEFAULT_PHOTOS_DESTINATION,
468 )
470 if not traverse_config_path(config=config, config_path=config_path):
471 log_config_not_found_warning(
472 config_path,
473 f"destination is missing. Using default photos destination: {config_path_to_string(config_path)}",
474 )
476 return photos_destination
479def prepare_photos_destination(config: dict) -> str:
480 """Prepare photos destination path by creating directory if needed.
482 Args:
483 config: Configuration dictionary
485 Returns:
486 Absolute path to photos destination directory
487 """
488 log_config_debug("Checking photos destination ...")
489 root_path = prepare_root_destination(config=config)
490 photos_destination = get_photos_destination_path(config)
491 return join_and_ensure_path(root_path, photos_destination)
494def get_photos_all_albums(config: dict) -> bool:
495 """Return flag to download all albums from config.
497 Args:
498 config: Configuration dictionary
500 Returns:
501 True if all albums should be synced, False otherwise
502 """
503 config_path = ["photos", "all_albums"]
504 download_all = get_config_value_or_default(config=config, config_path=config_path, default=False)
506 if download_all:
507 log_config_found_info("Syncing all albums.")
509 return download_all
512def get_photos_use_hardlinks(config: dict, log_messages: bool = True) -> bool:
513 """Return flag to use hard links for duplicate photos from config.
515 Args:
516 config: Configuration dictionary
517 log_messages: Whether to log informational messages (default: True)
519 Returns:
520 True if hard links should be used, False otherwise
521 """
522 config_path = ["photos", "use_hardlinks"]
523 use_hardlinks = get_config_value_or_default(config=config, config_path=config_path, default=False)
525 if use_hardlinks and log_messages:
526 log_config_found_info("Using hard links for duplicate photos.")
528 return use_hardlinks
531def get_photos_remove_obsolete(config: dict) -> bool:
532 """Return photos remove obsolete flag from config.
534 Args:
535 config: Configuration dictionary
537 Returns:
538 True if obsolete files should be removed, False otherwise
539 """
540 config_path = ["photos", "remove_obsolete"]
541 photos_remove_obsolete = get_config_value_or_default(config=config, config_path=config_path, default=False)
543 if not photos_remove_obsolete:
544 _log_config_warning_once(
545 config_path,
546 "remove_obsolete is not found. Not removing the obsolete files and folders.",
547 )
548 else:
549 log_config_debug(f"{'R' if photos_remove_obsolete else 'Not R'}emoving obsolete files and folders ...")
551 return photos_remove_obsolete
554def get_photos_folder_format(config: dict) -> str | None:
555 """Return filename format or None.
557 Args:
558 config: Configuration dictionary
560 Returns:
561 Folder format string if configured, None otherwise
562 """
563 config_path = ["photos", "folder_format"]
564 fmt = get_config_value_or_none(config=config, config_path=config_path)
566 if fmt:
567 log_config_found_info(f"Using format {fmt}.")
569 return fmt
572# =============================================================================
573# Photos Filter Configuration Functions
574# =============================================================================
577def validate_file_sizes(file_sizes: list[str]) -> list[str]:
578 """Validate and filter file sizes against valid options.
580 Args:
581 file_sizes: List of file size strings to validate
583 Returns:
584 List of valid file sizes (defaults to ["original"] if all invalid)
585 """
586 valid_file_sizes = list(PhotoAsset.PHOTO_VERSION_LOOKUP.keys())
587 validated_sizes = []
589 for file_size in file_sizes:
590 if file_size not in valid_file_sizes:
591 log_invalid_config_value(
592 ["photos", "filters", "file_sizes"],
593 file_size,
594 ",".join(valid_file_sizes),
595 )
596 else:
597 validated_sizes.append(file_size)
599 return validated_sizes if validated_sizes else ["original"]
602def get_photos_libraries_filter(config: dict, base_config_path: list[str]) -> list[str] | None:
603 """Get libraries filter from photos config.
605 Args:
606 config: Configuration dictionary
607 base_config_path: Base path to filters section
609 Returns:
610 List of library names if configured, None otherwise
611 """
612 config_path = base_config_path + ["libraries"]
613 libraries = get_config_value_or_none(config=config, config_path=config_path)
615 if not libraries or len(libraries) == 0:
616 log_config_not_found_warning(config_path, "not found. Downloading all libraries ...")
617 return None
619 return libraries
622def get_photos_albums_filter(config: dict, base_config_path: list[str]) -> list[str] | None:
623 """Get albums filter from photos config.
625 Args:
626 config: Configuration dictionary
627 base_config_path: Base path to filters section
629 Returns:
630 List of album names if configured, None otherwise
631 """
632 config_path = base_config_path + ["albums"]
633 albums = get_config_value_or_none(config=config, config_path=config_path)
635 if not albums or len(albums) == 0:
636 log_config_not_found_warning(config_path, "not found. Downloading all albums ...")
637 return None
639 return albums
642def get_photos_file_sizes_filter(config: dict, base_config_path: list[str]) -> list[str]:
643 """Get file sizes filter from photos config.
645 Args:
646 config: Configuration dictionary
647 base_config_path: Base path to filters section
649 Returns:
650 List of file size options (defaults to ["original"])
651 """
652 config_path = base_config_path + ["file_sizes"]
654 if not traverse_config_path(config=config, config_path=config_path):
655 log_config_not_found_warning(config_path, "not found. Downloading original size photos ...")
656 return ["original"]
658 file_sizes = get_config_value(config=config, config_path=config_path)
659 return validate_file_sizes(file_sizes)
662def get_photos_extensions_filter(config: dict, base_config_path: list[str]) -> list[str] | None:
663 """Get extensions filter from photos config.
665 Args:
666 config: Configuration dictionary
667 base_config_path: Base path to filters section
669 Returns:
670 List of file extensions if configured, None otherwise
671 """
672 config_path = base_config_path + ["extensions"]
673 extensions = get_config_value_or_none(config=config, config_path=config_path)
675 if not extensions or len(extensions) == 0:
676 log_config_not_found_warning(config_path, "not found. Downloading all extensions ...")
677 return None
679 return extensions
682def get_photos_filters(config: dict) -> dict[str, Any]:
683 """Return photos filters from config.
685 Args:
686 config: Configuration dictionary
688 Returns:
689 Dictionary containing filter configuration for photos
690 """
691 photos_filters = {
692 "libraries": None,
693 "albums": None,
694 "file_sizes": ["original"],
695 "extensions": None,
696 }
698 base_config_path = ["photos", "filters"]
700 # Check for filters section existence
701 if not traverse_config_path(config=config, config_path=base_config_path):
702 log_config_not_found_warning(
703 base_config_path,
704 "not found. Downloading all libraries and albums with original size ...",
705 )
706 return photos_filters
708 # Parse individual filter components
709 photos_filters["libraries"] = get_photos_libraries_filter(config, base_config_path)
710 photos_filters["albums"] = get_photos_albums_filter(config, base_config_path)
711 photos_filters["file_sizes"] = get_photos_file_sizes_filter(config, base_config_path)
712 photos_filters["extensions"] = get_photos_extensions_filter(config, base_config_path)
714 return photos_filters
717# =============================================================================
718# SMTP Configuration Functions
719# =============================================================================
722def get_smtp_config_value(config: dict, key: str, warn_if_missing: bool = True) -> str | None:
723 """Get SMTP configuration value with optional warning.
725 Common helper for SMTP config retrieval to reduce duplication.
727 Args:
728 config: Configuration dictionary
729 key: SMTP config key name
730 warn_if_missing: Whether to log warning if not found
732 Returns:
733 Config value if found, None otherwise
734 """
735 config_path = ["app", "smtp", key]
736 value = get_config_value_or_none(config=config, config_path=config_path)
738 if value is None and warn_if_missing:
739 log_config_not_found_warning(config_path, f"{key} is not found.")
741 return value
744def get_smtp_email(config: dict) -> str | None:
745 """Return smtp from email from config.
747 Args:
748 config: Configuration dictionary
750 Returns:
751 SMTP email address if configured, None otherwise
752 """
753 return get_smtp_config_value(config, "email", warn_if_missing=False)
756def get_smtp_username(config: dict) -> str | None:
757 """Return smtp username from the config, if set.
759 Args:
760 config: Configuration dictionary
762 Returns:
763 SMTP username if configured, None otherwise
764 """
765 return get_smtp_config_value(config, "username", warn_if_missing=False)
768def get_smtp_to_email(config: dict) -> str | None:
769 """Return smtp to email from config, defaults to from email.
771 Args:
772 config: Configuration dictionary
774 Returns:
775 SMTP 'to' email address, falling back to 'from' email if not specified
776 """
777 to_email = get_smtp_config_value(config, "to", warn_if_missing=False)
778 return to_email if to_email else get_smtp_email(config=config)
781def get_smtp_password(config: dict) -> str | None:
782 """Return smtp password from config.
784 Args:
785 config: Configuration dictionary
787 Returns:
788 SMTP password if configured, None otherwise
789 """
790 return get_smtp_config_value(config, "password", warn_if_missing=True)
793def get_smtp_host(config: dict) -> str | None:
794 """Return smtp host from config.
796 Args:
797 config: Configuration dictionary
799 Returns:
800 SMTP host if configured, None otherwise
801 """
802 return get_smtp_config_value(config, "host", warn_if_missing=True)
805def get_smtp_port(config: dict) -> int | None:
806 """Return smtp port from config.
808 Args:
809 config: Configuration dictionary
811 Returns:
812 SMTP port if configured, None otherwise
813 """
814 return get_smtp_config_value(config, "port", warn_if_missing=True) # type: ignore[return-value]
817def get_smtp_no_tls(config: dict) -> bool:
818 """Return smtp no_tls flag from config.
820 Args:
821 config: Configuration dictionary
823 Returns:
824 True if TLS should be disabled, False otherwise
825 """
826 no_tls = get_smtp_config_value(config, "no_tls", warn_if_missing=True)
827 return no_tls if no_tls is not None else False # type: ignore[return-value]
830# =============================================================================
831# Notification Service Configuration Functions
832# =============================================================================
835def get_notification_config_value(config: dict, service: str, key: str) -> str | None:
836 """Get notification service configuration value.
838 Common helper for notification service config retrieval.
840 Args:
841 config: Configuration dictionary
842 service: Service name (telegram, discord, pushover)
843 key: Config key name
845 Returns:
846 Config value if found, None otherwise
847 """
848 config_path = ["app", service, key]
849 value = get_config_value_or_none(config=config, config_path=config_path)
851 if value is None:
852 log_config_not_found_warning(config_path, f"{key} is not found.")
854 return value
857def get_telegram_bot_token(config: dict) -> str | None:
858 """Return telegram bot token from config.
860 Args:
861 config: Configuration dictionary
863 Returns:
864 Telegram bot token if configured, None otherwise
865 """
866 return get_notification_config_value(config, "telegram", "bot_token")
869def get_telegram_chat_id(config: dict) -> str | None:
870 """Return telegram chat id from config.
872 Args:
873 config: Configuration dictionary
875 Returns:
876 Telegram chat ID if configured, None otherwise
877 """
878 return get_notification_config_value(config, "telegram", "chat_id")
881def get_discord_webhook_url(config: dict) -> str | None:
882 """Return discord webhook_url from config.
884 Args:
885 config: Configuration dictionary
887 Returns:
888 Discord webhook URL if configured, None otherwise
889 """
890 return get_notification_config_value(config, "discord", "webhook_url")
893def get_discord_username(config: dict) -> str | None:
894 """Return discord username from config.
896 Args:
897 config: Configuration dictionary
899 Returns:
900 Discord username if configured, None otherwise
901 """
902 return get_notification_config_value(config, "discord", "username")
905def get_pushover_user_key(config: dict) -> str | None:
906 """Return Pushover user key from config.
908 Args:
909 config: Configuration dictionary
911 Returns:
912 Pushover user key if configured, None otherwise
913 """
914 return get_notification_config_value(config, "pushover", "user_key")
917def get_pushover_api_token(config: dict) -> str | None:
918 """Return Pushover API token from config.
920 Args:
921 config: Configuration dictionary
923 Returns:
924 Pushover API token if configured, None otherwise
925 """
926 return get_notification_config_value(config, "pushover", "api_token")
928def get_pushover_notification_priority(config: dict) -> int | None:
929 """Return Pushover notification priority from config.
931 Args:
932 config: Configuration dictionary
934 Returns:
935 Pushover notification priority if configured, None otherwise
936 """
937 config_path = ["app", "pushover", "priority"]
938 return get_config_value_or_none(config=config, config_path=config_path)
941# =============================================================================
942# Sync Summary Notification Configuration Functions
943# =============================================================================
946def get_sync_summary_enabled(config: dict) -> bool:
947 """Return whether sync summary notifications are enabled.
949 Args:
950 config: Configuration dictionary
952 Returns:
953 True if sync summary is enabled, False otherwise (default: False)
954 """
955 config_path = ["app", "notifications", "sync_summary", "enabled"]
956 if not traverse_config_path(config=config, config_path=config_path):
957 return False
959 value = get_config_value(config=config, config_path=config_path)
960 return bool(value) if value is not None else False
963def get_sync_summary_on_success(config: dict) -> bool:
964 """Return whether to send summary on successful syncs.
966 Args:
967 config: Configuration dictionary
969 Returns:
970 True if should send on success, False otherwise (default: True)
971 """
972 config_path = ["app", "notifications", "sync_summary", "on_success"]
973 if not traverse_config_path(config=config, config_path=config_path):
974 return True # Default to True if not configured
976 value = get_config_value(config=config, config_path=config_path)
977 return bool(value) if value is not None else True
980def get_sync_summary_on_error(config: dict) -> bool:
981 """Return whether to send summary when errors occur.
983 Args:
984 config: Configuration dictionary
986 Returns:
987 True if should send on error, False otherwise (default: True)
988 """
989 config_path = ["app", "notifications", "sync_summary", "on_error"]
990 if not traverse_config_path(config=config, config_path=config_path):
991 return True # Default to True if not configured
993 value = get_config_value(config=config, config_path=config_path)
994 return bool(value) if value is not None else True
997def get_sync_summary_min_downloads(config: dict) -> int:
998 """Return minimum downloads required to trigger notification.
1000 Args:
1001 config: Configuration dictionary
1003 Returns:
1004 Minimum downloads threshold (default: 1)
1005 """
1006 config_path = ["app", "notifications", "sync_summary", "min_downloads"]
1007 if not traverse_config_path(config=config, config_path=config_path):
1008 return 1 # Default to 1 if not configured
1010 value = get_config_value(config=config, config_path=config_path)
1011 return int(value) if value is not None else 1