From 65b12d650cb52c53aaf1e4ae807845faac244c55 Mon Sep 17 00:00:00 2001 From: Eduardo Kalinowski Date: Sun, 12 May 2024 15:10:23 -0300 Subject: [PATCH] Use different exit codes for different failures (#2243) --- docs/basic-usage.rst | 34 +++++++++++ docs/module/exceptions.rst | 10 ++++ instaloader/__main__.py | 95 +++++++++++++++++++------------ instaloader/exceptions.py | 8 ++- instaloader/instaloader.py | 31 +++++++--- instaloader/instaloadercontext.py | 40 ++++++++----- 6 files changed, 160 insertions(+), 58 deletions(-) diff --git a/docs/basic-usage.rst b/docs/basic-usage.rst index 54893f1..11a2b25 100644 --- a/docs/basic-usage.rst +++ b/docs/basic-usage.rst @@ -314,6 +314,40 @@ when they were downloaded:: instaloader --post-metadata-txt="{likes} likes, {comments} comments." /*.json.xz +.. _exit_codes: + +Exit codes +^^^^^^^^^^ + +Different exit codes are used to indicate different kinds of error: + +0 + No error, all downloads were successful. + +1 + A non-fatal error happened. One or more posts, or even one or more + profiles could not be downloaded, but execution was not stopped. The + errors are repeated at the end of the log for easy access. + +2 + Command-line error. An unrecognized option was passed, or an invalid + combination of options, for example. No interaction with Instagram + was made. + +3 + Login error. It was not possible to login. Downloads were not + attempted. + +4 + Fatal download error. Downloads were interrupted and no further + attempts were made. Happens when a response with one of the status + codes in the :option:`--abort-on` option were passed, or when + Instagram logs the user out during downloads. + +5 + Interrupted by the user. Happens when the user presses Control-C or + sends SIGINT to the process. + .. _instaloader-as-cronjob: Instaloader as Cronjob diff --git a/docs/module/exceptions.rst b/docs/module/exceptions.rst index 892e563..8f6e961 100644 --- a/docs/module/exceptions.rst +++ b/docs/module/exceptions.rst @@ -27,16 +27,26 @@ Exceptions .. autoexception:: LoginRequiredException +.. autoexception:: LoginException + + .. versionadded:: 4.12 + .. autoexception:: TwoFactorAuthRequiredException .. versionadded:: 4.2 + .. versionchanged:: 4.12 + Inherits LoginException + .. autoexception:: InvalidArgumentException .. autoexception:: BadResponseException .. autoexception:: BadCredentialsException + .. versionchanged:: 4.12 + Inherits LoginException + .. autoexception:: PostChangedException .. autoexception:: QueryReturnedNotFoundException diff --git a/instaloader/__main__.py b/instaloader/__main__.py index c7c46b1..d8a2f02 100644 --- a/instaloader/__main__.py +++ b/instaloader/__main__.py @@ -7,10 +7,11 @@ import re import sys import textwrap from argparse import ArgumentParser, ArgumentTypeError, SUPPRESS +from enum import IntEnum from typing import List, Optional from . import (AbortDownloadException, BadCredentialsException, Instaloader, InstaloaderException, - InvalidArgumentException, Post, Profile, ProfileNotExistsException, StoryItem, + InvalidArgumentException, LoginException, Post, Profile, ProfileNotExistsException, StoryItem, TwoFactorAuthRequiredException, __version__, load_structure_from_file) from .instaloader import (get_default_session_filename, get_default_stamps_filename) from .instaloadercontext import default_user_agent @@ -22,6 +23,15 @@ except ImportError: bc3_library = False +class ExitCode(IntEnum): + SUCCESS = 0 + NON_FATAL_ERROR = 1 + INIT_FAILURE = 2 + LOGIN_FAILURE = 3 + DOWNLOAD_ABORTED = 4 + USER_ABORTED = 5 + UNEXPECTED_ERROR = 99 + def usage_string(): # NOTE: duplicated in README.rst and docs/index.rst argv0 = os.path.basename(sys.argv[0]) @@ -84,9 +94,8 @@ def get_cookies_from_instagram(domain, browser, cookie_file='', cookie_name=''): } if browser not in supported_browsers: - print("Loading cookies from the specified browser failed") - print("Supported browsers are Chrome, Firefox, Edge, Brave, Opera and Safari") - return {} + raise InvalidArgumentException("Loading cookies from the specified browser failed\n" + "Supported browsers are Chrome, Firefox, Edge, Brave, Opera and Safari") cookies = {} browser_cookies = list(supported_browsers[browser](cookie_file=cookie_file)) @@ -98,7 +107,8 @@ def get_cookies_from_instagram(domain, browser, cookie_file='', cookie_name=''): if cookies: print(f"Cookies loaded successfully from {browser}") else: - print(f"No cookies found for Instagram in {browser}, Are you logged in succesfully in {browser}?") + raise LoginException(f"No cookies found for Instagram in {browser}, " + f"Are you logged in succesfully in {browser}?") if cookie_name: return cookies.get(cookie_name, {}) @@ -112,7 +122,7 @@ def import_session(browser, instaloader, cookiefile): instaloader.context.update_cookies(cookie) username = instaloader.test_login() if not username: - raise SystemExit(f"Not logged in. Are you logged in successfully in {browser}?") + raise LoginException(f"Not logged in. Are you logged in successfully in {browser}?") instaloader.context.username = username print(f"{username} has been successfully logged in.") next_step_text = (f"Next: Run instaloader --login={username} as it is required to download high quality media " @@ -133,7 +143,7 @@ def _main(instaloader: Instaloader, targetlist: List[str], max_count: Optional[int] = None, post_filter_str: Optional[str] = None, storyitem_filter_str: Optional[str] = None, browser: Optional[str] = None, - cookiefile: Optional[str] = None) -> None: + cookiefile: Optional[str] = None) -> ExitCode: """Download set of profiles, hashtags etc. and handle logging in and session files if desired.""" # Parse and generate filter function post_filter = None @@ -152,7 +162,7 @@ def _main(instaloader: Instaloader, targetlist: List[str], if browser and bc3_library: import_session(browser.lower(), instaloader, cookiefile) elif browser and not bc3_library: - raise SystemExit("browser_cookie3 library is needed to load cookies from browsers") + raise InvalidArgumentException("browser_cookie3 library is needed to load cookies from browsers") # Login, if desired if username is not None: if not re.match(r"^[A-Za-z0-9._]+$", username): @@ -189,6 +199,7 @@ def _main(instaloader: Instaloader, targetlist: List[str], # Try block for KeyboardInterrupt (save session on ^C) profiles = set() anonymous_retry_profiles = set() + exit_code = ExitCode.SUCCESS try: # Generate set of profiles, already downloading non-profile targets for target in targetlist: @@ -294,8 +305,10 @@ def _main(instaloader: Instaloader, targetlist: List[str], latest_stamps=latest_stamps) except KeyboardInterrupt: print("\nInterrupted by user.", file=sys.stderr) + exit_code = ExitCode.USER_ABORTED except AbortDownloadException as exc: print("\nDownload aborted: {}.".format(exc), file=sys.stderr) + exit_code = ExitCode.DOWNLOAD_ABORTED # Save session if it is useful if instaloader.context.is_logged_in: instaloader.save_session_to_file(sessionfile) @@ -307,6 +320,8 @@ def _main(instaloader: Instaloader, targetlist: List[str], else: # Instaloader did not do anything instaloader.context.log("usage:" + usage_string()) + exit_code = ExitCode.INIT_FAILURE + return exit_code def main(): @@ -488,11 +503,11 @@ def main(): print("--login=USERNAME required to download stories.", file=sys.stderr) args.stories = False if args.stories_only: - raise SystemExit(1) + raise InvalidArgumentException() if ':feed-all' in args.profile or ':feed-liked' in args.profile: - raise SystemExit(":feed-all and :feed-liked were removed. Use :feed as target and " - "eventually --post-filter=viewer_has_liked.") + raise InvalidArgumentException(":feed-all and :feed-liked were removed. Use :feed as target and " + "eventually --post-filter=viewer_has_liked.") post_metadata_txt_pattern = '\n'.join(args.post_metadata_txt) if args.post_metadata_txt else None storyitem_metadata_txt_pattern = '\n'.join(args.storyitem_metadata_txt) if args.storyitem_metadata_txt else None @@ -502,18 +517,18 @@ def main(): post_metadata_txt_pattern = '' storyitem_metadata_txt_pattern = '' else: - raise SystemExit("--no-captions and --post-metadata-txt or --storyitem-metadata-txt given; " - "That contradicts.") + raise InvalidArgumentException("--no-captions and --post-metadata-txt or --storyitem-metadata-txt " + "given; That contradicts.") if args.no_resume and args.resume_prefix: - raise SystemExit("--no-resume and --resume-prefix given; That contradicts.") + raise InvalidArgumentException("--no-resume and --resume-prefix given; That contradicts.") resume_prefix = (args.resume_prefix if args.resume_prefix else 'iterator') if not args.no_resume else None if args.no_pictures and args.fast_update: - raise SystemExit('--no-pictures and --fast-update cannot be used together.') + raise InvalidArgumentException('--no-pictures and --fast-update cannot be used together.') if args.login and args.load_cookies: - raise SystemExit('--load-cookies and --login cannot be used together.') + raise InvalidArgumentException('--load-cookies and --login cannot be used together.') # Determine what to download download_profile_pic = not args.no_profile_pic or args.profile_pic_only @@ -538,27 +553,37 @@ def main(): iphone_support=not args.no_iphone, title_pattern=args.title_pattern, sanitize_paths=args.sanitize_paths) - _main(loader, - args.profile, - username=args.login.lower() if args.login is not None else None, - password=args.password, - sessionfile=args.sessionfile, - download_profile_pic=download_profile_pic, - download_posts=download_posts, - download_stories=download_stories, - download_highlights=args.highlights, - download_tagged=args.tagged, - download_igtv=args.igtv, - fast_update=args.fast_update, - latest_stamps_file=args.latest_stamps, - max_count=int(args.count) if args.count is not None else None, - post_filter_str=args.post_filter, - storyitem_filter_str=args.storyitem_filter, - browser=args.load_cookies, - cookiefile=args.cookiefile) + exit_code = _main(loader, + args.profile, + username=args.login.lower() if args.login is not None else None, + password=args.password, + sessionfile=args.sessionfile, + download_profile_pic=download_profile_pic, + download_posts=download_posts, + download_stories=download_stories, + download_highlights=args.highlights, + download_tagged=args.tagged, + download_igtv=args.igtv, + fast_update=args.fast_update, + latest_stamps_file=args.latest_stamps, + max_count=int(args.count) if args.count is not None else None, + post_filter_str=args.post_filter, + storyitem_filter_str=args.storyitem_filter, + browser=args.load_cookies, + cookiefile=args.cookiefile) loader.close() + if loader.has_stored_errors: + exit_code = ExitCode.NON_FATAL_ERROR + except InvalidArgumentException as err: + print(err, file=sys.stderr) + exit_code = ExitCode.INIT_FAILURE + except LoginException as err: + print(err, file=sys.stderr) + exit_code = ExitCode.LOGIN_FAILURE except InstaloaderException as err: - raise SystemExit("Fatal error: %s" % err) from err + print("Fatal error: %s" % err) + exit_code = ExitCode.UNEXPECTED_ERROR + sys.exit(exit_code) if __name__ == "__main__": diff --git a/instaloader/exceptions.py b/instaloader/exceptions.py index 271d09b..9145d8e 100644 --- a/instaloader/exceptions.py +++ b/instaloader/exceptions.py @@ -33,7 +33,11 @@ class LoginRequiredException(InstaloaderException): pass -class TwoFactorAuthRequiredException(InstaloaderException): +class LoginException(InstaloaderException): + pass + + +class TwoFactorAuthRequiredException(LoginException): pass @@ -45,7 +49,7 @@ class BadResponseException(InstaloaderException): pass -class BadCredentialsException(InstaloaderException): +class BadCredentialsException(LoginException): pass diff --git a/instaloader/instaloader.py b/instaloader/instaloader.py index e854360..caf6bca 100644 --- a/instaloader/instaloader.py +++ b/instaloader/instaloader.py @@ -643,11 +643,16 @@ class Instaloader: def login(self, user: str, passwd: str) -> None: """Log in to instagram with given username and password and internally store session object. - :raises InvalidArgumentException: If the provided username does not exist. :raises BadCredentialsException: If the provided password is wrong. - :raises ConnectionException: If connection to Instagram failed. :raises TwoFactorAuthRequiredException: First step of 2FA login done, now call - :meth:`Instaloader.two_factor_login`.""" + :meth:`Instaloader.two_factor_login`. + :raises LoginException: An error happened during login (for example, an invalid response was received). + Or if the provided username does not exist. + + .. versionchanged:: 4.12 + Raises LoginException instead of ConnectionException when an error happens. + Raises LoginException instead of InvalidArgumentException when the username does not exist. + """ self.context.login(user, passwd) def two_factor_login(self, two_factor_code) -> None: @@ -1582,11 +1587,16 @@ class Instaloader: def interactive_login(self, username: str) -> None: """Logs in and internally stores session, asking user for password interactively. - :raises LoginRequiredException: when in quiet mode. - :raises InvalidArgumentException: If the provided username does not exist. - :raises ConnectionException: If connection to Instagram failed.""" + :raises InvalidArgumentException: when in quiet mode. + :raises LoginException: If the provided username does not exist. + :raises ConnectionException: If connection to Instagram failed. + + .. versionchanged:: 4.12 + Raises InvalidArgumentException instead of LoginRequiredException when in quiet mode. + Raises LoginException instead of InvalidArgumentException when the username does not exist. + """ if self.context.quiet: - raise LoginRequiredException("Quiet mode requires given password or valid session file.") + raise InvalidArgumentException("Quiet mode requires given password or valid session file.") try: password = None while password is None: @@ -1605,3 +1615,10 @@ class Instaloader: except BadCredentialsException as err: print(err, file=sys.stderr) pass + + @property + def has_stored_errors(self) -> bool: + """Returns whether any error has been reported and stored to be repeated at program termination. + + .. versionadded: 4.12""" + return self.context.has_stored_errors diff --git a/instaloader/instaloadercontext.py b/instaloader/instaloadercontext.py index 5efd08a..8ee87d0 100644 --- a/instaloader/instaloadercontext.py +++ b/instaloader/instaloadercontext.py @@ -151,6 +151,13 @@ class InstaloaderContext: if repeat_at_end: self.error_log.append(msg) + @property + def has_stored_errors(self) -> bool: + """Returns whether any error has been reported and stored to be repeated at program termination. + + .. versionadded: 4.12""" + return bool(self.error_log) + def close(self): """Print error log and close session""" if self.error_log and not self.quiet: @@ -242,11 +249,16 @@ class InstaloaderContext: def login(self, user, passwd): """Not meant to be used directly, use :meth:`Instaloader.login`. - :raises InvalidArgumentException: If the provided username does not exist. :raises BadCredentialsException: If the provided password is wrong. - :raises ConnectionException: If connection to Instagram failed. :raises TwoFactorAuthRequiredException: First step of 2FA login done, now call - :meth:`Instaloader.two_factor_login`.""" + :meth:`Instaloader.two_factor_login`. + :raises LoginException: An error happened during login (for example, and invalid response). + Or if the provided username does not exist. + + .. versionchanged:: 4.12 + Raises LoginException instead of ConnectionException when an error happens. + Raises LoginException instead of InvalidArgumentException when the username does not exist. + """ # pylint:disable=import-outside-toplevel import http.client # pylint:disable=protected-access @@ -277,7 +289,7 @@ class InstaloaderContext: resp_json = login.json() except json.decoder.JSONDecodeError as err: - raise ConnectionException( + raise LoginException( "Login error: JSON decode fail, {} - {}.".format(login.status_code, login.reason) ) from err if resp_json.get('two_factor_required'): @@ -289,31 +301,31 @@ class InstaloaderContext: resp_json['two_factor_info']['two_factor_identifier']) raise TwoFactorAuthRequiredException("Login error: two-factor authentication required.") if resp_json.get('checkpoint_url'): - raise ConnectionException("Login: Checkpoint required. Point your browser to " - "https://www.instagram.com{} - " - "follow the instructions, then retry.".format(resp_json.get('checkpoint_url'))) + raise LoginException("Login: Checkpoint required. Point your browser to " + "https://www.instagram.com{} - " + "follow the instructions, then retry.".format(resp_json.get('checkpoint_url'))) if resp_json['status'] != 'ok': if 'message' in resp_json: - raise ConnectionException("Login error: \"{}\" status, message \"{}\".".format(resp_json['status'], - resp_json['message'])) + raise LoginException("Login error: \"{}\" status, message \"{}\".".format(resp_json['status'], + resp_json['message'])) else: - raise ConnectionException("Login error: \"{}\" status.".format(resp_json['status'])) + raise LoginException("Login error: \"{}\" status.".format(resp_json['status'])) if 'authenticated' not in resp_json: # Issue #472 if 'message' in resp_json: - raise ConnectionException("Login error: Unexpected response, \"{}\".".format(resp_json['message'])) + raise LoginException("Login error: Unexpected response, \"{}\".".format(resp_json['message'])) else: - raise ConnectionException("Login error: Unexpected response, this might indicate a blocked IP.") + raise LoginException("Login error: Unexpected response, this might indicate a blocked IP.") if not resp_json['authenticated']: if resp_json['user']: # '{"authenticated": false, "user": true, "status": "ok"}' raise BadCredentialsException('Login error: Wrong password.') else: # '{"authenticated": false, "user": false, "status": "ok"}' - # Raise InvalidArgumentException rather than BadCredentialException, because BadCredentialException + # Raise LoginException rather than BadCredentialException, because BadCredentialException # triggers re-asking of password in Instaloader.interactive_login(), which makes no sense if the # username is invalid. - raise InvalidArgumentException('Login error: User {} does not exist.'.format(user)) + raise LoginException('Login error: User {} does not exist.'.format(user)) # '{"authenticated": true, "user": true, "userId": ..., "oneTapPrompt": false, "status": "ok"}' session.headers.update({'X-CSRFToken': login.cookies['csrftoken']}) self._session = session