OLD | NEW |
---|---|
1 #!/usr/bin/env python | 1 #!/usr/bin/env python |
2 # Copyright (c) 2012 The Chromium Authors. All rights reserved. | 2 # Copyright (c) 2012 The Chromium Authors. All rights reserved. |
3 # Use of this source code is governed by a BSD-style license that can be | 3 # Use of this source code is governed by a BSD-style license that can be |
4 # found in the LICENSE file. | 4 # found in the LICENSE file. |
5 | 5 |
6 # Virtual Me2Me implementation. This script runs and manages the processes | 6 # Virtual Me2Me implementation. This script runs and manages the processes |
7 # required for a Virtual Me2Me desktop, which are: X server, X desktop | 7 # required for a Virtual Me2Me desktop, which are: X server, X desktop |
8 # session, and Host process. | 8 # session, and Host process. |
9 # This script is intended to run continuously as a background daemon | 9 # This script is intended to run continuously as a background daemon |
10 # process, running under an ordinary (non-root) user account. | 10 # process, running under an ordinary (non-root) user account. |
(...skipping 56 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
67 FIRST_X_DISPLAY_NUMBER = 20 | 67 FIRST_X_DISPLAY_NUMBER = 20 |
68 | 68 |
69 X_AUTH_FILE = os.path.expanduser("~/.Xauthority") | 69 X_AUTH_FILE = os.path.expanduser("~/.Xauthority") |
70 os.environ["XAUTHORITY"] = X_AUTH_FILE | 70 os.environ["XAUTHORITY"] = X_AUTH_FILE |
71 | 71 |
72 | 72 |
73 # Globals needed by the atexit cleanup() handler. | 73 # Globals needed by the atexit cleanup() handler. |
74 g_desktops = [] | 74 g_desktops = [] |
75 g_pidfile = None | 75 g_pidfile = None |
76 | 76 |
77 class Config: | |
78 def __init__(self, path): | |
79 self.path = path | |
80 self.data = {} | |
81 self.changed = False | |
82 | |
83 def load(self): | |
84 try: | |
85 settings_file = open(self.path, 'r') | |
86 self.data = json.load(settings_file) | |
87 self.changed = False | |
88 settings_file.close() | |
89 except: | |
90 return False | |
91 return True | |
92 | |
93 def save(self): | |
94 if not self.changed: | |
95 return True | |
96 try: | |
97 old_umask = os.umask(0066) | |
98 settings_file = open(self.path, 'w') | |
99 settings_file.write(json.dumps(self.data, indent=2)) | |
100 settings_file.close() | |
101 os.umask(old_umask) | |
102 except: | |
103 return False | |
104 self.changed = False | |
105 return True | |
106 | |
107 def get(self, key): | |
108 return self.data.get(key) | |
109 | |
110 def __getitem__(self, key): | |
111 return self.data[key] | |
112 | |
113 def __setitem__(self, key, value): | |
114 self.data[key] = value | |
115 self.changed = True | |
116 | |
117 def clear_auth(self): | |
118 del self.data["xmpp_login"] | |
119 del self.data["chromoting_auth_token"] | |
120 del self.data["xmpp_auth_token"] | |
121 | |
122 def clear_host_info(self): | |
123 del self.data["host_id"] | |
124 del self.data["host_name"] | |
125 del self.data["host_secret_hash"] | |
126 del self.data["private_key"] | |
77 | 127 |
78 class Authentication: | 128 class Authentication: |
79 """Manage authentication tokens for Chromoting/xmpp""" | 129 """Manage authentication tokens for Chromoting/xmpp""" |
80 | 130 |
81 def __init__(self, config_file): | 131 def __init__(self): |
82 self.config_file = config_file | 132 pass |
83 | 133 |
84 def generate_tokens(self): | 134 def generate_tokens(self): |
85 """Prompt for username/password and use them to generate new authentication | 135 """Prompt for username/password and use them to generate new authentication |
86 tokens. | 136 tokens. |
87 | 137 |
88 Raises: | 138 Raises: |
89 Exception: Failed to get new authentication tokens. | 139 Exception: Failed to get new authentication tokens. |
90 """ | 140 """ |
91 print "Email:", | 141 print "Email:", |
92 self.login = raw_input() | 142 self.login = raw_input() |
93 password = getpass.getpass("App-specific password: ") | 143 password = getpass.getpass("App-specific password: ") |
94 | 144 |
95 chromoting_auth = gaia_auth.GaiaAuthenticator('chromoting') | 145 chromoting_auth = gaia_auth.GaiaAuthenticator('chromoting') |
96 self.chromoting_auth_token = chromoting_auth.authenticate(self.login, | 146 self.chromoting_auth_token = chromoting_auth.authenticate(self.login, |
97 password) | 147 password) |
98 | 148 |
99 xmpp_authenticator = gaia_auth.GaiaAuthenticator('chromiumsync') | 149 xmpp_authenticator = gaia_auth.GaiaAuthenticator('chromiumsync') |
100 self.xmpp_auth_token = xmpp_authenticator.authenticate(self.login, | 150 self.xmpp_auth_token = xmpp_authenticator.authenticate(self.login, |
101 password) | 151 password) |
102 | 152 |
103 def load_config(self): | 153 def load_config(self, config): |
104 try: | 154 try: |
105 settings_file = open(self.config_file, 'r') | 155 self.login = config["xmpp_login"] |
106 data = json.load(settings_file) | 156 self.chromoting_auth_token = config["chromoting_auth_token"] |
107 settings_file.close() | 157 self.xmpp_auth_token = config["xmpp_auth_token"] |
108 self.login = data["xmpp_login"] | 158 except KeyError: |
109 self.chromoting_auth_token = data["chromoting_auth_token"] | |
110 self.xmpp_auth_token = data["xmpp_auth_token"] | |
111 except: | |
112 return False | 159 return False |
113 return True | 160 return True |
114 | 161 |
115 def save_config(self): | 162 def save_config(self, config): |
116 data = { | 163 config["xmpp_login"] = self.login |
117 "xmpp_login": self.login, | 164 config["chromoting_auth_token"] = self.chromoting_auth_token |
118 "chromoting_auth_token": self.chromoting_auth_token, | 165 config["xmpp_auth_token"] = self.xmpp_auth_token |
119 "xmpp_auth_token": self.xmpp_auth_token, | |
120 } | |
121 # File will contain private keys, so deny read/write access to others. | |
122 old_umask = os.umask(0066) | |
123 settings_file = open(self.config_file, 'w') | |
124 settings_file.write(json.dumps(data, indent=2)) | |
125 settings_file.close() | |
126 os.umask(old_umask) | |
127 | |
128 | 166 |
129 class Host: | 167 class Host: |
130 """This manages the configuration for a host. | 168 """This manages the configuration for a host. |
131 | 169 |
132 Callers should instantiate a Host object (passing in a filename where the | 170 Callers should instantiate a Host object (passing in a filename where the |
133 config will be kept), then should call either of the methods: | 171 config will be kept), then should call either of the methods: |
134 | 172 |
135 * register(auth): Create a new Host configuration and register it | 173 * register(auth): Create a new Host configuration and register it |
136 with the Directory Service (the "auth" parameter is used to | 174 with the Directory Service (the "auth" parameter is used to |
137 authenticate with the Service). | 175 authenticate with the Service). |
138 * load_config(): Load a config from disk, with details of an existing Host | 176 * load_config(): Load a config from disk, with details of an existing Host |
139 registration. | 177 registration. |
140 | 178 |
141 After calling register() (or making any config changes) the method | 179 After calling register() (or making any config changes) the method |
142 save_config() should be called to save the details to disk. | 180 save_config() should be called to save the details to disk. |
143 """ | 181 """ |
144 | 182 |
145 server = 'www.googleapis.com' | 183 server = 'www.googleapis.com' |
146 url = 'https://' + server + '/chromoting/v1/@me/hosts' | 184 url = 'https://' + server + '/chromoting/v1/@me/hosts' |
147 | 185 |
148 def __init__(self, config_file, auth): | 186 def __init__(self, auth): |
149 """ | 187 """ |
150 Args: | 188 Args: |
151 config_file: Host configuration file path | 189 config: Host configuration object |
152 auth: Authentication object with credentials for authenticating with the | 190 auth: Authentication object with credentials for authenticating with the |
153 Directory service. | 191 Directory service. |
154 """ | 192 """ |
155 self.config_file = config_file | |
156 self.auth = auth | 193 self.auth = auth |
157 self.host_id = str(uuid.uuid1()) | 194 self.host_id = str(uuid.uuid1()) |
158 self.host_name = socket.gethostname() | 195 self.host_name = socket.gethostname() |
159 self.host_secret_hash = None | 196 self.host_secret_hash = None |
160 self.private_key = None | 197 self.private_key = None |
161 | 198 |
162 def register(self): | 199 def register(self): |
163 """Generates a private key for the stored |host_id|, and registers it with | 200 """Generates a private key for the stored |host_id|, and registers it with |
164 the Directory service. | 201 the Directory service. |
165 | 202 |
(...skipping 49 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
215 def set_pin(self, pin): | 252 def set_pin(self, pin): |
216 if pin == "": | 253 if pin == "": |
217 self.host_secret_hash = "plain:" | 254 self.host_secret_hash = "plain:" |
218 else: | 255 else: |
219 self.host_secret_hash = "hmac:" + base64.b64encode( | 256 self.host_secret_hash = "hmac:" + base64.b64encode( |
220 hmac.new(str(self.host_id), pin, hashlib.sha256).digest()) | 257 hmac.new(str(self.host_id), pin, hashlib.sha256).digest()) |
221 | 258 |
222 def is_pin_set(self): | 259 def is_pin_set(self): |
223 return self.host_secret_hash | 260 return self.host_secret_hash |
224 | 261 |
225 def load_config(self): | 262 def load_config(self, config): |
226 try: | 263 try: |
227 settings_file = open(self.config_file, 'r') | 264 self.host_id = config["host_id"] |
228 data = json.load(settings_file) | 265 self.host_name = config["host_name"] |
229 settings_file.close() | 266 self.host_secret_hash = config.get("host_secret_hash") |
230 except: | 267 self.private_key = config["private_key"] |
231 logging.info("Failed to load: " + self.config_file) | 268 except KeyError: |
232 return False | 269 return False |
233 self.host_id = data["host_id"] | |
234 self.host_name = data["host_name"] | |
235 self.host_secret_hash = data.get("host_secret_hash") | |
236 self.private_key = data["private_key"] | |
237 return True | 270 return True |
238 | 271 |
239 def save_config(self): | 272 def save_config(self, config): |
240 data = { | 273 config["host_id"] = self.host_id |
241 "host_id": self.host_id, | 274 config["host_name"] = self.host_name |
242 "host_name": self.host_name, | 275 config["host_secret_hash"] = self.host_secret_hash |
243 "host_secret_hash": self.host_secret_hash, | 276 config["private_key"] = self.private_key |
244 "private_key": self.private_key, | |
245 } | |
246 if self.host_secret_hash: | |
247 data["host_secret_hash"] = self.host_secret_hash | |
248 | |
249 old_umask = os.umask(0066) | |
250 settings_file = open(self.config_file, 'w') | |
251 settings_file.write(json.dumps(data, indent=2)) | |
252 settings_file.close() | |
253 os.umask(old_umask) | |
254 | |
255 | 277 |
256 class Desktop: | 278 class Desktop: |
257 """Manage a single virtual desktop""" | 279 """Manage a single virtual desktop""" |
258 | 280 |
259 def __init__(self, sizes): | 281 def __init__(self, sizes): |
260 self.x_proc = None | 282 self.x_proc = None |
261 self.session_proc = None | 283 self.session_proc = None |
262 self.host_proc = None | 284 self.host_proc = None |
263 self.sizes = sizes | 285 self.sizes = sizes |
264 g_desktops.append(self) | 286 g_desktops.append(self) |
(...skipping 108 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
373 # Daemonization would solve this problem by separating the process from the | 395 # Daemonization would solve this problem by separating the process from the |
374 # controlling terminal. | 396 # controlling terminal. |
375 logging.info("Launching X session: %s" % XSESSION_COMMAND) | 397 logging.info("Launching X session: %s" % XSESSION_COMMAND) |
376 self.session_proc = subprocess.Popen(XSESSION_COMMAND, | 398 self.session_proc = subprocess.Popen(XSESSION_COMMAND, |
377 stdin=open(os.devnull, "r"), | 399 stdin=open(os.devnull, "r"), |
378 cwd=HOME_DIR, | 400 cwd=HOME_DIR, |
379 env=self.child_env) | 401 env=self.child_env) |
380 if not self.session_proc.pid: | 402 if not self.session_proc.pid: |
381 raise Exception("Could not start X session") | 403 raise Exception("Could not start X session") |
382 | 404 |
383 def launch_host(self, host): | 405 def launch_host(self, host_config): |
384 # Start remoting host | 406 # Start remoting host |
385 args = [locate_executable(REMOTING_COMMAND), | 407 args = [locate_executable(REMOTING_COMMAND), |
386 "--host-config=%s" % (host.config_file)] | 408 "--host-config=%s" % (host_config.path)] |
387 if host.auth.config_file != host.config_file: | |
388 args.append("--auth-config=%s" % (host.auth.config_file)) | |
389 self.host_proc = subprocess.Popen(args, env=self.child_env) | 409 self.host_proc = subprocess.Popen(args, env=self.child_env) |
390 logging.info(args) | 410 logging.info(args) |
391 if not self.host_proc.pid: | 411 if not self.host_proc.pid: |
392 raise Exception("Could not start remoting host") | 412 raise Exception("Could not start remoting host") |
393 | 413 |
394 | 414 |
395 class PidFile: | 415 class PidFile: |
396 """Class to allow creating and deleting a file which holds the PID of the | 416 """Class to allow creating and deleting a file which holds the PID of the |
397 running process. This is used to detect if a process is already running, and | 417 running process. This is used to detect if a process is already running, and |
398 inform the user of the PID. On process termination, the PID file is | 418 inform the user of the PID. On process termination, the PID file is |
(...skipping 296 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
695 | 715 |
696 atexit.register(cleanup) | 716 atexit.register(cleanup) |
697 | 717 |
698 for s in [signal.SIGHUP, signal.SIGINT, signal.SIGTERM, signal.SIGUSR1]: | 718 for s in [signal.SIGHUP, signal.SIGINT, signal.SIGTERM, signal.SIGUSR1]: |
699 signal.signal(s, signal_handler) | 719 signal.signal(s, signal_handler) |
700 | 720 |
701 # Ensure full path to config directory exists. | 721 # Ensure full path to config directory exists. |
702 if not os.path.exists(CONFIG_DIR): | 722 if not os.path.exists(CONFIG_DIR): |
703 os.makedirs(CONFIG_DIR, mode=0700) | 723 os.makedirs(CONFIG_DIR, mode=0700) |
704 | 724 |
705 host_config_file = os.path.join(CONFIG_DIR, "host#%s.json" % host_hash) | 725 host_config = Config(os.path.join(CONFIG_DIR, "host#%s.json" % host_hash)) |
726 host_config.load() | |
706 | 727 |
707 # --silent option is specified when we are started from WebApp UI. Don't use | 728 auth = Authentication() |
708 # separate auth file in that case. | 729 auth_config_loaded = auth.load_config(host_config) |
709 # TODO(sergeyu): Always use host config for auth parameters. | 730 if not auth_config_loaded and not options.silent: |
710 if options.silent: | 731 # If we failed to load authentication parameters from the host config |
711 auth_config_file = host_config_file | 732 # then try loading them from the legacy auth.json file. |
712 else: | 733 auth_config = Config(os.path.join(CONFIG_DIR, "auth.json")) |
713 auth_config_file = os.path.join(CONFIG_DIR, "auth.json") | 734 if auth_config.load(): |
735 auth_config_loaded = auth.load_config(auth_config) | |
736 # If we were able to read auth.json then copy its content to the host | |
737 # config. | |
738 if auth_config_loaded: | |
739 auth.save_config(host_config) | |
740 host_config.save() | |
714 | 741 |
715 auth = Authentication(auth_config_file) | 742 host = Host(auth) |
716 auth_config_loaded = auth.load_config() | 743 host_config_loaded = host.load_config(host_config) |
717 | |
718 host = Host(host_config_file, auth) | |
719 host_config_loaded = host.load_config() | |
720 | 744 |
721 if options.silent: | 745 if options.silent: |
722 if not host_config_loaded or not auth_config_loaded: | 746 if not host_config_loaded or not auth_config_loaded: |
723 logging.error("Failed to load host configuration.") | 747 logging.error("Failed to load host configuration.") |
724 return 1 | 748 return 1 |
725 else: | 749 else: |
726 need_auth_tokens = not auth_config_loaded | 750 need_auth_tokens = not auth_config_loaded |
727 need_register_host = not host_config_loaded | 751 need_register_host = not host_config_loaded |
728 # Outside the loop so user doesn't get asked twice. | 752 # Outside the loop so user doesn't get asked twice. |
729 if need_register_host: | 753 if need_register_host: |
730 host.ask_pin() | 754 host.ask_pin() |
731 elif options.new_pin or not host.is_pin_set(): | 755 elif options.new_pin or not host.is_pin_set(): |
732 host.ask_pin() | 756 host.ask_pin() |
733 host.save_config() | 757 host.save_config(host_config) |
734 running, pid = PidFile(pid_filename).check() | 758 running, pid = PidFile(pid_filename).check() |
735 if running and pid != 0: | 759 if running and pid != 0: |
736 os.kill(pid, signal.SIGUSR1) | 760 os.kill(pid, signal.SIGUSR1) |
737 print "The running instance has been updated with the new PIN." | 761 print "The running instance has been updated with the new PIN." |
738 return 0 | 762 return 0 |
739 | 763 |
740 # The loop is to deal with the case of registering a new Host with | 764 # The loop is to deal with the case of registering a new Host with |
741 # previously-saved auth tokens (from a previous run of this script), which | 765 # previously-saved auth tokens (from a previous run of this script), which |
742 # may require re-prompting for username & password. | 766 # may require re-prompting for username & password. |
743 while True: | 767 while True: |
744 try: | 768 if need_auth_tokens: |
745 if need_auth_tokens: | 769 try: |
746 auth.generate_tokens() | 770 auth.generate_tokens() |
747 auth.save_config() | |
748 need_auth_tokens = False | 771 need_auth_tokens = False |
749 except Exception: | 772 except Exception: |
750 logging.error("Authentication failed") | 773 logging.error("Authentication failed") |
751 return 1 | 774 return 1 |
775 # Save the new auth tokens. | |
776 auth.save_config(host_config) | |
777 if not host_config.save(): | |
778 logging.error("Faled to save host configuration.") | |
Lambros
2012/08/09 01:20:27
s/Faled/Failed
Sergey Ulanov
2012/08/09 02:13:10
Done.
| |
779 return 1 | |
752 | 780 |
753 try: | 781 if need_register_host: |
754 if need_register_host: | 782 try: |
755 host.register() | 783 host.register() |
756 host.save_config() | 784 host.save_config(host_config) |
757 except urllib2.HTTPError, err: | 785 except urllib2.HTTPError, err: |
758 if err.getcode() == 401: | 786 if err.getcode() == 401: |
759 # Authentication failed - re-prompt for username & password. | 787 # Authentication failed - re-prompt for username & password. |
760 need_auth_tokens = True | 788 need_auth_tokens = True |
761 continue | 789 continue |
762 else: | 790 else: |
763 # Not an authentication error. | 791 # Not an authentication error. |
764 logging.error("Directory returned error: " + str(err)) | 792 logging.error("Directory returned error: " + str(err)) |
765 logging.error(err.read()) | 793 logging.error(err.read()) |
766 return 1 | 794 return 1 |
767 | 795 |
768 # |auth| and |host| are both set up, so break out of the loop. | 796 # |auth| and |host| are both set up, so break out of the loop. |
769 break | 797 break |
770 | 798 |
799 if not host_config.save(): | |
800 logging.error("Faled to save host configuration.") | |
Lambros
2012/08/09 01:20:27
s/Faled/Failed
Sergey Ulanov
2012/08/09 02:13:10
Done.
| |
801 return 1 | |
802 | |
771 global g_pidfile | 803 global g_pidfile |
772 g_pidfile = PidFile(pid_filename) | 804 g_pidfile = PidFile(pid_filename) |
773 running, pid = g_pidfile.check() | 805 running, pid = g_pidfile.check() |
774 | 806 |
775 if running: | 807 if running: |
776 print "An instance of this script is already running." | 808 print "An instance of this script is already running." |
777 print "Use the -k flag to terminate the running instance." | 809 print "Use the -k flag to terminate the running instance." |
778 print "If this isn't the case, delete '%s' and try again." % pid_filename | 810 print "If this isn't the case, delete '%s' and try again." % pid_filename |
779 return 1 | 811 return 1 |
780 | 812 |
(...skipping 41 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
822 "before starting new session.") | 854 "before starting new session.") |
823 time.sleep(60 - elapsed) | 855 time.sleep(60 - elapsed) |
824 | 856 |
825 logging.info("Launching X server and X session") | 857 logging.info("Launching X server and X session") |
826 last_launch_time = time.time() | 858 last_launch_time = time.time() |
827 desktop.launch_x_server(args) | 859 desktop.launch_x_server(args) |
828 desktop.launch_x_session() | 860 desktop.launch_x_session() |
829 | 861 |
830 if desktop.host_proc is None: | 862 if desktop.host_proc is None: |
831 logging.info("Launching host process") | 863 logging.info("Launching host process") |
832 desktop.launch_host(host) | 864 desktop.launch_host(host_config) |
833 | 865 |
834 try: | 866 try: |
835 pid, status = os.wait() | 867 pid, status = os.wait() |
836 except OSError, e: | 868 except OSError, e: |
837 if e.errno == errno.EINTR: | 869 if e.errno == errno.EINTR: |
838 # Retry on EINTR, which can happen if a signal such as SIGUSR1 is | 870 # Retry on EINTR, which can happen if a signal such as SIGUSR1 is |
839 # received. | 871 # received. |
840 continue | 872 continue |
841 else: | 873 else: |
842 # Anything else is an unexpected error. | 874 # Anything else is an unexpected error. |
(...skipping 16 matching lines...) Expand all Loading... | |
859 logging.info("Host process terminated") | 891 logging.info("Host process terminated") |
860 desktop.host_proc = None | 892 desktop.host_proc = None |
861 | 893 |
862 # These exit-codes must match the ones used by the host. | 894 # These exit-codes must match the ones used by the host. |
863 # See remoting/host/constants.h. | 895 # See remoting/host/constants.h. |
864 # Delete the host or auth configuration depending on the returned error | 896 # Delete the host or auth configuration depending on the returned error |
865 # code, so the next time this script is run, a new configuration | 897 # code, so the next time this script is run, a new configuration |
866 # will be created and registered. | 898 # will be created and registered. |
867 if os.WEXITSTATUS(status) == 2: | 899 if os.WEXITSTATUS(status) == 2: |
868 logging.info("Host configuration is invalid - exiting.") | 900 logging.info("Host configuration is invalid - exiting.") |
869 try: | 901 host_config.clear_auth() |
870 os.remove(host.config_file) | 902 host_config.clear_host_info() |
871 os.remove(auth.config_file) | 903 host_config.save() |
872 except: | |
873 pass | |
874 return 0 | 904 return 0 |
875 elif os.WEXITSTATUS(status) == 3: | 905 elif os.WEXITSTATUS(status) == 3: |
876 logging.info("Host ID has been deleted - exiting.") | 906 logging.info("Host ID has been deleted - exiting.") |
877 try: | 907 host_config.clear_host_info() |
878 os.remove(host.config_file) | 908 host_config.save() |
879 except: | |
880 pass | |
881 return 0 | 909 return 0 |
882 elif os.WEXITSTATUS(status) == 4: | 910 elif os.WEXITSTATUS(status) == 4: |
883 logging.info("OAuth credentials are invalid - exiting.") | 911 logging.info("OAuth credentials are invalid - exiting.") |
884 try: | 912 host_config.clear_auth() |
885 os.remove(auth.config_file) | 913 host_config.save() |
886 except: | |
887 pass | |
888 return 0 | 914 return 0 |
889 elif os.WEXITSTATUS(status) == 5: | 915 elif os.WEXITSTATUS(status) == 5: |
890 logging.info("Host domain is blocked by policy - exiting.") | 916 logging.info("Host domain is blocked by policy - exiting.") |
891 os.remove(host.config_file) | 917 os.remove(host.config_file) |
892 return 0 | 918 return 0 |
893 | 919 |
894 if __name__ == "__main__": | 920 if __name__ == "__main__": |
895 logging.basicConfig(level=logging.DEBUG) | 921 logging.basicConfig(level=logging.DEBUG) |
896 sys.exit(main()) | 922 sys.exit(main()) |
OLD | NEW |