Skip to content

Commit 733278f

Browse files
committed
Merge branch 'concurrent-config' into main - closes #155
2 parents b676a9c + 33e7ecc commit 733278f

File tree

1 file changed

+91
-68
lines changed

1 file changed

+91
-68
lines changed

emailproxy.py

+91-68
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
__author__ = 'Simon Robinson'
77
__copyright__ = 'Copyright (c) 2023 Simon Robinson'
88
__license__ = 'Apache 2.0'
9-
__version__ = '2023-09-03' # ISO 8601 (YYYY-MM-DD)
9+
__version__ = '2023-09-06' # ISO 8601 (YYYY-MM-DD)
1010

1111
import abc
1212
import argparse
@@ -405,15 +405,59 @@ def save(store_id, config_dict, create_secret=True):
405405
Log.error('Unable to get AWS SDK client; cannot cache credentials to AWS Secrets Manager')
406406

407407

408+
class ConcurrentConfigParser:
409+
"""Helper wrapper to add locking to a ConfigParser object (note: only wraps the methods used in this script)"""
410+
411+
def __init__(self):
412+
self.config = configparser.ConfigParser()
413+
self.lock = threading.Lock()
414+
415+
def read(self, filename):
416+
with self.lock:
417+
self.config.read(filename)
418+
419+
def sections(self):
420+
with self.lock:
421+
return self.config.sections()
422+
423+
def add_section(self, section):
424+
with self.lock:
425+
self.config.add_section(section)
426+
427+
def get(self, section, option, fallback=None):
428+
with self.lock:
429+
return self.config.get(section, option, fallback=fallback)
430+
431+
def getint(self, section, option, fallback=None):
432+
with self.lock:
433+
return self.config.getint(section, option, fallback=fallback)
434+
435+
def getboolean(self, section, option, fallback=None):
436+
with self.lock:
437+
return self.config.getboolean(section, option, fallback=fallback)
438+
439+
def set(self, section, option, value):
440+
with self.lock:
441+
self.config.set(section, option, value)
442+
443+
def remove_option(self, section, option):
444+
with self.lock:
445+
self.config.remove_option(section, option)
446+
447+
def write(self, file):
448+
with self.lock:
449+
self.config.write(file)
450+
451+
def items(self):
452+
with self.lock:
453+
return self.config.items() # used in read_dict when saving to cache store
454+
455+
408456
class AppConfig:
409457
"""Helper wrapper around ConfigParser to cache servers/accounts, and avoid writing to the file until necessary"""
410458

411459
_PARSER = None
412-
_LOADED = False
413-
414-
_GLOBALS = None
415-
_SERVERS = []
416-
_ACCOUNTS = []
460+
_PARSER_LOCK = threading.Lock()
417461

