diff --git a/CHANGELOG.md b/CHANGELOG.md index dc84684..a5b52b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ and versions are tracked in the repo-root `VERSION` file. - Continue compatibility hardening and adoption work for the next release. +### Fixed + +- Keep plain consumer configuration mappings opaque so only validated + `ConfigSnapshot.framework` values control lifecycle behavior. + ## [0.4.3] - 2026-08-29 This is a compatible pre-1.0 patch release. It contains lifecycle hardening, diff --git a/docs/consumer-profiles.md b/docs/consumer-profiles.md index 4ebe28f..983ef4b 100644 --- a/docs/consumer-profiles.md +++ b/docs/consumer-profiles.md @@ -149,6 +149,12 @@ validated into `Context.framework_config` and are excluded from the consumer configuration dictionary. All other keys remain consumer-owned and are exposed through `Context.config`. +Custom `ConfigLoader` callbacks that return a plain mapping do not opt into +those lifecycle settings: every mapping key, including names that resemble +framework keys, remains consumer data. Return a `ConfigSnapshot` to supply +validated framework settings. This prevents strings such as `"false"` or +`"debug"` from changing lifecycle behavior through an unvalidated mapping. + ## Safe profile errors Plain exceptions from profile callbacks are treated as unexpected internal diff --git a/lib/python/base_cli/_app_core.py b/lib/python/base_cli/_app_core.py index 507c9d6..a15b00e 100644 --- a/lib/python/base_cli/_app_core.py +++ b/lib/python/base_cli/_app_core.py @@ -1136,18 +1136,13 @@ def _create_context( environment = ( standard.get("environment") or (framework_config.environment if framework_config is not None else None) - or config.get("environment") or "dev" ) - log_level = ( - framework_config.log_level if framework_config is not None else str(config.get("log_level", "")).lower() - ) + log_level = framework_config.log_level if framework_config is not None else None debug = bool(standard.get("debug") or log_level == "debug") quiet = bool(standard.get("quiet")) keep_temp = bool( - standard.get("keep_temp") - or (framework_config.keep_temp if framework_config is not None else None) - or config.get("keep_temp") + standard.get("keep_temp") or (framework_config.keep_temp if framework_config is not None else None) ) _capture_effective_output_options( owner_app=self, diff --git a/lib/python/base_cli/_private_files.py b/lib/python/base_cli/_private_files.py index 76fe76f..787c6fd 100644 --- a/lib/python/base_cli/_private_files.py +++ b/lib/python/base_cli/_private_files.py @@ -182,7 +182,11 @@ def _sync_directory(parent_fd: int) -> None: def _replace_with_retry(source: Path, destination: Path) -> None: """Replace a private file, tolerating transient Windows sharing races.""" - attempts = 1 if os.name != "nt" else 10 + # Antivirus/indexer handles and concurrent writers can hold the destination + # briefly on Windows. Use a bounded, linear backoff long enough for those + # transient sharing violations without making a persistent permission error + # unbounded. + attempts = 1 if os.name != "nt" else 50 for attempt in range(attempts): try: os.replace(source, destination) @@ -190,4 +194,4 @@ def _replace_with_retry(source: Path, destination: Path) -> None: except PermissionError: if attempt == attempts - 1: raise - time.sleep(0.001 * (attempt + 1)) + time.sleep(0.005 * (attempt + 1)) diff --git a/tests/test_app_run_metadata.py b/tests/test_app_run_metadata.py index 11ee234..bd4c860 100644 --- a/tests/test_app_run_metadata.py +++ b/tests/test_app_run_metadata.py @@ -429,7 +429,11 @@ def fail_runtime(_cli_name: str, _project: base_cli.ProjectInfo | None) -> base_ profile = replace( base_cli.CliProfile.generic( - load_config=lambda _project, _explicit: {"log_level": "debug"}, + load_config=lambda _project, _explicit: base_cli.ConfigSnapshot( + config={}, + framework=base_cli.FrameworkConfig(log_level="debug"), + provenance={}, + ), ), resolve_runtime=fail_runtime, ) @@ -450,7 +454,11 @@ def main(ctx: base_cli.Context) -> None: def test_config_debug_with_quiet_keeps_traceback_out_of_stderr_and_shows_hint(self) -> None: profile = base_cli.CliProfile.generic( - load_config=lambda _project, _explicit: {"log_level": "debug"}, + load_config=lambda _project, _explicit: base_cli.ConfigSnapshot( + config={}, + framework=base_cli.FrameworkConfig(log_level="debug"), + provenance={}, + ), ) app = base_cli.App(name="metadata-config-debug-quiet", profile=profile) @@ -472,6 +480,39 @@ def main(ctx: base_cli.Context) -> None: self.assertIn("RuntimeError: quiet private detail", log_text) _assert_terminal_metadata(self, metadata, status="error", outcome="unexpected_error", exit_code=1) + def test_plain_consumer_lifecycle_keys_do_not_change_framework_state(self) -> None: + profile = base_cli.CliProfile.generic( + load_config=lambda _project, _explicit: { + "environment": "production", + "log_level": "debug", + "keep_temp": "false", + "answer": 42, + } + ) + app = base_cli.App(name="opaque-config", profile=profile, log_to_file=False) + seen: dict[str, object] = {} + + @app.command() + def main(ctx: base_cli.Context) -> None: + seen.update( + environment=ctx.environment, + debug=ctx.debug, + keep_temp=ctx.keep_temp, + config=ctx.config, + ) + + with tempfile.TemporaryDirectory() as tmpdir: + status, _stderr = _run(app, Path(tmpdir)) + + self.assertEqual(status, 0) + self.assertEqual(seen["environment"], "dev") + self.assertFalse(seen["debug"]) + self.assertFalse(seen["keep_temp"]) + self.assertEqual( + seen["config"], + {"environment": "production", "log_level": "debug", "keep_temp": "false", "answer": 42}, + ) + def test_traceback_logging_interruption_cannot_replace_primary_exception(self) -> None: app = base_cli.App(name="metadata-traceback-interrupt") diff --git a/tests/test_explicit_config_validation.py b/tests/test_explicit_config_validation.py index a2cc054..c4efc43 100644 --- a/tests/test_explicit_config_validation.py +++ b/tests/test_explicit_config_validation.py @@ -317,7 +317,7 @@ def main(ctx: base_cli.Context) -> None: result = invoke(app, ["--config", "~/explicit-config.yml"], home=home) self.assertEqual(result.exit_code, 0, _combined_output(result)) - self.assertEqual(seen, {"environment": "tilde"}) + self.assertEqual(seen, {"environment": "dev"}) if __name__ == "__main__":