diff --git a/docs/api-reference.md b/docs/api-reference.md index 5300181..91763c6 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1602,7 +1602,7 @@ base_cli.testing **Kind:** constant/type **Signature:** (value is not callable) -**Behavior:** str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str +**Behavior:** Installed base-cli distribution version exposed by the public facade. **Errors and compatibility:** Follow the contract documentation linked in the description. Callers should handle the documented exception types and pin a compatible minor release. @@ -1617,7 +1617,7 @@ value = base_cli.__version__ **Kind:** constant/type **Signature:** (value is not callable) -**Behavior:** Describe the wire type and nullability of one command-record field. +**Behavior:** Schema field descriptor for a required boolean command-record value. **Errors and compatibility:** Follow the contract documentation linked in the description. Callers should handle the documented exception types and pin a compatible minor release. @@ -1632,7 +1632,7 @@ value = base_cli.BOOLEAN **Kind:** constant/type **Signature:** (value is not callable) -**Behavior:** Type variable. +**Behavior:** Consumer-owned application-state type parameter carried by Context. **Errors and compatibility:** Follow the contract documentation linked in the description. Callers should handle the documented exception types and pin a compatible minor release. @@ -1647,7 +1647,7 @@ value = base_cli.ApplicationStateT **Kind:** constant/type **Signature:** (value is not callable) -**Behavior:** Public facade symbol; see the linked contract and source annotations for details. +**Behavior:** Callable type for canonicalizing command filter names. **Errors and compatibility:** Follow the contract documentation linked in the description. Callers should handle the documented exception types and pin a compatible minor release. @@ -1662,7 +1662,7 @@ value = base_cli.CommandFilterNormalizer **Kind:** constant/type **Signature:** (value is not callable) -**Behavior:** str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str +**Behavior:** Entry-point group name used to discover command extensions. **Errors and compatibility:** Follow the contract documentation linked in the description. Callers should handle the documented exception types and pin a compatible minor release. @@ -1677,7 +1677,7 @@ value = base_cli.COMMAND_ENTRY_POINT_GROUP **Kind:** constant/type **Signature:** (value is not callable) -**Behavior:** Type variable. +**Behavior:** Consumer-owned configuration type parameter carried by Context and App. **Errors and compatibility:** Follow the contract documentation linked in the description. Callers should handle the documented exception types and pin a compatible minor release. @@ -1692,7 +1692,7 @@ value = base_cli.ConfigT **Kind:** constant/type **Signature:** (value is not callable) -**Behavior:** Built-in immutable sequence. +**Behavior:** Supported base-cli extension entry-point groups in discovery order. **Errors and compatibility:** Follow the contract documentation linked in the description. Callers should handle the documented exception types and pin a compatible minor release. @@ -1707,7 +1707,7 @@ value = base_cli.ENTRY_POINT_GROUPS **Kind:** constant/type **Signature:** (value is not callable) -**Behavior:** str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str +**Behavior:** Extension API version negotiated through entry-point metadata. **Errors and compatibility:** Follow the contract documentation linked in the description. Callers should handle the documented exception types and pin a compatible minor release. @@ -1722,7 +1722,7 @@ value = base_cli.EXTENSION_API_VERSION **Kind:** constant/type **Signature:** (value is not callable) -**Behavior:** Own an isolated set of command record schemas. +**Behavior:** Process-local registry containing built-in and consumer record schemas. **Errors and compatibility:** Follow the contract documentation linked in the description. Callers should handle the documented exception types and pin a compatible minor release. @@ -1737,7 +1737,7 @@ value = base_cli.DEFAULT_SCHEMA_REGISTRY **Kind:** constant/type **Signature:** (value is not callable) -**Behavior:** str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str +**Behavior:** Metadata key used to mark lifecycle-owned Click parameters. **Errors and compatibility:** Follow the contract documentation linked in the description. Callers should handle the documented exception types and pin a compatible minor release. @@ -1752,7 +1752,7 @@ value = base_cli.LIFECYCLE_META_KEY **Kind:** constant/type **Signature:** (value is not callable) -**Behavior:** Describe the wire type and nullability of one command-record field. +**Behavior:** Schema field descriptor for a nullable string command-record value. **Errors and compatibility:** Follow the contract documentation linked in the description. Callers should handle the documented exception types and pin a compatible minor release. @@ -1767,7 +1767,7 @@ value = base_cli.NULLABLE_STRING **Kind:** constant/type **Signature:** (value is not callable) -**Behavior:** Describe the wire type and nullability of one command-record field. +**Behavior:** Schema field descriptor for a required string command-record value. **Errors and compatibility:** Follow the contract documentation linked in the description. Callers should handle the documented exception types and pin a compatible minor release. @@ -1782,7 +1782,7 @@ value = base_cli.STRING **Kind:** constant/type **Signature:** (value is not callable) -**Behavior:** int([x]) -> integer int(x, base=10) -> integer +**Behavior:** Version number for the stable JSON output, error, and log contracts. **Errors and compatibility:** Follow the contract documentation linked in the description. Callers should handle the documented exception types and pin a compatible minor release. @@ -1797,7 +1797,7 @@ value = base_cli.JSON_CONTRACT_VERSION **Kind:** constant/type **Signature:** (value is not callable) -**Behavior:** str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str +**Behavior:** Schema identifier emitted by machine-readable error envelopes. **Errors and compatibility:** Follow the contract documentation linked in the description. Callers should handle the documented exception types and pin a compatible minor release. @@ -1812,7 +1812,7 @@ value = base_cli.JSON_ERROR_SCHEMA **Kind:** constant/type **Signature:** (value is not callable) -**Behavior:** str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str +**Behavior:** Schema identifier emitted by structured JSON log records. **Errors and compatibility:** Follow the contract documentation linked in the description. Callers should handle the documented exception types and pin a compatible minor release. @@ -1827,7 +1827,7 @@ value = base_cli.JSON_LOG_SCHEMA **Kind:** constant/type **Signature:** (value is not callable) -**Behavior:** str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str +**Behavior:** Schema identifier emitted by successful JSON output envelopes. **Errors and compatibility:** Follow the contract documentation linked in the description. Callers should handle the documented exception types and pin a compatible minor release. @@ -1842,7 +1842,7 @@ value = base_cli.JSON_OUTPUT_SCHEMA **Kind:** constant/type **Signature:** (value is not callable) -**Behavior:** int([x]) -> integer int(x, base=10) -> integer +**Behavior:** Maximum retained message length for one structured JSON log record. **Errors and compatibility:** Follow the contract documentation linked in the description. Callers should handle the documented exception types and pin a compatible minor release. @@ -1857,7 +1857,7 @@ value = base_cli.MAX_JSON_LOG_MESSAGE_LENGTH **Kind:** constant/type **Signature:** (value is not callable) -**Behavior:** str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str +**Behavior:** Schema identifier emitted by versioned newline-delimited record output. **Errors and compatibility:** Follow the contract documentation linked in the description. Callers should handle the documented exception types and pin a compatible minor release. @@ -1872,7 +1872,7 @@ value = base_cli.NDJSON_SCHEMA **Kind:** constant/type **Signature:** (value is not callable) -**Behavior:** int([x]) -> integer int(x, base=10) -> integer +**Behavior:** Version number for the newline-delimited record contract. **Errors and compatibility:** Follow the contract documentation linked in the description. Callers should handle the documented exception types and pin a compatible minor release. @@ -1887,7 +1887,7 @@ value = base_cli.NDJSON_SCHEMA_VERSION **Kind:** constant/type **Signature:** (value is not callable) -**Behavior:** Built-in immutable sequence. +**Behavior:** Supported public output format names and their resolution order. **Errors and compatibility:** Follow the contract documentation linked in the description. Callers should handle the documented exception types and pin a compatible minor release. @@ -1902,7 +1902,7 @@ value = base_cli.PUBLIC_OUTPUT_FORMATS **Kind:** constant/type **Signature:** (value is not callable) -**Behavior:** A Mapping is a generic container for associating key/value pairs. +**Behavior:** Mapping shape accepted by the versioned command-record serializers. **Errors and compatibility:** Follow the contract documentation linked in the description. Callers should handle the documented exception types and pin a compatible minor release. @@ -1917,7 +1917,7 @@ value = base_cli.StructuredRecord **Kind:** constant/type **Signature:** (value is not callable) -**Behavior:** dict() -> new empty dictionary dict(mapping) -> new dictionary initialized from a mapping object's (key, value) pairs dict(iterable) -> new dictionary initialized as if via: d = {} for k, v in iterable: d[k] = v dict(**kwargs) -> new dictionary initialized with the name=value pairs in the keyword argument list. For example: dict(one=1, two=2) +**Behavior:** Mutable registry mapping consumer record names to FieldSpec definitions. **Errors and compatibility:** Follow the contract documentation linked in the description. Callers should handle the documented exception types and pin a compatible minor release. @@ -1932,7 +1932,7 @@ value = base_cli.RECORD_SCHEMAS **Kind:** constant/type **Signature:** (value is not callable) -**Behavior:** str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str +**Behavior:** Entry-point group name used to discover optional plugin extensions. **Errors and compatibility:** Follow the contract documentation linked in the description. Callers should handle the documented exception types and pin a compatible minor release. @@ -1947,7 +1947,7 @@ value = base_cli.PLUGIN_ENTRY_POINT_GROUP **Kind:** constant/type **Signature:** (value is not callable) -**Behavior:** str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str +**Behavior:** Entry-point group name used to discover profile extensions. **Errors and compatibility:** Follow the contract documentation linked in the description. Callers should handle the documented exception types and pin a compatible minor release. @@ -1962,7 +1962,7 @@ value = base_cli.PROFILE_ENTRY_POINT_GROUP **Kind:** constant/type **Signature:** (value is not callable) -**Behavior:** Type variable. +**Behavior:** Consumer-owned service-container type parameter carried by Context. **Errors and compatibility:** Follow the contract documentation linked in the description. Callers should handle the documented exception types and pin a compatible minor release. diff --git a/docs/typer-adapter.md b/docs/typer-adapter.md index b9bb8e3..a9dbe3c 100644 --- a/docs/typer-adapter.md +++ b/docs/typer-adapter.md @@ -67,8 +67,12 @@ command = adapter.attach(name="example") `adapter.command` exposes the cached Click command returned by the most recent `attach()` call. Reuse that object when the application needs to inspect or -pass the generated command to another integration boundary; calling -`attach()` again refreshes the cached command. +pass the generated command to another integration boundary. Repeating +`attach()` without new lifecycle arguments is idempotent and returns the same +Click object. A second call with lifecycle arguments after attachment is +rejected with +`TypeError`; construct a new adapter when the lifecycle configuration needs to +change rather than silently mutating an attached command tree. Typer is an optional extra and is imported lazily. Importing `base_cli` and using the Click integration never imports or requires Typer. 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/scripts/generate_api_reference.py b/scripts/generate_api_reference.py index 6fb8153..381051c 100644 --- a/scripts/generate_api_reference.py +++ b/scripts/generate_api_reference.py @@ -22,6 +22,35 @@ HEADING_PATTERN = re.compile(r"^### `([^`]+)`$", re.MULTILINE) +SYMBOL_DESCRIPTIONS = { + "__version__": "Installed base-cli distribution version exposed by the public facade.", + "BOOLEAN": "Schema field descriptor for a required boolean command-record value.", + "ApplicationStateT": "Consumer-owned application-state type parameter carried by Context.", + "CommandFilterNormalizer": "Callable type for canonicalizing command filter names.", + "COMMAND_ENTRY_POINT_GROUP": "Entry-point group name used to discover command extensions.", + "ConfigT": "Consumer-owned configuration type parameter carried by Context and App.", + "ENTRY_POINT_GROUPS": "Supported base-cli extension entry-point groups in discovery order.", + "EXTENSION_API_VERSION": "Extension API version negotiated through entry-point metadata.", + "DEFAULT_SCHEMA_REGISTRY": "Process-local registry containing built-in and consumer record schemas.", + "LIFECYCLE_META_KEY": "Metadata key used to mark lifecycle-owned Click parameters.", + "NULLABLE_STRING": "Schema field descriptor for a nullable string command-record value.", + "STRING": "Schema field descriptor for a required string command-record value.", + "JSON_CONTRACT_VERSION": "Version number for the stable JSON output, error, and log contracts.", + "JSON_ERROR_SCHEMA": "Schema identifier emitted by machine-readable error envelopes.", + "JSON_LOG_SCHEMA": "Schema identifier emitted by structured JSON log records.", + "JSON_OUTPUT_SCHEMA": "Schema identifier emitted by successful JSON output envelopes.", + "MAX_JSON_LOG_MESSAGE_LENGTH": "Maximum retained message length for one structured JSON log record.", + "NDJSON_SCHEMA": "Schema identifier emitted by versioned newline-delimited record output.", + "NDJSON_SCHEMA_VERSION": "Version number for the newline-delimited record contract.", + "PUBLIC_OUTPUT_FORMATS": "Supported public output format names and their resolution order.", + "StructuredRecord": "Mapping shape accepted by the versioned command-record serializers.", + "RECORD_SCHEMAS": "Mutable registry mapping consumer record names to FieldSpec definitions.", + "PLUGIN_ENTRY_POINT_GROUP": "Entry-point group name used to discover optional plugin extensions.", + "PROFILE_ENTRY_POINT_GROUP": "Entry-point group name used to discover profile extensions.", + "ServicesT": "Consumer-owned service-container type parameter carried by Context.", +} + + def _load_package() -> Any: sys.path.insert(0, str(PACKAGE_ROOT)) import base_cli @@ -53,7 +82,9 @@ def _signature(name: str, value: Any) -> str: return "(value is not callable)" -def _description(value: Any) -> str: +def _description(name: str, value: Any) -> str: + if name in SYMBOL_DESCRIPTIONS: + return SYMBOL_DESCRIPTIONS[name] doc = inspect.getdoc(value) if not doc: return "Public facade symbol; see the linked contract and source annotations for details." @@ -82,7 +113,7 @@ def render(base_cli: Any) -> str: f"**Kind:** {kind} ", f"**Signature:** {_signature(name, value)}", "", - f"**Behavior:** {_description(value)}", + f"**Behavior:** {_description(name, value)}", "", "**Errors and compatibility:** Follow the contract documentation linked in the description. " "Callers should handle the documented exception types and pin a compatible minor release.", @@ -138,6 +169,14 @@ def main() -> None: if documented != exported: print("generated API headings do not match base_cli.__all__", file=sys.stderr) raise SystemExit(1) + for name in exported: + value = getattr(base_cli, name) + if _kind(value) == "constant/type" and name not in SYMBOL_DESCRIPTIONS: + print(f"constant/type {name} has no symbol-owned description", file=sys.stderr) + raise SystemExit(1) + if "str(object='')" in current or "int([x])" in current or "dict(**kwargs)" in current: + print("generated API reference contains built-in constructor prose", file=sys.stderr) + raise SystemExit(1) print(f"Validated {len(exported)} public API symbols.") return REFERENCE.write_text(rendered, encoding="utf-8") diff --git a/tests/test_generated_api_reference.py b/tests/test_generated_api_reference.py new file mode 100644 index 0000000..cb065c9 --- /dev/null +++ b/tests/test_generated_api_reference.py @@ -0,0 +1,17 @@ +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def test_generated_reference_uses_framework_descriptions_for_runtime_constants() -> None: + reference = (ROOT / "docs" / "api-reference.md").read_text(encoding="utf-8") + assert "Installed base-cli distribution version exposed by the public facade." in reference + assert "Version number for the stable JSON output, error, and log contracts." in reference + assert "str(object='')" not in reference + assert "dict(**kwargs)" not in reference + + +def test_typer_guide_states_repeated_attachment_contract() -> None: + guide = (ROOT / "docs" / "typer-adapter.md").read_text(encoding="utf-8") + assert "without new lifecycle arguments is idempotent" in guide + assert "lifecycle arguments after attachment is" in guide diff --git a/tests/test_typer_adapter.py b/tests/test_typer_adapter.py index cd413a3..d78fc84 100644 --- a/tests/test_typer_adapter.py +++ b/tests/test_typer_adapter.py @@ -109,6 +109,20 @@ def status() -> None: self.assertIs(command, adapter.command) self.assertEqual(command.name, "cached-cli") + def test_repeated_attachment_is_idempotent_and_changed_arguments_are_rejected(self) -> None: + cli = self.typer.Typer() + + @cli.command() + def status() -> None: + self.typer.echo("ready") + + adapter = base_cli.TyperAdapter(cli) + first = adapter.attach(name="cached-cli", log_to_file=False) + second = adapter.attach() + self.assertIs(first, second) + with self.assertRaisesRegex(TypeError, "cannot be changed"): + adapter.attach(name="other-cli", log_to_file=False) + def test_adapter_uses_owner_dialect_for_version_option(self) -> None: cli = self.typer.Typer()