Skip to content

Commit

Permalink
Separate ConfigSpec parser (Address #1636)
Browse files Browse the repository at this point in the history
Separate ConfigParser code from Buildozer.

- Use subclass, rather than monkey-patching.
- Single implementation of list-handling code.
- Convenience functions to minimise impact of existing code (but instantly deprecated).
- Treat defaults consistently
- Apply the env overrides automatically (client doesn't need to trigger).
- Apply the env overrides once (read-time) rather than on every get.
- Avoid re-using term "raw" which has pre-existing definition in ConfigSpec.
   - Update android.py client to match,
- Unit tests.

Add comments to start and end of default.spec to explain the syntax (as discussed in #1636)
  • Loading branch information
Julian-O committed Jul 21, 2023
1 parent 9b50902 commit c17b3ff
Show file tree
Hide file tree
Showing 5 changed files with 351 additions and 112 deletions.
116 changes: 6 additions & 110 deletions buildozer/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@
import codecs
import textwrap
import warnings
from buildozer.jsonstore import JsonStore
from buildozer.logger import Logger
from sys import stdout, stderr, exit
from re import search
from os.path import join, exists, dirname, realpath, splitext, expanduser
Expand All @@ -30,12 +28,16 @@
import pexpect

from urllib.request import FancyURLopener
from configparser import ConfigParser
try:
import fcntl
except ImportError:
# on windows, no fcntl
fcntl = None

from buildozer.jsonstore import JsonStore
from buildozer.logger import Logger
from buildozer.specparser import SpecParser

SIMPLE_HTTP_SERVER_PORT = 8000


Expand Down Expand Up @@ -75,24 +77,14 @@ def __init__(self, filename='buildozer.spec', target=None):
self.state = None
self.build_id = None
self.config_profile = ''
self.config = ConfigParser(allow_no_value=True)
self.config.optionxform = lambda value: value
self.config.getlist = self._get_config_list
self.config.getlistvalues = self._get_config_list_values
self.config.getdefault = self._get_config_default
self.config.getbooldefault = self._get_config_bool
self.config.getrawdefault = self._get_config_raw_default
self.config = SpecParser()

self.logger = Logger()

if exists(filename):
self.config.read(filename, "utf-8")
self.check_configuration_tokens()

# Check all section/tokens for env vars, and replace the
# config value if a suitable env var exists.
set_config_from_envs(self.config)

try:
self.logger.set_level(
int(self.config.getdefault(
Expand Down Expand Up @@ -1074,99 +1066,3 @@ def _merge_config_profile(self):
print('merged ({}, {}) into {} (profile is {})'.format(name,
value, section_base, profile))
self.config.set(section_base, name, value)

def _get_config_list_values(self, *args, **kwargs):
kwargs['with_values'] = True
return self._get_config_list(*args, **kwargs)

def _get_config_list(self, section, token, default=None, with_values=False):
# monkey-patch method for ConfigParser
# get a key as a list of string, separated from the comma

# check if an env var exists that should replace the file config
set_config_token_from_env(section, token, self.config)

# if a section:token is defined, let's use the content as a list.
l_section = '{}:{}'.format(section, token)
if self.config.has_section(l_section):
values = self.config.options(l_section)
if with_values:
return ['{}={}'.format(key, self.config.get(l_section, key)) for
key in values]
else:
return [x.strip() for x in values]

values = self.config.getdefault(section, token, '')
if not values:
return default
values = values.split(',')
if not values:
return default
return [x.strip() for x in values]

def _get_config_default(self, section, token, default=None):
# monkey-patch method for ConfigParser
# get an appropriate env var if it exists, else
# get a key in a section, or the default

# check if an env var exists that should replace the file config
set_config_token_from_env(section, token, self.config)

if not self.config.has_section(section):
return default
if not self.config.has_option(section, token):
return default
return self.config.get(section, token)

def _get_config_bool(self, section, token, default=False):
# monkey-patch method for ConfigParser
# get a key in a section, or the default

# check if an env var exists that should replace the file config
set_config_token_from_env(section, token, self.config)

if not self.config.has_section(section):
return default
if not self.config.has_option(section, token):
return default
return self.config.getboolean(section, token)

def _get_config_raw_default(self, section, token, default=None, section_sep="=", split_char=" "):
l_section = '{}:{}'.format(section, token)
if self.config.has_section(l_section):
return [section_sep.join(item) for item in self.config.items(l_section)]
if not self.config.has_option(section, token):
return default.split(split_char)
return self.config.get(section, token).split(split_char)


def set_config_from_envs(config):
'''Takes a ConfigParser, and checks every section/token for an
environment variable of the form SECTION_TOKEN, with any dots
replaced by underscores. If the variable exists, sets the config
variable to the env value.
'''
for section in config.sections():
for token in config.options(section):
set_config_token_from_env(section, token, config)


def set_config_token_from_env(section, token, config):
'''Given a config section and token, checks for an appropriate
environment variable. If the variable exists, sets the config entry to
its value.
The environment variable checked is of the form SECTION_TOKEN, all
upper case, with any dots replaced by underscores.
Returns True if the environment variable exists and was used, or
False otherwise.
'''
env_var_name = ''.join([section.upper(), '_',
token.upper().replace('.', '_')])
env_var = os.environ.get(env_var_name)
if env_var is None:
return False
config.set(section, token, env_var)
return True
39 changes: 39 additions & 0 deletions buildozer/default.spec
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
# This .spec config file tells Buildozer an app's requirements for being built.
#
# It largely follows the syntax of an .ini file.
# See the end of the file for more details and warnings about common mistakes.

[app]

# (str) Title of your application
Expand Down Expand Up @@ -447,3 +452,37 @@ warn_on_root = 1
# Then, invoke the command line with the "demo" profile:
#
#buildozer --profile demo android debug

### Notes about using this file: ###
#
# Buildozer uses a variant of Python's ConfigSpec to read this file.
# For the syntax, including interpolations, see
# https://docs.python.org/3/library/configparser.html#supported-ini-file-structure
#
# Warning: Comments cannot be used "inline" - i.e.
# [app]
# title = My Application # This is not a comment, it is part of the title.
#
# Warning: Indented text is treated as a multiline string - i.e.
# [app]
# title = My Application
# package.name = myapp # This is all part of the title.
#
# Buildozer's .spec files have some additional features:
#
# Buildozer supports lists - i.e.
# [app]
# source.include_exts = py,png,jpg
# # ^ This is a list.
#
# [app:source.include_exts]
# py
# png
# jpg
# # ^ This is an alternative syntax for a list.
#
# Buildozer's option names are case-sensitive, unlike most .ini files.
#
# Buildozer supports overriding options through environment variables.
# Name an environment variable as SECTION_OPTION to override a value in a .spec
# file.
128 changes: 128 additions & 0 deletions buildozer/specparser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"""
A customised ConfigParser, suitable for buildozer.spec.
Supports
- list values
- either comma separated, or in their own [section:option] section.
- environment variable overrides of values
- overrides applied at construction.
- case-sensitive keys
- "No values" are permitted.
"""

from configparser import ConfigParser
from os import environ


class SpecParser(ConfigParser):
def __init__(self, *args, **kwargs):
# Allow "no value" options to better support lists.
super().__init__(*args, allow_no_value=True, **kwargs)

def optionxform(self, optionstr: str) -> str:
"""Override method that canonicalizes keys to retain
case sensitivity."""
return optionstr

# Override all the readers to apply env variables over the top.

def read(self, filenames, encoding=None):
super().read(filenames, encoding)
# Let environment variables override the values
self._override_config_from_envs()

def read_file(self, f, source=None):
super().read_file(f, source)
# Let environment variables override the values
self._override_config_from_envs()

def read_string(self, string, source="<string>"):
super().read_string(string, source)
# Let environment variables override the values
self._override_config_from_envs()

def read_dict(self, dictionary, source="<dict>"):
super().read_dict(dictionary, source)
# Let environment variables override the values
self._override_config_from_envs()

# Add new getters

def getlist(
self, section, token, default=None, with_values=False, strip=True,
section_sep="=", split_char=","
):
"""Return a list of strings.
They can be found as the list of options in a [section:token] section,
or in a [section], under the a option, as a comma-separated (or
split_char-separated) list,
Failing that, default is returned (as is).
If with_values is set, and they are in a [section:token] section,
the option values are included with the option key,
separated by section_sep
"""

# if a section:token is defined, let's use the content as a list.
l_section = "{}:{}".format(section, token)
if self.has_section(l_section):
values = self.options(l_section)
if with_values:
return [
"{}{}{}".format(key, section_sep, self.get(l_section, key))
for key in values
]
return values if not strip else [x.strip() for x in values]
values = self.getdefault(section, token, None)
if values is None:
return default
values = values.split(split_char)
if not values:
return default
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."""
return self.getlist(section, token, default, with_values=True)

def getdefault(self, section, token, default=None):
"""
Convenience function.
Deprecated - call get directly."""
return self.get(section, token, fallback=default)

def getbooldefault(self, section, token, default=False):
"""
Convenience function.
Deprecated - call getboolean directly."""
return self.getboolean(section, token, fallback=default)

# Handle env vars.

def _override_config_from_envs(self):
"""Takes a ConfigParser, and checks every section/token for an
environment variable of the form SECTION_TOKEN, with any dots
replaced by underscores. If the variable exists, sets the config
variable to the env value.
"""
for section in self.sections():
for token in self.options(section):
self._override_config_token_from_env(section, token)

def _override_config_token_from_env(self, section, token):
"""Given a config section and token, checks for an appropriate
environment variable. If the variable exists, sets the config entry to
its value.
The environment variable checked is of the form SECTION_TOKEN, all
upper case, with any dots replaced by underscores.
"""
env_var_name = "_".join(
item.upper().replace(".", "_") for item in (section, token)
)
env_var = environ.get(env_var_name)
if env_var is not None:
self.set(section, token, env_var)
8 changes: 6 additions & 2 deletions buildozer/targets/android.py
Original file line number Diff line number Diff line change
Expand Up @@ -1432,8 +1432,12 @@ def cmd_logcat(self, *args):
serial = self.serials[0:]
if not serial:
return
filters = self.buildozer.config.getrawdefault(
"app", "android.logcat_filters", "", section_sep=":", split_char=" ")
filters = self.buildozer.config.getlist(
"app",
"android.logcat_filters",
default="",
section_sep=":",
strip=False)
filters = " ".join(filters)
self.buildozer.environ['ANDROID_SERIAL'] = serial[0]
extra_args = []
Expand Down
Loading

0 comments on commit c17b3ff

Please sign in to comment.