diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index adbe842..228cce3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -42,8 +42,16 @@ Configuration follows one precedence rule: `api_key` resolves from `COMETAPI_KEY` and has no default. `base_url` resolves from `COMETAPI_BASE_URL` and defaults to -`https://api.cometapi.com/v1`. Complete credentials must never appear in -CometAPI-generated exceptions or logs. +`https://api.cometapi.com/v1`. Direct and environment string values are +trimmed. An explicitly blank value is rejected without fallback; a blank +environment key is missing, while a blank environment base URL selects the +default. Callable keys and `httpx.URL` objects pass through unchanged. Complete +credentials must never appear in CometAPI-generated exceptions or logs. + +The inherited OpenAI `copy` and `with_options` helpers are outside the 0.1 +support contract. They must remain fail-closed for provider routing, workload +identity, private credential controls, and injected keyword mappings rather +than weakening the explicit CometAPI constructor boundary. ## Supported resource boundary diff --git a/CHANGELOG.md b/CHANGELOG.md index 42ba364..3f41388 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ automation. ### Fixed +- Reject explicitly blank API keys and base URLs without fallback, treat blank + environment keys as missing, and use the default CometAPI URL for a blank + `COMETAPI_BASE_URL`. - Add a fail-closed immutable-release recovery path with an environment-secret preflight before any live request. - Execute PyPI Trusted Publishing directly in the single top-level @@ -17,6 +20,13 @@ automation. release chain after a successful selector, while continuing to reject cancellation, reruns, and every non-successful direct dependency. +### Changed + +- Trim leading and trailing whitespace from direct and environment string API + keys and base URLs. Applications that intentionally supplied surrounding + whitespace must pass the intended credential or URL without that padding; + callable keys and `httpx.URL` objects are unchanged. + ### Documentation - Record completed stable publication, provenance, digest, and clean-install diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 4cb6cd7..6a40798 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -62,9 +62,11 @@ remains upstream-supported. | Behavior | Contract | | --- | --- | -| API key | Explicit `api_key`, then `COMETAPI_KEY`; required | -| Base URL | Explicit `base_url`, then `COMETAPI_BASE_URL`, then `https://api.cometapi.com/v1` | +| API key | Trim explicit/environment strings; explicit blank is invalid, blank `COMETAPI_KEY` is missing; required | +| Base URL | Trim explicit/environment strings; explicit blank is invalid, blank `COMETAPI_BASE_URL` uses `https://api.cometapi.com/v1` | +| Callable key and `httpx.URL` | Preserve official deferred-key and URL-object behavior unchanged | | OpenAI options | Documented constructor options forwarded unchanged | +| Inherited copy helpers | Unsupported and fail-closed for provider, workload-identity, and private-option injection | | Complete-key disclosure | Forbidden in CometAPI-generated errors and logs | Applications can configure `openai.OpenAI` or `openai.AsyncOpenAI` directly diff --git a/README.md b/README.md index b23e0df..4c82fa5 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,12 @@ variable, then the default: | `api_key` | `COMETAPI_KEY` | Required | | `base_url` | `COMETAPI_BASE_URL` | `https://api.cometapi.com/v1` | +Leading and trailing whitespace is removed from string API keys and base URLs. +An explicitly blank string is invalid and never falls back to the environment +or default. A blank `COMETAPI_KEY` is treated as missing, while a blank +`COMETAPI_BASE_URL` uses the default CometAPI URL. Callable API keys and +`httpx.URL` values retain their official OpenAI behavior. + ## Usage ### Chat Completions @@ -138,7 +144,9 @@ The constructor exposes a named, typed option set: `api_key`, `admin_api_key`, that documented custom HTTP client path. OpenAI provider routing and workload identity are deliberately excluded because they replace CometAPI routing or authentication; private underscore-prefixed OpenAI controls and arbitrary -keywords are rejected. +keywords are rejected. Inherited `copy` and `with_options` helpers are not part +of the supported 0.1 surface and cannot be used to introduce those excluded +routing or authentication options. ## Direct OpenAI interoperability diff --git a/ROADMAP.md b/ROADMAP.md index 41d1402..1815c6d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -33,6 +33,19 @@ claims remain limited to the evidence defined in this roadmap and | `0.3.0` CometAPI resources | Planned | First schema-backed typed CometAPI-specific resource. | | Media and task APIs | Planned | Coherent task lifecycle precedes individual media helpers. | +## `0.1.x` maintenance + +Stable maintenance preserves the 0.1 public operation and constructor surface. +String API keys and base URLs are trimmed at their direct or environment +boundary. Explicit blank values fail without fallback; a blank environment key +is missing, while a blank environment base URL selects the default CometAPI +URL. Callable keys and `httpx.URL` values keep their official OpenAI semantics. +Inherited `copy` and `with_options` helpers remain unsupported and fail-closed +for provider routing, workload identity, and private-option injection. + +No maintenance release activates 0.2 provider adapters or adds a new resource, +CLI, translation, or Go surface without a separate maintainer request. + ## Repository foundation Deliverables: diff --git a/scripts/check_workflows.py b/scripts/check_workflows.py index de54407..81a70f1 100644 --- a/scripts/check_workflows.py +++ b/scripts/check_workflows.py @@ -21,7 +21,7 @@ RELEASE_PLEASE_LOCK_JSONPATH = "$.package[?(@.name.value == 'cometapi')].version" RELEASE_PLEASE_ACTION_SHA = "5c625bfb5d1ff62eadeeb3772007f7f66fdcf071" RELEASE_PLEASE_BRIDGE_VERSION = "0.1.0-alpha.1" -RELEASE_PLEASE_STABLE_VERSION = "0.1.0" +RELEASE_PLEASE_STABLE_VERSION_PATTERN = re.compile(r"0\.1\.(?:0|[1-9][0-9]*)") RELEASE_PLEASE_VERIFY_COMMAND = """\ test -n "$EXPECTED_TAG" test -n "$EXPECTED_SHA" @@ -953,7 +953,7 @@ def check_release_recovery_workflow(text: str) -> None: def check_release_please_config(text: str, manifest_text: str) -> None: - """Require either the reviewed bridge or its exact stable cleanup state.""" + """Require either the reviewed bridge or a stable 0.1.x cleanup state.""" try: value = cast(object, json.loads(text)) except json.JSONDecodeError as error: @@ -966,8 +966,14 @@ def check_release_please_config(text: str, manifest_text: str) -> None: manifest = _mapping(manifest_value, "Release Please manifest") _require_exact_keys(manifest, {"."}, "Release Please manifest") version = manifest["."] - if version not in {RELEASE_PLEASE_BRIDGE_VERSION, RELEASE_PLEASE_STABLE_VERSION}: - raise CheckError("Release Please manifest must be the reviewed bridge or stable version") + stable_version = ( + isinstance(version, str) + and RELEASE_PLEASE_STABLE_VERSION_PATTERN.fullmatch(version) is not None + ) + if version != RELEASE_PLEASE_BRIDGE_VERSION and not stable_version: + raise CheckError( + "Release Please manifest must be the reviewed bridge or a stable 0.1.x version" + ) common_keys = { "$schema", @@ -996,7 +1002,7 @@ def check_release_please_config(text: str, manifest_text: str) -> None: ) else: _require_exact_keys(config, common_keys, "Release Please stable config") - if version != RELEASE_PLEASE_STABLE_VERSION: + if not stable_version: raise CheckError("Release Please may remove the one-time bridge only after stable") if config["release-type"] != "python": raise CheckError("Release Please must retain its Python release type") diff --git a/src/cometapi/_config.py b/src/cometapi/_config.py index aee7cda..673a3c6 100644 --- a/src/cometapi/_config.py +++ b/src/cometapi/_config.py @@ -12,19 +12,43 @@ _T = TypeVar("_T") +def _strip_configuration_whitespace(value: str) -> str: + start = 0 + end = len(value) + while start < end and (value[start].isspace() or value[start] == "\ufeff"): + start += 1 + while end > start and (value[end - 1].isspace() or value[end - 1] == "\ufeff"): + end -= 1 + return value[start:end] + + def resolve_api_key(api_key: _T | None) -> _T | str: """Resolve an explicit API key before consulting ``COMETAPI_KEY``.""" + if api_key is not None and not isinstance(api_key, str): + return api_key + resolved = api_key if api_key is not None else os.environ.get("COMETAPI_KEY") - if resolved is None or (isinstance(resolved, str) and not resolved): + normalized = _strip_configuration_whitespace(resolved) if resolved is not None else "" + if not normalized: raise OpenAIError( "The CometAPI API key must be provided with the api_key client option " "or the COMETAPI_KEY environment variable." ) - return resolved + return normalized def resolve_base_url(base_url: _T | None) -> _T | str: """Resolve an explicit base URL before environment and default values.""" if base_url is not None: + if isinstance(base_url, str): + normalized = _strip_configuration_whitespace(base_url) + if not normalized: + raise OpenAIError("The CometAPI base_url client option must not be empty.") + return normalized return base_url - return os.environ.get("COMETAPI_BASE_URL") or DEFAULT_BASE_URL + + environment_url = os.environ.get("COMETAPI_BASE_URL") + normalized = ( + _strip_configuration_whitespace(environment_url) if environment_url is not None else "" + ) + return normalized or DEFAULT_BASE_URL diff --git a/tests/test_client.py b/tests/test_client.py index eb3cdf6..a2599cd 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -45,12 +45,37 @@ def test_missing_key_raises_official_error_without_secret( assert "Bearer" not in message -def test_explicit_empty_key_does_not_fall_back_to_environment( +@pytest.mark.parametrize("client_type", [CometAPI, AsyncCometAPI]) +@pytest.mark.parametrize("api_key", ["", " ", "\ufeff", " \ufeff\t"]) +def test_explicit_blank_key_does_not_fall_back_to_environment( + client_type: type[CometAPI] | type[AsyncCometAPI], + api_key: str, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("COMETAPI_KEY", "environment-key") - with pytest.raises(OpenAIError): - CometAPI(api_key="") + with pytest.raises(OpenAIError) as caught: + client_type(api_key=api_key) + + assert "environment-key" not in str(caught.value) + assert "environment-key" not in repr(caught.value) + + +@pytest.mark.parametrize("client_type", [CometAPI, AsyncCometAPI]) +@pytest.mark.parametrize("environment_key", [" \t ", "\ufeff", " \ufeff\t"]) +def test_whitespace_environment_key_is_treated_as_missing( + client_type: type[CometAPI] | type[AsyncCometAPI], + environment_key: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("COMETAPI_KEY", environment_key) + monkeypatch.setenv("OPENAI_API_KEY", "upstream-environment-key") + + with pytest.raises(OpenAIError) as caught: + client_type() + + message = str(caught.value) + assert "COMETAPI_KEY" in message + assert "upstream-environment-key" not in message def test_explicit_sync_key_takes_precedence_over_environment( @@ -66,6 +91,20 @@ def test_explicit_sync_key_takes_precedence_over_environment( assert router.requests[0].headers["authorization"] == f"Bearer {API_KEY}" +def test_explicit_sync_key_is_trimmed() -> None: + router = ContractRouter() + http_client = sync_http_client(router) + + with CometAPI( + api_key=f" \ufeff{API_KEY}\ufeff\t", + base_url=BASE_URL, + http_client=http_client, + ) as client: + client.models.list() + + assert router.requests[0].headers["authorization"] == f"Bearer {API_KEY}" + + @pytest.mark.asyncio async def test_explicit_async_key_takes_precedence_over_environment( monkeypatch: pytest.MonkeyPatch, @@ -84,10 +123,25 @@ async def test_explicit_async_key_takes_precedence_over_environment( assert router.requests[0].headers["authorization"] == f"Bearer {API_KEY}" +@pytest.mark.asyncio +async def test_explicit_async_key_is_trimmed() -> None: + router = ContractRouter() + http_client = async_http_client(router) + + async with AsyncCometAPI( + api_key=f" \ufeff{API_KEY}\ufeff\t", + base_url=BASE_URL, + http_client=http_client, + ) as client: + await client.models.list() + + assert router.requests[0].headers["authorization"] == f"Bearer {API_KEY}" + + def test_sync_environment_key_is_used_when_key_is_omitted( monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setenv("COMETAPI_KEY", API_KEY) + monkeypatch.setenv("COMETAPI_KEY", f" {API_KEY}\t") router = ContractRouter() http_client = sync_http_client(router) @@ -101,7 +155,7 @@ def test_sync_environment_key_is_used_when_key_is_omitted( async def test_async_environment_key_is_used_when_key_is_omitted( monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setenv("COMETAPI_KEY", API_KEY) + monkeypatch.setenv("COMETAPI_KEY", f" {API_KEY}\t") router = ContractRouter() http_client = async_http_client(router) @@ -122,11 +176,32 @@ def test_explicit_base_url_takes_precedence_over_environment( client.close() +@pytest.mark.parametrize("client_type", [CometAPI, AsyncCometAPI]) +@pytest.mark.parametrize("base_url", ["", " ", "\ufeff", " \ufeff\t"]) +def test_explicit_blank_base_url_does_not_fall_back_to_environment( + client_type: type[CometAPI] | type[AsyncCometAPI], + base_url: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("COMETAPI_BASE_URL", "https://environment.example.test/v1") + + with pytest.raises(OpenAIError, match="base_url"): + client_type(api_key=API_KEY, base_url=base_url) + + +def test_explicit_sync_string_base_url_is_trimmed() -> None: + client = CometAPI(api_key=API_KEY, base_url=f" \ufeff{BASE_URL}\ufeff\t") + try: + assert str(client.base_url) == f"{BASE_URL}/" + finally: + client.close() + + def test_environment_base_url_is_used_when_explicit_url_is_omitted( monkeypatch: pytest.MonkeyPatch, ) -> None: environment_url = "https://environment.example.test/v1" - monkeypatch.setenv("COMETAPI_BASE_URL", environment_url) + monkeypatch.setenv("COMETAPI_BASE_URL", f" {environment_url}\t") client = CometAPI(api_key=API_KEY) try: assert str(client.base_url) == f"{environment_url}/" @@ -146,12 +221,21 @@ async def test_async_explicit_base_url_takes_precedence_over_environment( await client.close() +@pytest.mark.asyncio +async def test_explicit_async_string_base_url_is_trimmed() -> None: + client = AsyncCometAPI(api_key=API_KEY, base_url=f" {BASE_URL}\t") + try: + assert str(client.base_url) == f"{BASE_URL}/" + finally: + await client.close() + + @pytest.mark.asyncio async def test_async_environment_base_url_is_used_when_explicit_url_is_omitted( monkeypatch: pytest.MonkeyPatch, ) -> None: environment_url = "https://environment.example.test/v1" - monkeypatch.setenv("COMETAPI_BASE_URL", environment_url) + monkeypatch.setenv("COMETAPI_BASE_URL", f" {environment_url}\t") client = AsyncCometAPI(api_key=API_KEY) try: assert str(client.base_url) == f"{environment_url}/" @@ -170,11 +254,45 @@ def test_default_base_url_is_used_when_no_override_exists( client.close() +@pytest.mark.parametrize("environment_url", [" \t ", "\ufeff", " \ufeff\t"]) +def test_whitespace_environment_base_url_uses_cometapi_default( + environment_url: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("COMETAPI_BASE_URL", environment_url) + monkeypatch.setenv("OPENAI_BASE_URL", "https://upstream.example.test/v1") + + client = CometAPI(api_key=API_KEY) + try: + assert str(client.base_url) == f"{DEFAULT_BASE_URL}/" + finally: + client.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("environment_url", [" \t ", "\ufeff", " \ufeff\t"]) +async def test_async_whitespace_environment_base_url_uses_cometapi_default( + environment_url: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("COMETAPI_BASE_URL", environment_url) + monkeypatch.setenv("OPENAI_BASE_URL", "https://upstream.example.test/v1") + + client = AsyncCometAPI(api_key=API_KEY) + try: + assert str(client.base_url) == f"{DEFAULT_BASE_URL}/" + finally: + await client.close() + + def test_sync_callable_key_is_preserved() -> None: router = ContractRouter() http_client = sync_http_client(router) + calls = 0 def key_provider() -> str: + nonlocal calls + calls += 1 return API_KEY with CometAPI( @@ -182,8 +300,10 @@ def key_provider() -> str: base_url=BASE_URL, http_client=http_client, ) as client: + assert calls == 0 client.models.list() + assert calls == 1 assert router.requests[0].headers["authorization"] == f"Bearer {API_KEY}" @@ -191,8 +311,11 @@ def key_provider() -> str: async def test_async_callable_key_is_preserved() -> None: router = ContractRouter() http_client = async_http_client(router) + calls = 0 async def key_provider() -> str: + nonlocal calls + calls += 1 return API_KEY async with AsyncCometAPI( @@ -200,8 +323,10 @@ async def key_provider() -> str: base_url=BASE_URL, http_client=http_client, ) as client: + assert calls == 0 await client.models.list() + assert calls == 1 assert router.requests[0].headers["authorization"] == f"Bearer {API_KEY}" @@ -420,13 +545,16 @@ def test_sync_official_error_identity_and_key_non_leakage( ) -> None: caplog.set_level(logging.DEBUG) http_client = sync_http_client(unauthorized_handler) + padded_key = f" {API_KEY}\t" with CometAPI( - api_key=API_KEY, + api_key=padded_key, base_url=BASE_URL, max_retries=0, http_client=http_client, ) as client: + assert API_KEY not in repr(client) + assert padded_key not in repr(client) with pytest.raises(AuthenticationError) as caught: if operation == "chat": client.chat.completions.create( @@ -442,6 +570,9 @@ def test_sync_official_error_identity_and_key_non_leakage( assert API_KEY not in str(caught.value) assert API_KEY not in repr(caught.value) assert API_KEY not in caplog.text + assert padded_key not in str(caught.value) + assert padded_key not in repr(caught.value) + assert padded_key not in caplog.text @pytest.mark.parametrize("operation", ["chat", "responses", "models"]) @@ -452,13 +583,16 @@ async def test_async_official_error_identity_and_key_non_leakage( ) -> None: caplog.set_level(logging.DEBUG) http_client = async_http_client(unauthorized_handler) + padded_key = f" {API_KEY}\t" async with AsyncCometAPI( - api_key=API_KEY, + api_key=padded_key, base_url=BASE_URL, max_retries=0, http_client=http_client, ) as client: + assert API_KEY not in repr(client) + assert padded_key not in repr(client) with pytest.raises(AuthenticationError) as caught: if operation == "chat": await client.chat.completions.create( @@ -474,6 +608,9 @@ async def test_async_official_error_identity_and_key_non_leakage( assert API_KEY not in str(caught.value) assert API_KEY not in repr(caught.value) assert API_KEY not in caplog.text + assert padded_key not in str(caught.value) + assert padded_key not in repr(caught.value) + assert padded_key not in caplog.text def test_constructor_forwards_additional_public_openai_options() -> None: @@ -570,3 +707,55 @@ def test_public_constructor_rejects_private_or_route_bypassing_options( ) -> None: with pytest.raises(TypeError, match="unexpected keyword argument"): client_type(api_key=API_KEY, **{option: value}) # pyright: ignore[reportArgumentType] + + +COPY_BYPASS_OPTIONS = [ + ("provider", object()), + ( + "workload_identity", + { + "identity_provider_id": "provider", + "service_account_id": "service-account", + "provider": {"token_type": "jwt", "get_token": lambda: "token"}, + }, + ), + ("_enforce_credentials", False), + ("_extra_kwargs", {"provider": object()}), +] + + +@pytest.mark.parametrize("method_name", ["copy", "with_options"]) +@pytest.mark.parametrize(("option", "value"), COPY_BYPASS_OPTIONS) +def test_sync_inherited_copy_options_cannot_bypass_cometapi_boundaries( + method_name: str, + option: str, + value: object, +) -> None: + client = CometAPI(api_key=API_KEY, base_url=BASE_URL) + try: + method = client.copy if method_name == "copy" else client.with_options + with pytest.raises(TypeError, match="unexpected keyword argument"): + method(**{option: value}) # pyright: ignore[reportArgumentType] + assert client.api_key == API_KEY + assert str(client.base_url) == f"{BASE_URL}/" + finally: + client.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method_name", ["copy", "with_options"]) +@pytest.mark.parametrize(("option", "value"), COPY_BYPASS_OPTIONS) +async def test_async_inherited_copy_options_cannot_bypass_cometapi_boundaries( + method_name: str, + option: str, + value: object, +) -> None: + client = AsyncCometAPI(api_key=API_KEY, base_url=BASE_URL) + try: + method = client.copy if method_name == "copy" else client.with_options + with pytest.raises(TypeError, match="unexpected keyword argument"): + method(**{option: value}) # pyright: ignore[reportArgumentType] + assert client.api_key == API_KEY + assert str(client.base_url) == f"{BASE_URL}/" + finally: + await client.close() diff --git a/tests/test_release_documents.py b/tests/test_release_documents.py index 9588ac7..fdbe935 100644 --- a/tests/test_release_documents.py +++ b/tests/test_release_documents.py @@ -15,6 +15,7 @@ CANONICAL_SECURITY, CANONICAL_SUPPORT, CheckError, + read_project_version, ) from scripts.check_version import require_public_preview_docs, require_releasable_docs @@ -243,12 +244,13 @@ def test_public_preview_cli_reports_aggregated_violations_and_fails( def test_release_version_cli_accepts_current_stable_tag() -> None: + current_version = read_project_version() result = subprocess.run( [ sys.executable, str(VERSION_SCRIPT), "--tag", - "v0.1.0", + f"v{current_version}", "--require-changelog", ], cwd=PROJECT_ROOT, @@ -258,7 +260,7 @@ def test_release_version_cli_accepts_current_stable_tag() -> None: ) assert result.returncode == 0, result.stderr - assert "version agreement passed: 0.1.0" in result.stdout + assert f"version agreement passed: {current_version}" in result.stdout def test_release_version_cli_accepts_approved_initial_alpha_recovery_tag( diff --git a/tests/test_release_workflow.py b/tests/test_release_workflow.py index 2b80d0c..e6c2027 100644 --- a/tests/test_release_workflow.py +++ b/tests/test_release_workflow.py @@ -1002,20 +1002,35 @@ def test_release_please_config_rejects_alpha_type_and_extra_updaters() -> None: def test_release_please_config_rejects_manifest_drift() -> None: - with pytest.raises(RuntimeError, match="reviewed bridge or stable version"): + with pytest.raises(RuntimeError, match=r"reviewed bridge or a stable 0\.1\.x version"): check_release_please_config( RELEASE_PLEASE_CONFIG.read_text(encoding="utf-8"), '{".": "0.1.0-alpha.2"}\n', ) -def test_release_please_config_accepts_exact_stable_cleanup() -> None: +@pytest.mark.parametrize("version", ["0.1.0", "0.1.1", "0.1.42"]) +def test_release_please_config_accepts_stable_maintenance_cleanup(version: str) -> None: check_release_please_config( RELEASE_PLEASE_CONFIG.read_text(encoding="utf-8"), - '{".": "0.1.0"}\n', + f'{{".": "{version}"}}\n', ) +@pytest.mark.parametrize( + "version", + ["0.1.01", "0.1.1-alpha.1", "0.1.1\u0662", "0.1.1\uff12", "0.2.0", "1.0.0"], +) +def test_release_please_config_rejects_versions_outside_stable_maintenance( + version: str, +) -> None: + with pytest.raises(RuntimeError, match=r"stable 0\.1\.x"): + check_release_please_config( + RELEASE_PLEASE_CONFIG.read_text(encoding="utf-8"), + f'{{".": "{version}"}}\n', + ) + + def test_release_please_config_rejects_stable_manifest_with_bridge() -> None: with pytest.raises(RuntimeError, match="remove the one-time bridge"): check_release_please_config(