# Copyright (c) 2014-present PlatformIO # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import configparser import glob import json import os import re import click from platformio import fs from platformio.compat import MISSING, string_types from platformio.project import exception from platformio.project.options import ProjectOptions CONFIG_HEADER = """ ; PlatformIO Project Configuration File ; ; Build options: build flags, source filter ; Upload options: custom upload port, speed and extra flags ; Library options: dependencies, extra library storages ; Advanced options: extra scripting ; ; Please visit documentation for the other options and examples ; https://docs.platformio.org/page/projectconf.html """ class ProjectConfigBase: ENVNAME_RE = re.compile(r"^[a-z\d\_\-]+$", flags=re.I) INLINE_COMMENT_RE = re.compile(r"\s+;.*$") VARTPL_RE = re.compile(r"\$\{([^\.\}\()]+)\.([^\}]+)\}") CUSTOM_OPTION_PREFIXES = ("custom_", "board_") expand_interpolations = True warnings = [] _parser = None _parsed = [] @staticmethod def parse_multi_values(items): result = [] if not items: return result if not isinstance(items, (list, tuple)): items = items.split("\n" if "\n" in items else ", ") for item in items: item = item.strip() # comment if not item or item.startswith((";", "#")): continue if ";" in item: item = ProjectConfigBase.INLINE_COMMENT_RE.sub("", item).strip() result.append(item) return result @staticmethod def get_default_path(): from platformio import app # pylint: disable=import-outside-toplevel return app.get_session_var("custom_project_conf") or os.path.join( os.getcwd(), "platformio.ini" ) def __init__(self, path=None, parse_extra=True, expand_interpolations=True): path = self.get_default_path() if path is None else path self.path = path self.expand_interpolations = expand_interpolations self.warnings = [] self._parsed = [] self._parser = configparser.ConfigParser(inline_comment_prefixes=("#", ";")) if path and os.path.isfile(path): self.read(path, parse_extra) self._maintain_renaimed_options() def __getattr__(self, name): return getattr(self._parser, name) def read(self, path, parse_extra=True): if path in self._parsed: return self._parsed.append(path) try: self._parser.read(path, "utf-8") except configparser.Error as exc: raise exception.InvalidProjectConfError(path, str(exc)) if not parse_extra: return # load extra configs for pattern in self.get("platformio", "extra_configs", []): if pattern.startswith("~"): pattern = fs.expanduser(pattern) for item in glob.glob(pattern, recursive=True): self.read(item) def _maintain_renaimed_options(self): renamed_options = {} for option in ProjectOptions.values(): if option.oldnames: renamed_options.update({name: option.name for name in option.oldnames}) for section in self._parser.sections(): scope = self.get_section_scope(section) if scope not in ("platformio", "env"): continue for option in self._parser.options(section): if option in renamed_options: self.warnings.append( "`%s` configuration option in section [%s] is " "deprecated and will be removed in the next release! " "Please use `%s` instead" % (option, section, renamed_options[option]) ) # # rename on-the-fly # self._parser.set( # section, # renamed_options[option], # self._parser.get(section, option), # ) # self._parser.remove_option(section, option) continue # unknown unknown_conditions = [ ("%s.%s" % (scope, option)) not in ProjectOptions, scope != "env" or not option.startswith(self.CUSTOM_OPTION_PREFIXES), ] if all(unknown_conditions): self.warnings.append( "Ignore unknown configuration option `%s` " "in section [%s]" % (option, section) ) return True @staticmethod def get_section_scope(section): return section.split(":", 1)[0] if ":" in section else section def walk_options(self, root_section): extends_queue = ( ["env", root_section] if root_section.startswith("env:") else [root_section] ) extends_done = [] while extends_queue: section = extends_queue.pop() extends_done.append(section) if not self._parser.has_section(section): continue for option in self._parser.options(section): yield (section, option) if self._parser.has_option(section, "extends"): extends_queue.extend( self.parse_multi_values(self._parser.get(section, "extends")) ) def options(self, section=None, env=None): result = [] assert section or env if not section: section = "env:" + env if not self.expand_interpolations: return self._parser.options(section) for _, option in self.walk_options(section): if option not in result: result.append(option) # handle system environment variables scope = self.get_section_scope(section) for option_meta in ProjectOptions.values(): if option_meta.scope != scope or option_meta.name in result: continue if option_meta.sysenvvar and option_meta.sysenvvar in os.environ: result.append(option_meta.name) return result def has_option(self, section, option): if self._parser.has_option(section, option): return True return option in self.options(section) def items(self, section=None, env=None, as_dict=False): assert section or env if not section: section = "env:" + env if as_dict: return { option: self.get(section, option) for option in self.options(section) } return [(option, self.get(section, option)) for option in self.options(section)] def set(self, section, option, value): if value is None: value = "" if isinstance(value, (list, tuple)): value = "\n".join(value) elif isinstance(value, bool): value = "yes" if value else "no" elif isinstance(value, (int, float)): value = str(value) # start multi-line value from a new line if "\n" in value and not value.startswith("\n"): value = "\n" + value self._parser.set(section, option, value) def resolve_renamed_option(self, section, old_name): scope = self.get_section_scope(section) if scope not in ("platformio", "env"): return None for option_meta in ProjectOptions.values(): if ( option_meta.oldnames and option_meta.scope == scope and old_name in option_meta.oldnames ): return option_meta.name return None def find_option_meta(self, section, option): scope = self.get_section_scope(section) if scope not in ("platformio", "env"): return None option_meta = ProjectOptions.get("%s.%s" % (scope, option)) if option_meta: return option_meta for option_meta in ProjectOptions.values(): if option_meta.scope == scope and option in (option_meta.oldnames or []): return option_meta return None def _traverse_for_value(self, section, option, option_meta=None): for _section, _option in self.walk_options(section): if _option == option or ( option_meta and ( option_meta.name == _option or _option in (option_meta.oldnames or []) ) ): return self._parser.get(_section, _option) return MISSING def getraw( self, section, option, default=MISSING ): # pylint: disable=too-many-branches if not self.expand_interpolations: return self._parser.get(section, option) option_meta = self.find_option_meta(section, option) value = self._traverse_for_value(section, option, option_meta) if not option_meta: if value == MISSING: value = ( default if default != MISSING else self._parser.get(section, option) ) return self._expand_interpolations(section, value) if option_meta.sysenvvar: envvar_value = os.getenv(option_meta.sysenvvar) if not envvar_value and option_meta.oldnames: for oldoption in option_meta.oldnames: envvar_value = os.getenv("PLATFORMIO_" + oldoption.upper()) if envvar_value: break if envvar_value and option_meta.multiple: if value == MISSING: value = "" value += ("\n" if value else "") + envvar_value elif envvar_value: value = envvar_value if value == MISSING: value = default if default != MISSING else option_meta.default if callable(value): value = value() if value == MISSING: return None return self._expand_interpolations(section, value) def _expand_interpolations(self, parent_section, value): if ( not value or not isinstance(value, string_types) or not all(["${" in value, "}" in value]) ): return value return self.VARTPL_RE.sub( lambda match: self._re_interpolation_handler(parent_section, match), value ) def _re_interpolation_handler(self, parent_section, match): section, option = match.group(1), match.group(2) # handle system environment variables if section == "sysenv": return os.getenv(option) # handle ${this.*} if section == "this": section = parent_section if option == "__env__": if not parent_section.startswith("env:"): raise exception.ProjectOptionValueError( f"`${{this.__env__}}` is called from the `{parent_section}` " "section that is not valid PlatformIO environment, see", option, section, ) return parent_section[4:] # handle nested calls try: value = self.get(section, option) except RecursionError as exc: raise exception.ProjectOptionValueError( "Infinite recursion has been detected", option, section ) from exc if isinstance(value, list): return "\n".join(value) return str(value) def get(self, section, option, default=MISSING): value = None try: value = self.getraw(section, option, default) except configparser.Error as exc: raise exception.InvalidProjectConfError(self.path, str(exc)) option_meta = self.find_option_meta(section, option) if not option_meta: return value if option_meta.validate: value = option_meta.validate(value) if option_meta.multiple: value = self.parse_multi_values(value or []) try: return self.cast_to(value, option_meta.type) except click.BadParameter as exc: if not self.expand_interpolations: return value raise exception.ProjectOptionValueError( exc.format_message(), option, section ) @staticmethod def cast_to(value, to_type): items = value if not isinstance(value, (list, tuple)): items = [value] items = [ to_type(item) if isinstance(to_type, click.ParamType) else item for item in items ] return items if isinstance(value, (list, tuple)) else items[0] def envs(self): return [s[4:] for s in self._parser.sections() if s.startswith("env:")] def default_envs(self): return self.get("platformio", "default_envs", []) def get_default_env(self): default_envs = self.default_envs() if default_envs: return default_envs[0] envs = self.envs() return envs[0] if envs else None def validate(self, envs=None, silent=False): if not os.path.isfile(self.path): raise exception.NotPlatformIOProjectError(os.path.dirname(self.path)) known_envs = set(self.envs()) # check envs if not known_envs: raise exception.ProjectEnvsNotAvailableError() unknown_envs = set(list(envs or []) + self.default_envs()) - known_envs if unknown_envs: raise exception.UnknownEnvNamesError( ", ".join(unknown_envs), ", ".join(known_envs) ) for env in known_envs: # check envs names if not self.ENVNAME_RE.match(env): raise exception.InvalidEnvNameError(env) # check simultaneous use of `monitor_raw` and `monitor_filters` if self.get(f"env:{env}", "monitor_raw", False) and self.get( f"env:{env}", "monitor_filters", None ): self.warnings.append( "The `monitor_raw` and `monitor_filters` options cannot be " f"used simultaneously for the `{env}` environment in the " "`platformio.ini` file. The `monitor_filters` option will " "be disabled to avoid conflicts." ) if not silent: for warning in self.warnings: click.secho("Warning! %s" % warning, fg="yellow") return True class ProjectConfigDirsMixin: def get_optional_dir(self, name): """ Deprecated, used by platformio-node-helpers.project.observer.fetchLibDirs PlatformIO IDE for Atom depends on platformio-node-helpers@~7.2.0 """ return self.get("platformio", f"{name}_dir") class ProjectConfig(ProjectConfigBase, ProjectConfigDirsMixin): _instances = {} @staticmethod def get_instance(path=None): path = ProjectConfig.get_default_path() if path is None else path mtime = os.path.getmtime(path) if os.path.isfile(path) else 0 instance = ProjectConfig._instances.get(path) if instance and instance["mtime"] != mtime: instance = None if not instance: instance = {"mtime": mtime, "config": ProjectConfig(path)} ProjectConfig._instances[path] = instance return instance["config"] def __repr__(self): return "" % (self.path or "in-memory") def as_tuple(self): return [(s, self.items(s)) for s in self.sections()] def to_json(self): return json.dumps(self.as_tuple()) def update(self, data, clear=False): assert isinstance(data, list) if clear: self._parser = configparser.ConfigParser() for section, options in data: if not self._parser.has_section(section): self._parser.add_section(section) for option, value in options: self.set(section, option, value) def save(self, path=None): path = path or self.path if path in self._instances: del self._instances[path] with open(path or self.path, mode="w+", encoding="utf8") as fp: fp.write(CONFIG_HEADER.strip() + "\n\n") self._parser.write(fp) fp.seek(0) contents = fp.read() fp.seek(0) fp.truncate() fp.write(contents.strip() + "\n") return True