418462
# note: removing the unencrypted version of `client_secret_encrypted` is not automatic with --cache-store (see docs)
419463
_CACHED_OPTION_KEYS = ['token_salt', 'access_token', 'access_token_expiry', 'refresh_token', 'last_activity',
@@ -424,38 +468,26 @@ class AppConfig:
424468

425469
@staticmethod
426470
def _load():
427-
AppConfig.unload()
428-
AppConfig._PARSER = configparser.ConfigParser()
429-
AppConfig._PARSER.read(CONFIG_FILE_PATH)
430-
431-
config_sections = AppConfig._PARSER.sections()
432-
if APP_SHORT_NAME in config_sections:
433-
AppConfig._GLOBALS = AppConfig._PARSER[APP_SHORT_NAME]
434-
else:
435-
AppConfig._GLOBALS = configparser.SectionProxy(AppConfig._PARSER, APP_SHORT_NAME)
471+
config_parser = ConcurrentConfigParser()
472+
config_parser.read(CONFIG_FILE_PATH)
436473

437474
# cached account credentials can be stored in the configuration file (default) or, via `--cache-store`, a
438475
# separate local file or external service (such as a secrets manager) - we combine these sources at load time
439476
if CACHE_STORE != CONFIG_FILE_PATH:
440477
# it would be cleaner to avoid specific options here, but best to load unexpected sections only when enabled
441-
allow_catch_all_accounts = AppConfig._GLOBALS.getboolean('allow_catch_all_accounts', fallback=False)
478+
allow_catch_all_accounts = config_parser.getboolean(APP_SHORT_NAME, 'allow_catch_all_accounts',
479+
fallback=False)
442480

443481
cache_file_parser = AppConfig._load_cache(CACHE_STORE)
444482
cache_file_accounts = [s for s in cache_file_parser.sections() if '@' in s]
445483
for account in cache_file_accounts:
446-
if allow_catch_all_accounts and account not in AppConfig._PARSER.sections(): # missing sub-accounts
447-
AppConfig._PARSER.add_section(account)
484+
if allow_catch_all_accounts and account not in config_parser.sections(): # missing sub-accounts
485+
config_parser.add_section(account)
448486
for option in cache_file_parser.options(account):
449487
if option in AppConfig._CACHED_OPTION_KEYS:
450-
AppConfig._PARSER.set(account, option, cache_file_parser.get(account, option))
451-
452-
if allow_catch_all_accounts:
453-
config_sections = AppConfig._PARSER.sections() # new sections may have been added
488+
config_parser.set(account, option, cache_file_parser.get(account, option))
454489

455-
AppConfig._SERVERS = [s for s in config_sections if CONFIG_SERVER_MATCHER.match(s)]
456-
AppConfig._ACCOUNTS = [s for s in config_sections if '@' in s]
457-
458-
AppConfig._LOADED = True
490+
return config_parser
459491

460492
@staticmethod
461493
def _load_cache(cache_store_identifier):
@@ -469,59 +501,47 @@ def _load_cache(cache_store_identifier):
469501

470502
@staticmethod
471503
def get():
472-
if not AppConfig._LOADED:
473-
AppConfig._load()
474-
return AppConfig._PARSER
504+
with AppConfig._PARSER_LOCK:
505+
if AppConfig._PARSER is None:
506+
AppConfig._PARSER = AppConfig._load()
507+
return AppConfig._PARSER
475508

476509
@staticmethod
477510
def unload():
478-
AppConfig._PARSER = None
479-
AppConfig._LOADED = False
480-
481-
AppConfig._GLOBALS = None
482-
AppConfig._SERVERS = []
483-
AppConfig._ACCOUNTS = []
484-
485-
@staticmethod
486-
def reload():
487-
AppConfig.unload()
488-
return AppConfig.get()
511+
with AppConfig._PARSER_LOCK:
512+
AppConfig._PARSER = None
489513

490514
@staticmethod
491-
def globals():
492-
AppConfig.get() # make sure config is loaded
493-
return AppConfig._GLOBALS
515+
def get_global(name, fallback):
516+
return AppConfig.get().getboolean(APP_SHORT_NAME, name, fallback)
494517

495518
@staticmethod
496519
def servers():
497-
AppConfig.get() # make sure config is loaded
498-
return AppConfig._SERVERS
520+
return [s for s in AppConfig.get().sections() if CONFIG_SERVER_MATCHER.match(s)]
499521

500522
@staticmethod
501523
def accounts():
502-
AppConfig.get() # make sure config is loaded
503-
return AppConfig._ACCOUNTS
504-
505-
@staticmethod
506-
def add_account(username):
507-
AppConfig._PARSER.add_section(username)
508-
AppConfig._ACCOUNTS = [s for s in AppConfig._PARSER.sections() if '@' in s]
524+
return [s for s in AppConfig.get().sections() if '@' in s]
509525

510526
@staticmethod
511527
def save():
512-
if AppConfig._LOADED:
528+
with AppConfig._PARSER_LOCK:
529+
if AppConfig._PARSER is None: # intentionally using _PARSER not get() so we don't (re-)load if unloaded
530+
return
531+
513532
if CACHE_STORE != CONFIG_FILE_PATH:
514533
# in `--cache-store` mode we ignore everything except _CACHED_OPTION_KEYS (OAuth 2.0 tokens, etc)
515534
output_config_parser = configparser.ConfigParser()
516535
output_config_parser.read_dict(AppConfig._PARSER) # a deep copy of the current configuration
536+
config_accounts = [s for s in output_config_parser.sections() if '@' in s]
517537

518-
for account in AppConfig._ACCOUNTS:
538+
for account in config_accounts:
519539
for option in output_config_parser.options(account):
520540
if option not in AppConfig._CACHED_OPTION_KEYS:
521541
output_config_parser.remove_option(account, option)
522542

523543
for section in output_config_parser.sections():
524-
if section not in AppConfig._ACCOUNTS or len(output_config_parser.options(section)) <= 0:
544+
if section not in config_accounts or len(output_config_parser.options(section)) <= 0:
525545
output_config_parser.remove_section(section)
526546

527547
AppConfig._save_cache(CACHE_STORE, output_config_parser)
@@ -557,10 +577,11 @@ def get_oauth2_credentials(username, password, recurse_retries=True):
557577
if invalid). Returns either (True, '[OAuth2 string for authentication]') or (False, '[Error message]')"""
558578

559579
# we support broader catch-all account names (e.g., `@domain.com` / `@`) if enabled
560-
valid_accounts = [username in AppConfig.accounts()]
561-
if AppConfig.globals().getboolean('allow_catch_all_accounts', fallback=False):
580+
config_accounts = AppConfig.accounts()
581+
valid_accounts = [username in config_accounts]
582+
if AppConfig.get_global('allow_catch_all_accounts', fallback=False):
562583
user_domain = '@%s' % username.split('@')[-1]
563-
valid_accounts.extend([account in AppConfig.accounts() for account in [user_domain, '@']])
584+
valid_accounts.extend([account in config_accounts for account in [user_domain, '@']])
564585

565586
if not any(valid_accounts):
566587
Log.error('Proxy config file entry missing for account', username, '- aborting login')
@@ -572,7 +593,7 @@ def get_oauth2_credentials(username, password, recurse_retries=True):
572593

573594
def get_account_with_catch_all_fallback(option):
574595
fallback = None
575-
if AppConfig.globals().getboolean('allow_catch_all_accounts', fallback=False):
596+
if AppConfig.get_global('allow_catch_all_accounts', fallback=False):
576597
fallback = config.get(user_domain, option, fallback=config.get('@', option, fallback=None))
577598
return config.get(username, option, fallback=fallback)
578599

@@ -614,7 +635,7 @@ def get_account_with_catch_all_fallback(option):
614635

615636
# try reloading remotely cached tokens if possible
616637
if not access_token and CACHE_STORE != CONFIG_FILE_PATH and recurse_retries:
617-
AppConfig.reload()
638+
AppConfig.unload()
618639
return OAuth2Helper.get_oauth2_credentials(username, password, recurse_retries=False)
619640

620641
# we hash locally-stored tokens with the given password
@@ -682,8 +703,8 @@ def get_account_with_catch_all_fallback(option):
682703
oauth2_flow, username, password)
683704

684705
access_token = response['access_token']
685-
if not config.has_section(username):
686-
AppConfig.add_account(username) # in wildcard mode the section may not yet exist
706+
if username not in config.sections():
707+
config.add_section(username) # in catch-all mode the section may not yet exist
687708
REQUEST_QUEUE.put(MENU_UPDATE) # make sure the menu shows the newly-added account
688709
config.set(username, 'token_salt', token_salt)
689710
config.set(username, 'access_token', OAuth2Helper.encrypt(fernet, access_token))
@@ -695,7 +716,7 @@ def get_account_with_catch_all_fallback(option):
695716
Log.info('Warning: no refresh token returned for', username, '- you will need to re-authenticate',
696717
'each time the access token expires (does your `oauth2_scope` value allow `offline` use?)')
697718

698-
if AppConfig.globals().getboolean('encrypt_client_secret_on_first_use', fallback=False):
719+
if AppConfig.get_global('encrypt_client_secret_on_first_use', fallback=False):
699720
if client_secret:
700721
# note: save to the `username` entry even if `user_domain` exists, avoiding conflicts when using
701722
# incompatible `encrypt_client_secret_on_first_use` and `allow_catch_all_accounts` options
@@ -712,8 +733,8 @@ def get_account_with_catch_all_fallback(option):
712733
except InvalidToken as e:
713734
# if invalid details are the reason for failure we remove our cached version and re-authenticate - this can
714735
# be disabled by a configuration setting, but note that we always remove credentials on 400 Bad Request
715-
if e.args == (400, APP_PACKAGE) or AppConfig.globals().getboolean('delete_account_token_on_password_error',
716-
fallback=True):
736+
if e.args == (400, APP_PACKAGE) or AppConfig.get_global('delete_account_token_on_password_error',
737+
fallback=True):
717738
config.remove_option(username, 'token_salt')
718739
config.remove_option(username, 'access_token')
719740
config.remove_option(username, 'access_token_expiry')
@@ -2290,11 +2311,11 @@ def macos_nsworkspace_notification_listener_(self, notification):
22902311
Log.info('Received power off notification; exiting', APP_NAME)
22912312
self.exit(self.icon)
22922313

2314+
# noinspection PyDeprecation
22932315
def create_icon(self):
22942316
# temporary fix for pystray <= 0.19.4 incompatibility with PIL 10.0.0+; fixed once pystray PR #147 is released
22952317
with warnings.catch_warnings():
22962318
warnings.simplefilter('ignore', DeprecationWarning)
2297-
# noinspection PyDeprecation
22982319
pystray_version = pkg_resources.get_distribution('pystray').version
22992320
pillow_version = pkg_resources.get_distribution('pillow').version
23002321
if pkg_resources.parse_version(pystray_version) <= pkg_resources.parse_version('0.19.4') and \
@@ -2382,7 +2403,7 @@ def create_config_menu(self):
23822403
if len(config_accounts) <= 0:
23832404
items.append(pystray.MenuItem(' No accounts configured', None, enabled=False))
23842405
else:
2385-
catch_all_enabled = AppConfig.globals().getboolean('allow_catch_all_accounts', fallback=False)
2406+
catch_all_enabled = AppConfig.get_global('allow_catch_all_accounts', fallback=False)
23862407
catch_all_accounts = []
23872408
for account in config_accounts:
23882409
if account.startswith('@') and catch_all_enabled:
@@ -2772,7 +2793,9 @@ def load_and_start_servers(self, icon=None, reload=True):
27722793
# we allow reloading, so must first stop any existing servers
27732794
self.stop_servers()
27742795
Log.info('Initialising', APP_NAME, '(version %s)' % __version__, 'from config file', CONFIG_FILE_PATH)
2775-
config = AppConfig.reload() if reload else AppConfig.get()
2796+
if reload:
2797+
AppConfig.unload()
2798+
config = AppConfig.get()
27762799

27772800
# load server types and configurations
27782801
server_load_error = False

0 commit comments

Comments
 (0)