diff --git a/buildozer/__init__.py b/buildozer/__init__.py index 5c98f4679..8bda3ed95 100644 --- a/buildozer/__init__.py +++ b/buildozer/__init__.py @@ -76,7 +76,6 @@ def __init__(self, filename='buildozer.spec', target=None): self.specfilename = filename self.state = None self.build_id = None - self.config_profile = '' self.config = SpecParser() self.logger = Logger() @@ -900,6 +899,8 @@ def run_default(self): self.run_command(cmd) def run_command(self, args): + profile = None + while args: if not args[0].startswith('-'): break @@ -913,13 +914,13 @@ def run_command(self, args): exit(0) elif arg in ('-p', '--profile'): - self.config_profile = args.pop(0) + profile = args.pop(0) elif arg == '--version': print('Buildozer {0}'.format(__version__)) exit(0) - self._merge_config_profile() + self.config.apply_profile(profile) self.check_root() @@ -1034,35 +1035,3 @@ def cmd_serve(self, *args): print("Serving via HTTP at port {}".format(SIMPLE_HTTP_SERVER_PORT)) print("Press Ctrl+c to quit serving.") httpd.serve_forever() - - # - # Private - # - - def _merge_config_profile(self): - profile = self.config_profile - if not profile: - return - for section in self.config.sections(): - - # extract the profile part from the section name - # example: [app@default,hd] - parts = section.split('@', 1) - if len(parts) < 2: - continue - - # create a list that contain all the profiles of the current section - # ['default', 'hd'] - section_base, section_profiles = parts - section_profiles = section_profiles.split(',') - if profile not in section_profiles: - continue - - # the current profile is one available in the section - # merge with the general section, or make it one. - if not self.config.has_section(section_base): - self.config.add_section(section_base) - for name, value in self.config.items(section): - print('merged ({}, {}) into {} (profile is {})'.format(name, - value, section_base, profile)) - self.config.set(section_base, name, value) diff --git a/buildozer/default.spec b/buildozer/default.spec index 89ebe6396..68efaaf7a 100644 --- a/buildozer/default.spec +++ b/buildozer/default.spec @@ -415,48 +415,11 @@ warn_on_root = 1 # (str) Path to build output (i.e. .apk, .aab, .ipa) storage # bin_dir = ./bin -# ----------------------------------------------------------------------------- -# List as sections -# -# You can define all the "list" as [section:key]. -# Each line will be considered as a option to the list. -# Let's take [app] / source.exclude_patterns. -# Instead of doing: -# -#[app] -#source.exclude_patterns = license,data/audio/*.wav,data/images/original/* -# -# This can be translated into: -# -#[app:source.exclude_patterns] -#license -#data/audio/*.wav -#data/images/original/* -# - - -# ----------------------------------------------------------------------------- -# Profiles -# -# You can extend section / key with a profile -# For example, you want to deploy a demo version of your application without -# HD content. You could first change the title to add "(demo)" in the name -# and extend the excluded directories to remove the HD content. -# -#[app@demo] -#title = My Application (demo) -# -#[app:source.exclude_patterns@demo] -#images/hd/* -# -# Then, invoke the command line with the "demo" profile: -# -#buildozer --profile demo android debug - -### Notes about using this file: ### +#----------------------------------------------------------------------------- +# Notes about using this file: # # Buildozer uses a variant of Python's ConfigSpec to read this file. -# For the syntax, including interpolations, see +# For the basic syntax, including interpolations, see # https://docs.python.org/3/library/configparser.html#supported-ini-file-structure # # Warning: Comments cannot be used "inline" - i.e. @@ -486,3 +449,20 @@ warn_on_root = 1 # Buildozer supports overriding options through environment variables. # Name an environment variable as SECTION_OPTION to override a value in a .spec # file. +# +# Buildozer support overriding options through profiles. +# For example, you want to deploy a demo version of your application without +# HD content. You could first change the title to add "(demo)" in the name +# and extend the excluded directories to remove the HD content. +# +# [app@demo] +# title = My Application (demo) +# +# [app:source.exclude_patterns@demo] +# images/hd/* +# +# Then, invoke the command line with the "demo" profile: +# +# buildozer --profile demo android debug +# +# Environment variable overrides have priority over profile overrides. diff --git a/buildozer/scripts/remote.py b/buildozer/scripts/remote.py index ffc8bee35..2caf643cf 100644 --- a/buildozer/scripts/remote.py +++ b/buildozer/scripts/remote.py @@ -36,6 +36,8 @@ class BuildozerRemote(Buildozer): def run_command(self, args): + profile = None + while args: if not args[0].startswith('-'): break @@ -45,7 +47,7 @@ def run_command(self, args): self.logger.log_level = 2 elif arg in ('-p', '--profile'): - self.config_profile = args.pop(0) + profile = args.pop(0) elif arg in ('-h', '--help'): self.usage() @@ -55,7 +57,7 @@ def run_command(self, args): print('Buildozer (remote) {0}'.format(__version__)) exit(0) - self._merge_config_profile() + self.config.apply_profile(profile) if len(args) < 2: self.usage() diff --git a/buildozer/specparser.py b/buildozer/specparser.py index 5167ed3cc..3e39033a5 100644 --- a/buildozer/specparser.py +++ b/buildozer/specparser.py @@ -6,6 +6,7 @@ - either comma separated, or in their own [section:option] section. - environment variable overrides of values - overrides applied at construction. + - profiles - case-sensitive keys - "No values" are permitted. """ @@ -13,6 +14,8 @@ from configparser import ConfigParser from os import environ +from buildozer.logger import Logger + class SpecParser(ConfigParser): def __init__(self, *args, **kwargs): @@ -49,8 +52,14 @@ def read_dict(self, dictionary, source=""): # Add new getters def getlist( - self, section, token, default=None, with_values=False, strip=True, - section_sep="=", split_char="," + self, + section, + token, + default=None, + with_values=False, + strip=True, + section_sep="=", + split_char=",", ): """Return a list of strings. @@ -83,8 +92,8 @@ def getlist( return values if not strip else [x.strip() for x in values] def getlistvalues(self, section, token, default=None): - """ Convenience function. - Deprecated - call getlist directly.""" + """Convenience function. + Deprecated - call getlist directly.""" return self.getlist(section, token, default, with_values=True) def getdefault(self, section, token, default=None): @@ -99,7 +108,55 @@ def getbooldefault(self, section, token, default=False): Deprecated - call getboolean directly.""" return self.getboolean(section, token, fallback=default) - # Handle env vars. + def apply_profile(self, profile): + """ + Sections marked with an @ followed by a list of profiles are only + applied if the profile is provided here. + + Implementation Note: A better structure would be for the Profile to be + provided in the constructor, so this could be a private method + automatically applied on read *before* _override_config_from_envs(), + but that will require a significant restructure of Buildozer. + + Instead, this must be called by the client after the read, and the env + var overrides need to be reapplied to the relevant options. + """ + if not profile: + return + for section in self.sections(): + + # extract the profile part from the section name + # example: [app@default,hd] + parts = section.split("@", 1) + if len(parts) < 2: + continue + + # create a list that contain all the profiles of the current section + # ['default', 'hd'] + section_base, section_profiles = parts + section_profiles = section_profiles.split(",") + + # Trim + section_base = section_base.strip() + section_profiles = [profile.strip() for profile in section_profiles] + + if profile not in section_profiles: + continue + + # the current profile is one available in the section + # merge with the general section, or make it one. + if not self.has_section(section_base): + self.add_section(section_base) + for name, value in self.items(section): + Logger().debug( + "merged ({}, {}) into {} (profile is {})".format( + name, value, section_base, profile + ) + ) + self.set(section_base, name, value) + + # Reapply env var, if any. + self._override_config_token_from_env(section_base, name) def _override_config_from_envs(self): """Takes a ConfigParser, and checks every section/token for an diff --git a/tests/test_specparser.py b/tests/test_specparser.py index 553a09f80..0f4dc7f16 100644 --- a/tests/test_specparser.py +++ b/tests/test_specparser.py @@ -46,6 +46,8 @@ def test_overrides(self): sp.read([spec_path]) assert sp.get("section.1", "attribute.1") == "Env Value" + del environ["SECTION_1_ATTRIBUTE_1"] + def test_new_getters(self): sp = SpecParser() sp.read_string( @@ -118,6 +120,55 @@ def test_case_sensitivity(self): assert sp.get("section1", "attribute1") == "a" assert sp.get("section1", "Attribute1") == "A" + def test_profiles(self): + sp = SpecParser() + sp.read_string( + """ + [section1] + attribute1=full system + [section1 @demo1, demo2] + attribute1=demo mode + """ + ) + + # Before a profile is set, return the basic version. + assert sp.get("section1", "attribute1") == "full system" + + # Empty profile makes no difference. + sp.apply_profile(None) + assert sp.get("section1", "attribute1") == "full system" + + # Inapplicable profile makes no difference + sp.apply_profile("doesn't exist") + assert sp.get("section1", "attribute1") == "full system" + + # Applicable profile changes value + sp.apply_profile("demo2") + assert sp.get("section1", "attribute1") == "demo mode" + + def test_profiles_vs_env_var(self): + sp = SpecParser() + + environ["SECTION1_ATTRIBUTE1"] = "simulation mode" + + sp.read_string( + """ + [section1] + attribute1=full system + [section1@demo1,demo2] + attribute1=demo mode + """ + ) + + # Before a profile is set, env var should win. + assert sp.get("section1", "attribute1") == "simulation mode" + + # Applicable profile: env var should still win + sp.apply_profile("demo1") + assert sp.get("section1", "attribute1") == "simulation mode" + + del environ["SECTION1_ATTRIBUTE1"] + def test_controversial_cases(self): """Some aspects of the config syntax seem to cause confusion. This shows what the code is *specified* to do, which might not be