From 992e9ae3e5567c772bd1597f395fccb18512177d Mon Sep 17 00:00:00 2001 From: Jonas Thiem Date: Wed, 1 May 2019 00:37:29 +0200 Subject: [PATCH] New experimental argparse wrapper to have all options on all parsers --- pythonforandroid/mergedargsparser.py | 66 ++++++++++++++++++++++++++++ pythonforandroid/toolchain.py | 52 +++++++++++++++------- 2 files changed, 101 insertions(+), 17 deletions(-) create mode 100644 pythonforandroid/mergedargsparser.py diff --git a/pythonforandroid/mergedargsparser.py b/pythonforandroid/mergedargsparser.py new file mode 100644 index 0000000000..2d20b603b1 --- /dev/null +++ b/pythonforandroid/mergedargsparser.py @@ -0,0 +1,66 @@ +""" +This provides a wrapper that allows multiple different ArgumentParser's +parse_args() function to return an argument object that will have an +attribute for ANY argument provided through ANY of all the parsers with +a value of None. + +With this, we can avoid missing attribute errors if a code path was +called through a different initial toolchain command. These could also +be avoided through tons of hasattr()/getattr() calls, but that tends +to become unwieldy. +""" + + +class MergedArgsOverlay(object): + def __init__(self, original_args, all_args_names_set): + self._regular_args = original_args + self._all_possible_args = all_args_names_set + + @property + def __dict__(self): + # Needed because argparse may for subparsers call our overridden + # parse_known_arguments() internally, then try to use vars() + # on this and return a new repackaged Namespace() based on it. + # + # In this case we just want to return the original objects args, + # since the "outside" parse_args/parse_known_args call will re-wrap + # this anyway, and otherwise we'd just confuse argparse internals + # with our additional None'd arguments. + return dict(vars(self._regular_args)) + + def __getattr__(self, name): + if not name.startswith("__") and hasattr(self._regular_args, name): + return getattr(self._regular_args, name) + if name in self._all_possible_args: + return None + raise AttributeError("no such attribute {}".format(name)) + + +def wrap_as_parser_with_merged_args(args_store_set, parser): + original_parse_args = parser.parse_args + original_parse_known_args = parser.parse_known_args + original_add_argument = parser.add_argument + + def parse_args_override(*args): + args_obj = original_parse_args(args) + while isinstance(args_obj, MergedArgsOverlay): # handle nested wraps + args_obj = MergedArgsOverlay._regular_args + return MergedArgsOverlay(args_obj, args_store_set) + + def parse_known_args_override(*args): + args_obj, args_unknown = original_parse_known_args(*args) + while isinstance(args_obj, MergedArgsOverlay): # handle nested wraps + args_obj = MergedArgsOverlay._regular_args + return (MergedArgsOverlay(args_obj, args_store_set), args_unknown) + + def add_argument_override(*args, **kwargs): + for arg in args: + while arg.startswith("-"): + arg = arg[1:] + args_store_set.add(arg.replace("-", "_")) + return original_add_argument(*args, **kwargs) + + parser.parse_args = parse_args_override + parser.parse_known_args = parse_known_args_override + parser.add_argument = add_argument_override + return parser diff --git a/pythonforandroid/toolchain.py b/pythonforandroid/toolchain.py index 232e2499cc..a350990105 100644 --- a/pythonforandroid/toolchain.py +++ b/pythonforandroid/toolchain.py @@ -9,6 +9,7 @@ from __future__ import print_function from os import environ from pythonforandroid import __version__ +from pythonforandroid.mergedargsparser import wrap_as_parser_with_merged_args from pythonforandroid.pythonpackage import get_dep_names_of_package from pythonforandroid.recommendations import ( RECOMMENDED_NDK_API, RECOMMENDED_TARGET_API) @@ -246,13 +247,28 @@ def __init__(self): argv.append(argv.pop(1)) # the --color arg argv.append(argv.pop(1)) # the --storage-dir arg - parser = NoAbbrevParser( - description='A packaging tool for turning Python scripts and apps ' - 'into Android APKs') + # This set keeps all args ever seen, so the parse_args() function of + # each parser can return 'None' even for arguments only truly provided + # by other command's parsers, making our life easier. + # (Otherwise we'd need to use hasattr()/getattr() a lot since many + # code paths are reachable with multiple different command parsers) + merged_args_store = set() + + parser = wrap_as_parser_with_merged_args( + merged_args_store, + NoAbbrevParser(description=( + 'A packaging tool for turning Python scripts and apps ' + 'into Android APKs' + )) + ) - generic_parser = argparse.ArgumentParser( - add_help=False, - description='Generic arguments applied to all commands') + generic_parser = wrap_as_parser_with_merged_args( + merged_args_store, + argparse.ArgumentParser( + add_help=False, + description='Generic arguments applied to all commands' + ) + ) argparse.ArgumentParser( add_help=False, description='Arguments for dist building') @@ -390,7 +406,9 @@ def add_parser(subparsers, *args, **kwargs): """ if 'aliases' in kwargs and sys.version_info.major < 3: kwargs.pop('aliases') - return subparsers.add_parser(*args, **kwargs) + return wrap_as_parser_with_merged_args( + merged_args_store, subparsers.add_parser(*args, **kwargs) + ) add_parser( subparsers, @@ -568,10 +586,10 @@ def add_parser(subparsers, *args, **kwargs): args, unknown = parser.parse_known_args(sys.argv[1:]) args.unknown_args = unknown - if hasattr(args, "private") and args.private is not None: + if args.private is not None: # Pass this value on to the internal bootstrap build.py: args.unknown_args += ["--private", args.private] - if hasattr(args, "ignore_setup_py") and args.ignore_setup_py: + if args.ignore_setup_py: args.use_setup_py = False self.args = args @@ -586,24 +604,24 @@ def add_parser(subparsers, *args, **kwargs): logger.setLevel(logging.DEBUG) self.ctx = Context() - self.ctx.use_setup_py = getattr(args, "use_setup_py", True) + self.ctx.use_setup_py = ( + args.use_setup_py if args.use_setup_py is not None else True + ) have_setup_py_or_similar = False - if getattr(args, "private", None) is not None: - project_dir = getattr(args, "private") - if (os.path.exists(os.path.join(project_dir, "setup.py")) or - os.path.exists(os.path.join(project_dir, + if args.private is not None: + if (os.path.exists(os.path.join(args.private, "setup.py")) or + os.path.exists(os.path.join(args.private, "pyproject.toml"))): have_setup_py_or_similar = True # Process requirements and put version in environ - if hasattr(args, 'requirements'): + if args.requirements is not None: requirements = [] # Add dependencies from setup.py, but only if they are recipes # (because otherwise, setup.py itself will install them later) - if (have_setup_py_or_similar and - getattr(args, "use_setup_py", False)): + if have_setup_py_or_similar and args.use_setup_py is True: try: info("Analyzing package dependencies. MAY TAKE A WHILE.") # Get all the dependencies corresponding to a recipe: