From 720b8f556cc68791310aeb1fd51b2a4f9f1ee48b Mon Sep 17 00:00:00 2001 From: Matthew McGowan Date: Fri, 20 Feb 2026 02:33:30 -0800 Subject: [PATCH 1/2] feat(config): add source file tracking for config options Add get_source(section, option) method to ProjectConfigBase that returns the file path where a given option was last defined. This enables tools to determine which config file (main platformio.ini or extra_configs) contributes each option -- useful for lockfile generation, debugging config resolution, and IDE tooling. Implementation: independently parses each file during read() to build a source map, matching configparser last-file-wins merge semantics. --- platformio/project/config.py | 26 +++++++ tests/project/test_config.py | 130 +++++++++++++++++++++++------------ 2 files changed, 112 insertions(+), 44 deletions(-) diff --git a/platformio/project/config.py b/platformio/project/config.py index 82b76abf09..3c2c6f4e4d 100644 --- a/platformio/project/config.py +++ b/platformio/project/config.py @@ -94,6 +94,7 @@ def __init__(self, path=None, parse_extra=True, expand_interpolations=True): self.expand_interpolations = expand_interpolations self.warnings = [] self._parsed = [] + self._source_map = {} self._parser = configparser.ConfigParser(inline_comment_prefixes=("#", ";")) if path and os.path.isfile(path): self.read(path, parse_extra) @@ -112,6 +113,8 @@ def read(self, path, parse_extra=True): except configparser.Error as exc: raise exception.InvalidProjectConfError(path, str(exc)) from exc + self._update_source_map(path) + if not parse_extra: return @@ -122,6 +125,29 @@ def read(self, path, parse_extra=True): for item in glob.glob(pattern, recursive=True): self.read(item) + def _update_source_map(self, path): + """Record which file defines each (section, option) pair. + + Independently parses the file to discover its sections and options. + Later files override earlier ones, matching configparser's merge + semantics. + """ + file_parser = configparser.ConfigParser(inline_comment_prefixes=("#", ";")) + try: + file_parser.read(path, "utf-8") + except configparser.Error: + return + for section in file_parser.sections(): + for option in file_parser.options(section): + self._source_map[(section, option)] = path + + def get_source(self, section, option): + """Return the file path where the given option was last defined. + + Returns None if the option is not found in any parsed file. + """ + return self._source_map.get((section, option)) + def _maintain_renamed_options(self): renamed_options = {} for option in ProjectOptions.values(): diff --git a/tests/project/test_config.py b/tests/project/test_config.py index 02741b9544..4fb8e82d31 100644 --- a/tests/project/test_config.py +++ b/tests/project/test_config.py @@ -438,15 +438,13 @@ def test_items(config): def test_update_and_save(tmpdir_factory): tmpdir = tmpdir_factory.mktemp("project") - tmpdir.join("platformio.ini").write( - """ + tmpdir.join("platformio.ini").write(""" [platformio] extra_configs = a.ini, b.ini [env:myenv] board = myboard - """ - ) + """) config = ProjectConfig(tmpdir.join("platformio.ini").strpath) assert config.envs() == ["myenv"] assert config.as_tuple()[0][1][0][1] == ["a.ini", "b.ini"] @@ -487,15 +485,13 @@ def test_update_and_save(tmpdir_factory): def test_update_and_clear(tmpdir_factory): tmpdir = tmpdir_factory.mktemp("project") - tmpdir.join("platformio.ini").write( - """ + tmpdir.join("platformio.ini").write(""" [platformio] extra_configs = a.ini, b.ini [env:myenv] board = myboard - """ - ) + """) config = ProjectConfig(tmpdir.join("platformio.ini").strpath) assert config.sections() == ["platformio", "env:myenv"] config.update([["mysection", [("opt1", "value1"), ("opt2", "value2")]]], clear=True) @@ -588,12 +584,10 @@ def test_win_core_root_dir(tmpdir_factory): # Override in config tmpdir = tmpdir_factory.mktemp("project") - tmpdir.join("platformio.ini").write( - """ + tmpdir.join("platformio.ini").write(""" [platformio] core_dir = ~/.pio - """ - ) + """) config = ProjectConfig(tmpdir.join("platformio.ini").strpath) assert config.get("platformio", "core_dir") != win_core_root_dir assert config.get("platformio", "core_dir") == os.path.realpath( @@ -608,8 +602,7 @@ def test_win_core_root_dir(tmpdir_factory): def test_this(tmp_path: Path): project_conf = tmp_path / "platformio.ini" - project_conf.write_text( - """ + project_conf.write_text(""" [common] board = uno @@ -617,8 +610,7 @@ def test_this(tmp_path: Path): extends = common build_flags = -D${this.__env__} custom_option = ${this.board} - """ - ) + """) config = ProjectConfig(str(project_conf)) assert config.get("env:myenv", "custom_option") == "uno" assert config.get("env:myenv", "build_flags") == ["-Dmyenv"] @@ -628,30 +620,25 @@ def test_project_name(tmp_path: Path): project_dir = tmp_path / "my-project-name" project_dir.mkdir() project_conf = project_dir / "platformio.ini" - project_conf.write_text( - """ + project_conf.write_text(""" [env:myenv] - """ - ) + """) with fs.cd(str(project_dir)): config = ProjectConfig(str(project_conf)) assert config.get("platformio", "name") == "my-project-name" # custom name - project_conf.write_text( - """ + project_conf.write_text(""" [platformio] name = custom-project-name - """ - ) + """) config = ProjectConfig(str(project_conf)) assert config.get("platformio", "name") == "custom-project-name" def test_nested_interpolation(tmp_path: Path): project_conf = tmp_path / "platformio.ini" - project_conf.write_text( - """ + project_conf.write_text(""" [platformio] build_dir = /tmp/pio-$PROJECT_HASH data_dir = $PROJECT_DIR/assets @@ -669,8 +656,7 @@ def test_nested_interpolation(tmp_path: Path): 16000000L ${UPLOAD_PORT and "-p "+UPLOAD_PORT} ${platformio.build_dir}/${this.__env__}/firmware.elf - """ - ) + """) config = ProjectConfig(str(project_conf)) assert config.get("platformio", "data_dir").endswith( os.path.join("$PROJECT_DIR", "assets") @@ -688,8 +674,7 @@ def test_nested_interpolation(tmp_path: Path): def test_extends_order(tmp_path: Path): project_conf = tmp_path / "platformio.ini" - project_conf.write_text( - """ + project_conf.write_text(""" [a] board = test @@ -701,19 +686,16 @@ def test_extends_order(tmp_path: Path): [env:na_ti-ve13] extends = a, b, c - """ - ) + """) config = ProjectConfig(str(project_conf)) assert config.get("env:na_ti-ve13", "upload_tool") == "three" def test_invalid_env_names(tmp_path: Path): project_conf = tmp_path / "platformio.ini" - project_conf.write_text( - """ + project_conf.write_text(""" [env:app:1] - """ - ) + """) config = ProjectConfig(str(project_conf)) with pytest.raises(InvalidEnvNameError, match=r".*Invalid environment name 'app:1"): config.validate() @@ -721,13 +703,11 @@ def test_invalid_env_names(tmp_path: Path): def test_linting_errors(tmp_path: Path): project_conf = tmp_path / "platformio.ini" - project_conf.write_text( - """ + project_conf.write_text(""" [env:app1] lib_use = 1 broken_line - """ - ) + """) result = ProjectConfig.lint(str(project_conf)) assert not result["warnings"] assert result["errors"] and len(result["errors"]) == 1 @@ -738,18 +718,80 @@ def test_linting_errors(tmp_path: Path): def test_linting_warnings(tmp_path: Path): project_conf = tmp_path / "platformio.ini" - project_conf.write_text( - """ + project_conf.write_text(""" [platformio] build_dir = /tmp/pio-$PROJECT_HASH [env:app1] lib_use = 1 test_testing_command = /usr/bin/flash-tool -p $UPLOAD_PORT -b $UPLOAD_SPEED - """ - ) + """) result = ProjectConfig.lint(str(project_conf)) assert not result["errors"] assert result["warnings"] and len(result["warnings"]) == 2 assert "deprecated" in result["warnings"][0] assert "Invalid variable declaration" in result["warnings"][1] + + +def test_get_source_single_file(tmp_path: Path): + project_conf = tmp_path / "platformio.ini" + project_conf.write_text(""" +[platformio] +src_dir = src + +[env:myenv] +board = esp32 +build_flags = -DFOO + """) + config = ProjectConfig(str(project_conf)) + assert config.get_source("platformio", "src_dir") == str(project_conf) + assert config.get_source("env:myenv", "board") == str(project_conf) + assert config.get_source("env:myenv", "build_flags") == str(project_conf) + + +def test_get_source_extra_configs(config): + # The module-scoped `config` fixture has three files: + # - platformio.ini (BASE_CONFIG) + # - extra_envs.ini (EXTRA_ENVS_CONFIG) + # - extra_debug.ini (EXTRA_DEBUG_CONFIG) + + # Options only in platformio.ini + assert os.path.basename(config.get_source("platformio", "env_default")) == ( + "platformio.ini" + ) + assert os.path.basename(config.get_source("env:base", "build_flags")) == ( + "platformio.ini" + ) + + # Options only in extra_envs.ini + assert os.path.basename(config.get_source("env:extra_1", "build_flags")) == ( + "extra_envs.ini" + ) + assert os.path.basename(config.get_source("env:extra_2", "upload_port")) == ( + "extra_envs.ini" + ) + + # Options overridden by extra_debug.ini (last file wins) + # custom.debug_flags is defined in platformio.ini and overridden in extra_debug.ini + assert os.path.basename(config.get_source("custom", "debug_flags")) == ( + "extra_debug.ini" + ) + # env:extra_2.build_flags is in extra_envs.ini and overridden in extra_debug.ini + assert os.path.basename(config.get_source("env:extra_2", "build_flags")) == ( + "extra_debug.ini" + ) + + +def test_get_source_unknown_option(config): + assert config.get_source("custom", "nonexistent_option") is None + assert config.get_source("nonexistent_section", "option") is None + + +def test_get_source_no_extra(tmp_path: Path): + project_conf = tmp_path / "platformio.ini" + project_conf.write_text(""" +[env:myenv] +board = esp32 + """) + config = ProjectConfig(str(project_conf), parse_extra=False) + assert config.get_source("env:myenv", "board") == str(project_conf) From 92a27271a4b6167196a126a9f32e06aa8bf66830 Mon Sep 17 00:00:00 2001 From: Matthew McGowan Date: Fri, 20 Feb 2026 02:33:30 -0800 Subject: [PATCH 2/2] feat(config): add source file tracking for config options Add get_source(section, option) method to ProjectConfigBase that returns the file path where a given option was last defined. This enables tools to determine which config file (main platformio.ini or extra_configs) contributes each option -- useful for lockfile generation, debugging config resolution, and IDE tooling. Implementation: independently parses each file during read() to build a source map, matching configparser last-file-wins merge semantics. --- platformio/project/commands/config.py | 29 ++++++++++++++++----------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/platformio/project/commands/config.py b/platformio/project/commands/config.py index 214b0db145..c0ac08b5b5 100644 --- a/platformio/project/commands/config.py +++ b/platformio/project/commands/config.py @@ -32,16 +32,21 @@ ) @click.option("--lint", is_flag=True) @click.option("--json-output", is_flag=True) -def project_config_cmd(project_dir, lint, json_output): +@click.option( + "--show-source", + is_flag=True, + help="Show the config file where each option is defined.", +) +def project_config_cmd(project_dir, lint, json_output, show_source): if not is_platformio_project(project_dir): raise NotPlatformIOProjectError(project_dir) with fs.cd(project_dir): if lint: return lint_configuration(json_output) - return print_configuration(json_output) + return print_configuration(json_output, show_source) -def print_configuration(json_output=False): +def print_configuration(json_output=False, show_source=False): config = ProjectConfig.get_instance() if json_output: return click.echo(config.to_json()) @@ -51,15 +56,15 @@ def print_configuration(json_output=False): for section, options in config.as_tuple(): click.secho(section, fg="cyan") click.echo("-" * len(section)) - click.echo( - tabulate( - [ - (name, "=", "\n".join(value) if isinstance(value, list) else value) - for name, value in options - ], - tablefmt="plain", - ) - ) + rows = [] + for name, value in options: + display_value = "\n".join(value) if isinstance(value, list) else value + row = [name, "=", display_value] + if show_source: + source = config.get_source(section, name) + row.append("; from " + os.path.basename(source) if source else "") + rows.append(row) + click.echo(tabulate(rows, tablefmt="plain")) click.echo() return None