Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions docs/consumer-profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 2 additions & 7 deletions lib/python/base_cli/_app_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 6 additions & 2 deletions lib/python/base_cli/_private_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,12 +182,16 @@ 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)
return
except PermissionError:
if attempt == attempts - 1:
raise
time.sleep(0.001 * (attempt + 1))
time.sleep(0.005 * (attempt + 1))
45 changes: 43 additions & 2 deletions tests/test_app_run_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand All @@ -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)

Expand All @@ -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")

Expand Down
2 changes: 1 addition & 1 deletion tests/test_explicit_config_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__":
Expand Down
Loading