Coverage for youversion/clients/sync_client.py: 100%
208 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-26 11:31 +0100
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-26 11:31 +0100
1"""Synchronous client for YouVersion Bible API."""
3import asyncio
4from typing import Any
6from ..core.base_client import BaseClient
7from ..models.moments import CreateMoment
10class SyncClient(BaseClient):
11 """Synchronous wrapper for BaseClient."""
13 def __init__(self, username: str | None = None, password: str | None = None):
14 """Initialize sync client.
16 Args:
17 username: Username for authentication
18 password: Password for authentication
19 """
20 # Call BaseClient.__init__ with explicit self for easier test patching
21 BaseClient.__init__(self, username, password)
22 # Preserve provided username for tests where BaseClient is mocked
23 self._username = username
24 self._loop = None
25 self._loop_owner = False
27 def _get_loop(self) -> asyncio.AbstractEventLoop:
28 """Get or create an event loop for running async operations.
30 Returns:
31 Event loop for async operations
33 Raises:
34 RuntimeError: If called within an async context
35 """
36 try:
37 # Try to get the current event loop
38 asyncio.get_running_loop()
39 # If we're already in an async context, we can't run sync ops
40 raise RuntimeError(
41 "Cannot use synchronous SyncClient within an async context. "
42 "Use AsyncClient instead or run this code outside of an "
43 "async function."
44 )
45 except RuntimeError as e:
46 if "Cannot use synchronous SyncClient" in str(e):
47 raise
48 # No running loop, create a new one
49 if self._loop is None or self._loop.is_closed():
50 self._loop = asyncio.new_event_loop()
51 asyncio.set_event_loop(self._loop)
52 self._loop_owner = True
53 return self._loop
55 def _run_async(self, coro):
56 """Run an async coroutine in the event loop.
58 Args:
59 coro: Async coroutine to run
61 Returns:
62 Result of the coroutine
63 """
64 loop = self._get_loop()
66 # Ensure the client is initialized
67 async def _ensure_client_initialized():
68 await self._ensure_authenticated()
69 return await coro
71 return loop.run_until_complete(_ensure_client_initialized())
73 def __enter__(self):
74 """Context manager entry."""
75 self._run_async(self.__aenter__())
76 return self
78 def __exit__(self, exc_type, exc_val, exc_tb):
79 """Context manager exit."""
80 self._run_async(self.__aexit__(exc_type, exc_val, exc_tb))
81 if self._loop_owner and self._loop and not self._loop.is_closed():
82 self._loop.close()
84 async def __aenter__(self):
85 """Async context manager entry."""
86 await self._ensure_authenticated()
87 return self
89 async def __aexit__(self, exc_type, exc_val, exc_tb):
90 """Async context manager exit."""
91 await super().close()
93 def close(self):
94 """Manually close the client."""
96 async def _close_async():
97 await BaseClient.close(self)
99 # Run close coroutine depending on loop ownership
100 if self._loop and not self._loop.is_closed():
101 self._loop.run_until_complete(_close_async())
102 elif self._loop_owner:
103 # Create and assign a loop we own
104 self._loop = asyncio.new_event_loop()
105 asyncio.set_event_loop(self._loop)
106 self._loop.run_until_complete(_close_async())
107 else:
108 # Create a temporary loop but do not close it (not owner)
109 temp_loop = asyncio.new_event_loop()
110 asyncio.set_event_loop(temp_loop)
111 temp_loop.run_until_complete(_close_async())
113 # Close the stored loop only if we own it
114 if self._loop_owner and self._loop and not self._loop.is_closed():
115 self._loop.close()
117 @property
118 def username(self) -> str | None:
119 """Get the username, tolerant of BaseClient being mocked in tests."""
120 auth = getattr(self, "_authenticator", None)
121 if auth is not None and hasattr(auth, "username"):
122 return auth.username
123 return getattr(self, "_username", None)
125 # Synchronous wrapper methods
126 def moments(self, page: int = 1) -> list[Any]:
127 """Get moments.
129 Args:
130 page: Page number
132 Returns:
133 List of dynamically created Moment objects conforming to
134 MomentProtocol
135 """
136 return self._run_async(super().moments(page))
138 def highlights(self, page: int = 1) -> list[Any]:
139 """Get highlights.
141 Args:
142 page: Page number
144 Returns:
145 List of Highlight objects
146 """
147 return self._run_async(super().highlights(page))
149 def verse_of_the_day(self, day: int | None = None) -> Any:
150 """Get verse of the day.
152 Args:
153 day: Specific day number (optional)
155 Returns:
156 Votd object
157 """
158 return self._run_async(super().verse_of_the_day(day))
160 def notes(self, page: int = 1) -> list[Any]:
161 """Get notes.
163 Args:
164 page: Page number
166 Returns:
167 List of note data
168 """
169 return self._run_async(super().notes(page))
171 def bookmarks(self, page: int = 1) -> list[Any]:
172 """Get bookmarks.
174 Args:
175 page: Page number
177 Returns:
178 List of bookmark data
179 """
180 return self._run_async(super().bookmarks(page))
182 def my_images(self, page: int = 1) -> list[Any]:
183 """Get images.
185 Args:
186 page: Page number
188 Returns:
189 List of image data
190 """
191 return self._run_async(super().my_images(page))
193 def plan_progress(self, page: int = 1) -> list[Any]:
194 """Get plan progress.
196 Args:
197 page: Page number
199 Returns:
200 List of plan progress data
201 """
202 return self._run_async(super().plan_progress(page))
204 def plan_subscriptions(self, page: int = 1) -> list[Any]:
205 """Get plan subscriptions.
207 Args:
208 page: Page number
210 Returns:
211 List of plan subscription data
212 """
213 return self._run_async(super().plan_subscriptions(page))
215 def plan_completions(self, page: int = 1) -> list[Any]:
216 """Get plan completions.
218 Args:
219 page: Page number
221 Returns:
222 List of plan completion data
223 """
224 return self._run_async(super().plan_completions(page))
226 def convert_note_to_md(self) -> list[Any]:
227 """Convert notes to markdown.
229 Returns:
230 List of converted note data
231 """
232 return self._run_async(super().convert_note_to_md())
234 def send_friend_request(self, user_id: int) -> dict[str, Any]:
235 """Send a friend request to a user.
237 Args:
238 user_id: User ID to send friend request to
240 Returns:
241 Friend request response data with incoming and outgoing lists
242 """
243 return self._run_async(super().send_friend_request(user_id))
245 # Bible API methods
246 def get_bible_configuration(self) -> dict[str, Any]:
247 """Get Bible configuration.
249 Returns:
250 Bible configuration data
251 """
252 return self._run_async(super().get_bible_configuration())
254 def get_bible_versions(
255 self, language_tag: str = "eng", version_type: str = "all"
256 ) -> dict[str, Any]:
257 """Get Bible versions for a language.
259 Args:
260 language_tag: Language tag (e.g., 'eng', 'spa')
261 version_type: Type of versions ('all', 'text', 'audio')
263 Returns:
264 Bible versions data
265 """
266 return self._run_async(super().get_bible_versions(language_tag, version_type))
268 def get_bible_version(self, version_id: int) -> dict[str, Any]:
269 """Get specific Bible version details.
271 Args:
272 version_id: Version ID
274 Returns:
275 Bible version data
276 """
277 return self._run_async(super().get_bible_version(version_id))
279 def get_bible_chapter(
280 self,
281 reference: str,
282 version_id: int = 1,
283 ) -> dict[str, Any]:
284 """Get Bible chapter content.
286 Args:
287 reference: USFM reference (e.g., 'GEN.1')
288 version_id: Version ID
290 Returns:
291 Chapter content data
292 """
293 return self._run_async(super().get_bible_chapter(reference, version_id))
295 def get_recommended_languages(self, country: str = "US") -> dict[str, Any]:
296 """Get recommended languages for a country.
298 Args:
299 country: Country code (e.g., 'US', 'CA')
301 Returns:
302 Recommended languages data
303 """
304 return self._run_async(super().get_recommended_languages(country))
306 # Audio Bible API methods
307 def get_audio_chapter(
308 self,
309 reference: str,
310 version_id: int = 1,
311 ) -> dict[str, Any]:
312 """Get audio chapter information.
314 Args:
315 reference: USFM reference (e.g., 'GEN.1')
316 version_id: Audio version ID
318 Returns:
319 Audio chapter data
320 """
321 return self._run_async(super().get_audio_chapter(reference, version_id))
323 def get_audio_version(self, audio_id: int) -> dict[str, Any]:
324 """Get audio version details.
326 Args:
327 audio_id: Audio version ID
329 Returns:
330 Audio version data
331 """
332 return self._run_async(super().get_audio_version(audio_id))
334 # Search API methods
335 def search_bible(
336 self,
337 query: str,
338 version_id: int | None = None,
339 book: str | None = None,
340 page: int = 1,
341 ) -> dict[str, Any]:
342 """Search Bible text.
344 Args:
345 query: Search query
346 version_id: Version ID (optional)
347 book: Book filter (optional)
348 page: Page number
350 Returns:
351 Search results data
352 """
353 return self._run_async(super().search_bible(query, version_id, book, page))
355 def search_plans(
356 self, query: str, language_tag: str = "en", page: int = 1
357 ) -> dict[str, Any]:
358 """Search reading plans.
360 Args:
361 query: Search query
362 language_tag: Language tag
363 page: Page number
365 Returns:
366 Plan search results data
367 """
368 return self._run_async(super().search_plans(query, language_tag, page))
370 def search_users(
371 self, query: str, language_tag: str = "eng", page: int = 1
372 ) -> dict[str, Any]:
373 """Search users.
375 Args:
376 query: Search query
377 language_tag: Language tag
378 page: Page number
380 Returns:
381 User search results data
382 """
383 return self._run_async(super().search_users(query, language_tag, page))
385 # Videos API methods
386 def get_videos(self, language_tag: str = "eng") -> dict[str, Any]:
387 """Get videos list.
389 Args:
390 language_tag: Language tag
392 Returns:
393 Videos data
394 """
395 return self._run_async(super().get_videos(language_tag))
397 def get_video_details(self, video_id: int) -> dict[str, Any]:
398 """Get video details.
400 Args:
401 video_id: Video ID
403 Returns:
404 Video details data
405 """
406 return self._run_async(super().get_video_details(video_id))
408 # Badges API methods
409 def badges(self, page: int = 1) -> list[Any]:
410 """Get badges.
412 Args:
413 page: Page number
415 Returns:
416 List of badge data
417 """
418 return self._run_async(super().badges(page))
420 # Images API methods
421 def get_images(
422 self, reference: str, language_tag: str = "eng", page: int = 1
423 ) -> dict[str, Any]:
424 """Get images for a reference.
426 Args:
427 reference: USFM reference
428 language_tag: Language tag
429 page: Page number
431 Returns:
432 Images data
433 """
434 return self._run_async(super().get_images(reference, language_tag, page))
436 def get_image_upload_url(self) -> dict[str, Any]:
437 """Get image upload URL and parameters.
439 Returns:
440 Upload URL data
441 """
442 return self._run_async(super().get_image_upload_url())
444 # Events API methods
445 def search_events(
446 self,
447 query: str,
448 latitude: float | None = None,
449 longitude: float | None = None,
450 page: int = 1,
451 ) -> dict[str, Any]:
452 """Search events.
454 Args:
455 query: Search query
456 latitude: Latitude (optional)
457 longitude: Longitude (optional)
458 page: Page number
460 Returns:
461 Event search results data
462 """
463 return self._run_async(super().search_events(query, latitude, longitude, page))
465 def get_event_details(self, event_id: int) -> dict[str, Any]:
466 """Get event details.
468 Args:
469 event_id: Event ID
471 Returns:
472 Event details data
473 """
474 return self._run_async(super().get_event_details(event_id))
476 def get_saved_events(self, page: int = 1) -> dict[str, Any]:
477 """Get saved events.
479 Args:
480 page: Page number
482 Returns:
483 Saved events data
484 """
485 return self._run_async(super().get_saved_events(page))
487 def save_event(
488 self, event_id: int, comments: dict[str, Any] | None = None
489 ) -> dict[str, Any]:
490 """Save event.
492 Args:
493 event_id: Event ID
494 comments: Comments (optional)
496 Returns:
497 Save result data
498 """
499 return self._run_async(super().save_event(event_id, comments))
501 def delete_saved_event(self, event_id: int) -> dict[str, Any]:
502 """Delete saved event.
504 Args:
505 event_id: Event ID
507 Returns:
508 Delete result data
509 """
510 return self._run_async(super().delete_saved_event(event_id))
512 def get_all_saved_event_ids(self) -> dict[str, Any]:
513 """Get all saved event IDs.
515 Returns:
516 All saved event IDs data
517 """
518 return self._run_async(super().get_all_saved_event_ids())
520 def get_event_configuration(self) -> dict[str, Any]:
521 """Get event configuration.
523 Returns:
524 Event configuration data
525 """
526 return self._run_async(super().get_event_configuration())
528 # Moments API methods
529 def get_moments(
530 self,
531 page: int = 1,
532 user_id: int | None = None,
533 kind: str | None = None,
534 version_id: int | None = None,
535 usfm: str | None = None,
536 ) -> dict[str, Any]:
537 """Get moments list.
539 Args:
540 page: Page number
541 user_id: User ID (optional)
542 kind: Kind of moment (optional)
543 version_id: Bible version ID (optional)
544 usfm: USFM reference (optional)
546 Returns:
547 Moments data
548 """
549 return self._run_async(
550 super().get_moments(page, user_id, kind, version_id, usfm)
551 )
553 def get_moment_details(self, moment_id: int) -> dict[str, Any]:
554 """Get moment details.
556 Args:
557 moment_id: Moment ID
559 Returns:
560 Moment details data
561 """
562 return self._run_async(super().get_moment_details(moment_id))
564 def create_moment(self, data: CreateMoment | dict[str, Any]) -> dict[str, Any]:
565 """Create a new moment.
567 Args:
568 data: Moment data as CreateMoment model or dict
570 Returns:
571 Created moment data
572 """
573 return self._run_async(super().create_moment(data))
575 def update_moment(self, data: dict[str, Any]) -> dict[str, Any]:
576 """Update an existing moment.
578 Args:
579 data: Moment data
581 Returns:
582 Updated moment data
583 """
584 return self._run_async(super().update_moment(data))
586 def delete_moment(self, moment_id: int) -> dict[str, Any]:
587 """Delete a moment.
589 Args:
590 moment_id: Moment ID
592 Returns:
593 Delete result data
594 """
595 return self._run_async(super().delete_moment(moment_id))
597 def get_moment_colors(self) -> dict[str, Any]:
598 """Get available highlight colors.
600 Returns:
601 Colors data
602 """
603 return self._run_async(super().get_moment_colors())
605 def get_moment_labels(self) -> dict[str, Any]:
606 """Get moment labels.
608 Returns:
609 Labels data
610 """
611 return self._run_async(super().get_moment_labels())
613 def get_verse_colors(self, usfm: str, version_id: int) -> dict[str, Any]:
614 """Get verse highlight colors.
616 Args:
617 usfm: USFM reference
618 version_id: Bible version ID
620 Returns:
621 Verse colors data
622 """
623 return self._run_async(super().get_verse_colors(usfm, version_id))
625 def hide_verse_colors(self, data: dict[str, Any]) -> dict[str, Any]:
626 """Hide verse highlight colors.
628 Args:
629 data: Hide colors data
631 Returns:
632 Hide result data
633 """
634 return self._run_async(super().hide_verse_colors(data))
636 def get_moments_configuration(self) -> dict[str, Any]:
637 """Get moments configuration.
639 Returns:
640 Moments configuration data
641 """
642 return self._run_async(super().get_moments_configuration())
644 # Comments API methods
645 def create_comment(self, moment_id: int, comment: str) -> dict[str, Any]:
646 """Create a comment on a moment.
648 Args:
649 moment_id: Moment ID
650 comment: Comment text
652 Returns:
653 Created comment data
654 """
655 return self._run_async(super().create_comment(moment_id, comment))
657 def delete_comment(self, comment_id: int) -> dict[str, Any]:
658 """Delete a comment.
660 Args:
661 comment_id: Comment ID
663 Returns:
664 Delete result data
665 """
666 return self._run_async(super().delete_comment(comment_id))
668 # Likes API methods
669 def like_moment(self, moment_id: int) -> dict[str, Any]:
670 """Like a moment.
672 Args:
673 moment_id: Moment ID
675 Returns:
676 Like result data
677 """
678 return self._run_async(super().like_moment(moment_id))
680 def unlike_moment(self, moment_id: int) -> dict[str, Any]:
681 """Unlike a moment.
683 Args:
684 moment_id: Moment ID
686 Returns:
687 Unlike result data
688 """
689 return self._run_async(super().unlike_moment(moment_id))
691 # Messaging API methods
692 def register_device(
693 self,
694 device_id: str,
695 device_type: str = "android",
696 user_id: int | None = None,
697 old_device_id: str | None = None,
698 tags: str | None = None,
699 ) -> dict[str, Any]:
700 """Register device for push notifications.
702 Args:
703 device_id: Device ID
704 device_type: Device type
705 user_id: User ID (optional)
706 old_device_id: Previous device ID (optional)
707 tags: Device tags (optional)
709 Returns:
710 Registration result data
711 """
712 return self._run_async(
713 super().register_device(
714 device_id, device_type, user_id, old_device_id, tags
715 )
716 )
718 def unregister_device(self, device_id: str) -> dict[str, Any]:
719 """Unregister device from push notifications.
721 Args:
722 device_id: Device ID
724 Returns:
725 Unregistration result data
726 """
727 return self._run_async(super().unregister_device(device_id))
729 # Themes API methods
730 def get_themes(self, page: int = 1, language_tag: str = "eng") -> dict[str, Any]:
731 """Get available themes.
733 Args:
734 page: Page number
735 language_tag: Language tag
737 Returns:
738 Themes data
739 """
740 return self._run_async(super().get_themes(page, language_tag))
742 def add_theme(
743 self,
744 theme_id: int,
745 available_locales: list[str],
746 colors: dict[str, Any],
747 cta_urls: dict[str, Any],
748 msgid_suffix: str,
749 version_ids: dict[str, int],
750 ) -> dict[str, Any]:
751 """Add a theme to user's collection.
753 Args:
754 theme_id: Theme ID
755 available_locales: List of available locale codes
756 colors: Theme colors dictionary
757 cta_urls: Call-to-action URLs dictionary
758 msgid_suffix: Message ID suffix
759 version_ids: Dictionary of version IDs by locale code
761 Returns:
762 Add result data
763 """
764 return self._run_async(
765 super().add_theme(
766 theme_id, available_locales, colors, cta_urls, msgid_suffix, version_ids
767 )
768 )
770 def remove_theme(self, theme_id: int) -> dict[str, Any]:
771 """Remove a theme from user's collection.
773 Args:
774 theme_id: Theme ID
776 Returns:
777 Remove result data
778 """
779 return self._run_async(super().remove_theme(theme_id))
781 def set_theme(
782 self, theme_id: int, previous_theme_id: int | None = None
783 ) -> dict[str, Any]:
784 """Set active theme.
786 Args:
787 theme_id: Theme ID
788 previous_theme_id: Previous theme ID (optional)
790 Returns:
791 Set result data
792 """
793 return self._run_async(super().set_theme(theme_id, previous_theme_id))
795 def get_theme_description(
796 self, theme_id: int, language_tag: str = "eng"
797 ) -> dict[str, Any]:
798 """Get theme description.
800 Args:
801 theme_id: Theme ID
802 language_tag: Language tag
804 Returns:
805 Theme description data
806 """
807 return self._run_async(super().get_theme_description(theme_id, language_tag))
809 # Friends API methods
810 def get_friends(self, page: int = 1) -> dict[str, Any]:
811 """Get the authenticated user's friends list."""
812 return self._run_async(super().get_friends(page))
814 def get_all_friends(self, page: int = 1) -> dict[str, Any]:
815 """Get all friends including extended metadata."""
816 return self._run_async(super().get_all_friends(page))
818 def delete_friend(self, user_id: int) -> dict[str, Any]:
819 """Remove a friend by user ID."""
820 return self._run_async(super().delete_friend(user_id))
822 def get_incoming_friend_requests(self, page: int = 1) -> dict[str, Any]:
823 """Get incoming friend requests."""
824 return self._run_async(super().get_incoming_friend_requests(page))
826 def get_friend_suggestions(self, page: int = 1) -> dict[str, Any]:
827 """Get friend suggestions."""
828 return self._run_async(super().get_friend_suggestions(page))
830 def accept_friend_request(self, user_id: int) -> dict[str, Any]:
831 """Accept an incoming friend request."""
832 return self._run_async(super().accept_friend_request(user_id))
834 def decline_friend_request(self, user_id: int) -> dict[str, Any]:
835 """Decline an incoming friend request."""
836 return self._run_async(super().decline_friend_request(user_id))
838 def dismiss_friend_suggestion(self, user_id: int) -> dict[str, Any]:
839 """Dismiss a friend suggestion."""
840 return self._run_async(super().dismiss_friend_suggestion(user_id))
842 def get_facebook_friends(self, page: int = 1) -> dict[str, Any]:
843 """Get Facebook-linked friend suggestions."""
844 return self._run_async(super().get_facebook_friends(page))
846 def sync_friendship_contacts(
847 self, contacts: list[dict[str, Any]]
848 ) -> dict[str, Any]:
849 """Upload contacts for friend matching."""
850 return self._run_async(super().sync_friendship_contacts(contacts))
852 # Notifications API methods
853 def get_notifications(self, page: int = 1) -> dict[str, Any]:
854 """Get notification feed items."""
855 return self._run_async(super().get_notifications(page))
857 def get_notification_settings(self) -> dict[str, Any]:
858 """Get notification settings."""
859 return self._run_async(super().get_notification_settings())
861 def update_notification_settings(self, data: dict[str, Any]) -> dict[str, Any]:
862 """Update notification settings."""
863 return self._run_async(super().update_notification_settings(data))
865 def update_notifications(self, data: dict[str, Any]) -> dict[str, Any]:
866 """Update notification read state."""
867 return self._run_async(super().update_notifications(data))
869 def get_votd_notification_settings(self) -> dict[str, Any]:
870 """Get VOTD notification settings."""
871 return self._run_async(super().get_votd_notification_settings())
873 def update_votd_notification_settings(self, data: dict[str, Any]) -> dict[str, Any]:
874 """Update VOTD notification settings."""
875 return self._run_async(super().update_votd_notification_settings(data))
877 # Share API methods
878 def invite_by_email(self, data: dict[str, Any]) -> dict[str, Any]:
879 """Send a YouVersion invite by email."""
880 return self._run_async(super().invite_by_email(data))
882 def invite_by_sms(self, data: dict[str, Any]) -> dict[str, Any]:
883 """Send a YouVersion invite by SMS."""
884 return self._run_async(super().invite_by_sms(data))
886 # Additional moments and search API methods
887 # Additional moments API methods
888 def get_client_side_moments(self, page: int = 1) -> dict[str, Any]:
889 """Get client-side moment items."""
890 return self._run_async(super().get_client_side_moments(page))
892 def get_moments_votd(self, language_tag: str | None = None) -> dict[str, Any]:
893 """Get verse of the day from the moments API."""
894 return self._run_async(super().get_moments_votd(language_tag))