diff --git a/docs/STABILITY.md b/docs/STABILITY.md index 3a121d47..00b19ff8 100644 --- a/docs/STABILITY.md +++ b/docs/STABILITY.md @@ -157,13 +157,15 @@ source identity reject credential material rather than persist it. | Module | Level | Notes | |--------|-------|-------| | `tributo.exporting.service` — `BundleExportService` | `beta` | Primary export orchestration | -| `tributo.exporting.models` — all public models | `beta` | Configuration, artifact, canonical Bundle reference, and exact publication result models | +| `tributo.exporting.models` — core storage values | `stable` | AliasConfig, artifact/producer/reference/validation values, execution/failure results, BundleResult, BundleRef, ResolvedArtifact, and PublishedBundle | +| `tributo.exporting.models` — export configuration, Hook and plugin values | `beta` | Exporter orchestration and Hook payloads retain independent contracts | | `tributo.exporting.protocols` — all protocols | `beta` | Exporter/Validator/SourceProvider contracts | -| `tributo.exporting.manifest` — `ExportManifest` | `beta` | Bundle manifest (schema v1) | -| `tributo.exporting.bundle_reader` — `BundleReader` | `beta` | Repository-routed Bundle consumption with exact manifest-byte and artifact verification | +| `tributo.exporting.manifest` — schema-v1 models and registry | `stable` | Source info, typed signatures, execution records, ExportManifest, and schema reader registration | +| `tributo.exporting.manifest` — `ExportManifestV2` and `compute_bundle_digest` | `beta` | Explainability extension and compatibility digest helper | +| `tributo.exporting.bundle_reader` — `BundleReader` | `stable` | Repository-routed Bundle consumption with exact manifest-byte and artifact verification | | `tributo.exporting.planner` | `beta` | Export plan builder | | `tributo.exporting.executor` | `beta` | Export executor | -| `tributo.exporting.publisher` | `beta` | Bundle publisher | +| `tributo.exporting.publisher` | `stable` | Local/file/S3 core publication; the optional Explainability parameter remains Alpha | | `tributo.exporting.validators` | `beta` | Artifact validator runner | | `tributo.exporting.registries` | `beta` | Exporter/validator registries | | `tributo.exporting.options` | `beta` | Compatibility re-exports; schemas are owned by integration exporters | @@ -173,10 +175,33 @@ source identity reject credential material rather than persist it. | `tributo.exporting.hooks` | `beta` | Adapter and committed-artifact access contracts | | `tributo.exporting.dispatch` | `beta` | Inline Hook dispatch policy | | `tributo.exporting.capabilities` | `beta` | Exporter/Flavor-derived capability declarations | -| `tributo.exporting.repository` | `beta` | Bundle repository and alias store ports | +| `tributo.exporting.repository` | `stable` | Bundle repository/alias ports and their value objects; internal routing remains DeveloperAPI | | `tributo.exporting.runtime` | `beta` | Bundle model runtime and Flavor protocol | | `tributo.exporting.conftest` | `beta` | Public plugin conformance test kit | +The Stable storage model allowlist is `AliasConfig`, `ArtifactFile`, +`ProducerInfo`, `ArtifactRef`, `LogicalArtifact`, `ResolvedArtifact`, +`FailureInfo`, `NodeResult`, `ExportExecutionResult`, `BundleResult`, `BundleRef`, +`PublishedBundle`, and `ValidationResult`. The Stable manifest allowlist is +`ManifestSourceInfo`, `SignatureField`, `ManifestSignature`, +`ManifestExecutionNode`, `ManifestExecution`, `ExportManifest`, and +`ManifestSchemaRegistry`. `BundleCommitBusyError` and `AliasConflict` are Stable +storage errors. The high-level `export`, `ExportSpec`, and `load_bundle` +compatibility facade remain Beta. + +The Stable promise covers the core storage operation and fields. Passing Alpha +`ExplainabilityConfig` opts into its independent contract, and +`ExportManifestV2` remains Beta. `BundleResult.hook_receipts` keeps the Beta +`HookReceipt` payload; Hook delivery and orchestration do not become Stable. +Developer router/assembler injection remains an advanced extension with its +existing constructor parameters. No signature or extension payload is removed. + +`ResolvedArtifact` paths are valid inside their materialization context. +`PublishedBundle.local_dir_ephemeral` distinguishes transient S3 staging from +persistent local publication. Ephemeral paths belong to the staging owner; +BundleExportService guarantees their callback window. Consumers persist +`BundleRef` or `BundleResult.canonical_uri` instead of transient local paths. + ### Explainability (tributo.explainability.*) | Module | Level | Notes | @@ -288,7 +313,7 @@ source identity reject credential material rather than persist it. | Module | Level | Notes | |--------|-------|-------| -| `tributo._common.storage_profiles` | `beta` | Storage profile resolution | +| `tributo._common.storage_profiles` | `stable` | S3 profile values, named/default resolution, and credential-safe diagnostics | | `tributo._common.dependencies` | `beta` | Unified dependency probing layer | | `tributo._common` | `developer` | Internal shared package | | All other `tributo._common.*` | `developer` | Internal shared utilities | diff --git a/docs/reference/api/model-lifecycle.md b/docs/reference/api/model-lifecycle.md index 8cade0db..748a523b 100644 --- a/docs/reference/api/model-lifecycle.md +++ b/docs/reference/api/model-lifecycle.md @@ -233,6 +233,14 @@ documentation for every public stability tier. :no-members: ``` +```{autoclass} tributo.exporting.models.PublishedBundle +:no-members: +``` + +```{autoclass} tributo.exporting.models.ResolvedArtifact +:no-members: +``` + ```{autoclass} tributo.exporting.models.SupportRequest :no-members: ``` @@ -331,6 +339,10 @@ documentation for every public stability tier. ## `tributo.exporting.repository` +```{autoclass} tributo.exporting.repository.AliasUpdate +:no-members: +``` + ```{autoclass} tributo.exporting.repository.BundleAliasStore :no-members: ``` @@ -343,6 +355,14 @@ documentation for every public stability tier. :no-members: ``` +```{autoclass} tributo.exporting.repository.RepositoryCommit +:no-members: +``` + +```{autoclass} tributo.exporting.repository.StagedBundle +:no-members: +``` + ## `tributo.exporting.runtime` diff --git a/docs/reference/support-matrix.md b/docs/reference/support-matrix.md index c27aebac..ffc671c6 100644 --- a/docs/reference/support-matrix.md +++ b/docs/reference/support-matrix.md @@ -103,8 +103,9 @@ matrix are generated from the same Registry projection. | Capability | Status | Boundary | | --- | --- | --- | -| Local and `file://` bundle publication | Beta | Manifest and digest validation | -| S3 bundle publication | Beta | Manifest-last and alias compare-and-set | +| Local and `file://` bundle publication | Stable | Core Publisher/repository contracts, immutable identity, manifest and digest validation; Explainability and Hook extensions retain their own levels | +| S3 bundle publication | Stable | Core Publisher/repository contracts, manifest-last, leases, idempotency, and alias compare-and-set | +| Bundle manifest/artifact reading and integrity | Stable | BundleReader, schema-v1 fields, exact committed bytes, digest/identity checks, bounded materialization, and context lifetime; extension payload semantics are independent | | HDFS bundle publication | Not implemented | Storage backend extension | | Ray Data batch inference | Beta | Actor-based model reuse | | Batch output to local/S3 Parquet | Implemented | Database sinks are separate | diff --git a/src/tributo/_common/storage_profiles.py b/src/tributo/_common/storage_profiles.py index da109da3..b5062e02 100644 --- a/src/tributo/_common/storage_profiles.py +++ b/src/tributo/_common/storage_profiles.py @@ -15,10 +15,15 @@ from tributo.util.annotations import PublicAPI -@PublicAPI(stability="beta") +@PublicAPI(stability="stable") @dataclass(frozen=True) class StorageProfile: - """Resolved S3-compatible storage parameters.""" + """Resolved S3-compatible storage parameters. + + Credential fields are trusted runtime data and excluded from repr. + Profile resolution and the existing boto3 keyword projection are stable; + no credentials are persisted into bundle manifests by this value object. + """ endpoint: str | None = field(default=None, repr=False) region: str | None = None @@ -54,7 +59,7 @@ def to_boto3_kwargs(self) -> dict[str, Any]: return kwargs -@PublicAPI(stability="beta") +@PublicAPI(stability="stable") class StorageProfileResolver: """Resolves a profile *name* to connection parameters. @@ -64,8 +69,8 @@ class StorageProfileResolver: 2. ``boto3`` standard credential chain (env vars, ``~/.aws/credentials``, instance metadata). - Plugins may register additional resolvers via entry-point - ``tributo.storage_resolvers``. + Consumer constructors may receive a custom resolver. The built-in resolver + does not discover an additional resolver entry-point group. """ def __init__(self) -> None: diff --git a/src/tributo/exceptions.py b/src/tributo/exceptions.py index 69ceeeda..738dd227 100644 --- a/src/tributo/exceptions.py +++ b/src/tributo/exceptions.py @@ -151,7 +151,7 @@ def __init__(self, message: str, execution_result: Any = None) -> None: self.execution_result = execution_result -@PublicAPI(stability="beta") +@PublicAPI(stability="stable") class BundleCommitBusyError(TributoError): """A bundle commit is temporarily blocked by another active writer. @@ -161,7 +161,7 @@ class BundleCommitBusyError(TributoError): retryable = True -@PublicAPI(stability="beta") +@PublicAPI(stability="stable") class AliasConflict(TributoError): """Alias CAS update failed — concurrent modification detected.""" diff --git a/src/tributo/exporting/bundle_reader.py b/src/tributo/exporting/bundle_reader.py index b94265b7..6240d371 100644 --- a/src/tributo/exporting/bundle_reader.py +++ b/src/tributo/exporting/bundle_reader.py @@ -21,9 +21,16 @@ from tributo.util.annotations import PublicAPI -@PublicAPI(stability="beta") +@PublicAPI(stability="stable") class BundleReader: - """Read and verify bundles through storage-independent repository ports.""" + """Read and verify bundles through storage-independent repository ports. + + Stable consumption covers local/file/S3 locations, exact manifest bytes, + immutable references, resource limits, and verified artifact contexts. + Manifest v2 remains readable, but its explainability payload keeps its + existing experimental contract. Custom schema readers and developer router + injection do not turn their extensions into stable first-party capabilities. + """ def __init__( self, diff --git a/src/tributo/exporting/manifest.py b/src/tributo/exporting/manifest.py index b829d55d..21213c49 100644 --- a/src/tributo/exporting/manifest.py +++ b/src/tributo/exporting/manifest.py @@ -38,7 +38,7 @@ # ── Manifest models ──────────────────────────────────────────────────────────── -@PublicAPI(stability="beta") +@PublicAPI(stability="stable") class ManifestSourceInfo(BaseModel): """Stable source identification — no temp paths, credentials, or samples.""" @@ -52,7 +52,7 @@ class ManifestSourceInfo(BaseModel): task_type: str | None = None -@PublicAPI(stability="beta") +@PublicAPI(stability="stable") class SignatureField(BaseModel): """A single input/output field with its data type and shape. @@ -87,7 +87,7 @@ def _check_shape(cls, v: tuple[Any, ...]) -> tuple[Any, ...]: return v -@PublicAPI(stability="beta") +@PublicAPI(stability="stable") class ManifestSignature(BaseModel): """Input / output signature recorded in the manifest. @@ -176,7 +176,7 @@ def _check_dynamic_axes_consistency( ) -@PublicAPI(stability="beta") +@PublicAPI(stability="stable") class ManifestExecutionNode(BaseModel): """A single DAG node as recorded in the manifest. @@ -196,7 +196,7 @@ class ManifestExecutionNode(BaseModel): duration_ms: int = 0 -@PublicAPI(stability="beta") +@PublicAPI(stability="stable") class ManifestExecution(BaseModel): """Execution summary recorded in the manifest.""" @@ -206,7 +206,7 @@ class ManifestExecution(BaseModel): nodes: tuple[ManifestExecutionNode, ...] = () -@PublicAPI(stability="beta") +@PublicAPI(stability="stable") class ExportManifest(BaseModel): """Canonical manifest for a published model bundle (schema v1). @@ -291,7 +291,7 @@ def _rebuild_manifest_v2() -> None: # ── Schema registry ──────────────────────────────────────────────────────────── -@PublicAPI(stability="beta") +@PublicAPI(stability="stable") class ManifestSchemaRegistry: """Versioned manifest reader registry. diff --git a/src/tributo/exporting/models.py b/src/tributo/exporting/models.py index f6fe0946..65b0f593 100644 --- a/src/tributo/exporting/models.py +++ b/src/tributo/exporting/models.py @@ -25,7 +25,7 @@ ) from tributo.exporting.formats import validate_format_id -from tributo.util.annotations import DeveloperAPI, PublicAPI +from tributo.util.annotations import PublicAPI if TYPE_CHECKING: from tributo.exporting.manifest import ManifestSignature @@ -110,7 +110,7 @@ class HookReceipt(BaseModel): external_references: dict[str, str] = Field(default_factory=dict) -@PublicAPI(stability="beta") +@PublicAPI(stability="stable") class AliasConfig(BaseModel): """Optional stable alias for a published bundle.""" @@ -475,7 +475,7 @@ def to_manifest_signatures( # ── Artifact models ────────────────────────────────────────────────────────── -@PublicAPI(stability="beta") +@PublicAPI(stability="stable") class ArtifactFile(BaseModel): """A single file within a logical artifact.""" @@ -511,7 +511,7 @@ def _check_path(cls, v: str) -> str: return _validate_posix_relative(v, "relative_path") -@PublicAPI(stability="beta") +@PublicAPI(stability="stable") class ProducerInfo(BaseModel): """Exporter identity and version information.""" @@ -525,7 +525,7 @@ class ProducerInfo(BaseModel): effective_options_digest: str = Field(default="", min_length=0) -@PublicAPI(stability="beta") +@PublicAPI(stability="stable") class ArtifactRef(BaseModel): """A reference to a published artifact by node_id and tree digest.""" @@ -581,7 +581,7 @@ def _check_entrypoint(self) -> ArtifactDraft: return self -@PublicAPI(stability="beta") +@PublicAPI(stability="stable") class LogicalArtifact(BaseModel): """A verified, hash-materialized artifact ready for publishing.""" @@ -638,7 +638,7 @@ def compute_tree_digest(cls, files: tuple[ArtifactFile, ...]) -> str: return hashlib.sha256(payload).hexdigest() -@DeveloperAPI +@PublicAPI(stability="stable") class ResolvedArtifact: """In-process view of an artifact with a local root directory. @@ -661,7 +661,7 @@ def path_for(self, relative_path: str) -> Path: # ── Execution results ──────────────────────────────────────────────────────── -@PublicAPI(stability="beta") +@PublicAPI(stability="stable") class FailureInfo(BaseModel): """Structured, sanitised failure detail — no tracebacks or credentials.""" @@ -675,7 +675,7 @@ class FailureInfo(BaseModel): retryable: bool = False -@PublicAPI(stability="beta") +@PublicAPI(stability="stable") class NodeResult(BaseModel): """Result of a single DAG node execution.""" @@ -694,7 +694,7 @@ class NodeResult(BaseModel): duration_ms: int = Field(default=0, ge=0) -@PublicAPI(stability="beta") +@PublicAPI(stability="stable") class ExportExecutionResult(BaseModel): """Complete result of an export DAG execution (before publishing).""" @@ -716,9 +716,14 @@ def succeeded_artifacts(self) -> dict[str, ArtifactRef]: } -@PublicAPI(stability="beta") +@PublicAPI(stability="stable") class BundleResult(BaseModel): - """Immutable result returned to the caller after a successful publish.""" + """Immutable result returned to the caller after a successful publish. + + Core storage identity, artifact, role, and alias fields are stable. + ``hook_receipts`` retains the beta HookReceipt payload contract; storing a + receipt does not promise a hook's external delivery behavior. + """ model_config = ConfigDict(frozen=True, extra="forbid") @@ -740,7 +745,7 @@ class BundleResult(BaseModel): hook_receipts: tuple[HookReceipt, ...] = () -@PublicAPI(stability="beta") +@PublicAPI(stability="stable") class BundleRef(BaseModel): """Immutable reference to a committed bundle. @@ -756,11 +761,14 @@ class BundleRef(BaseModel): manifest_sha256: str -@DeveloperAPI +@PublicAPI(stability="stable") class PublishedBundle: """Transient handle returned within BundleExportService context. - ``local_bundle_dir`` is only valid during the callback window. + ``local_dir_ephemeral`` distinguishes caller-owned staging (S3) from a + persistent local publication directory. Ephemeral files are valid only while + the staging owner keeps them alive; BundleExportService provides that window + to its callback. Persist BundleRef or the result's canonical URI instead. """ def __init__( @@ -779,7 +787,7 @@ def __init__( # ── Validation models ──────────────────────────────────────────────────────── -@PublicAPI(stability="beta") +@PublicAPI(stability="stable") class ValidationResult(BaseModel): """Result of a single validator run.""" diff --git a/src/tributo/exporting/publisher.py b/src/tributo/exporting/publisher.py index 81b741cc..7979eef1 100644 --- a/src/tributo/exporting/publisher.py +++ b/src/tributo/exporting/publisher.py @@ -30,13 +30,19 @@ logger = logging.getLogger(__name__) -@PublicAPI(stability="beta") +@PublicAPI(stability="stable") class Publisher: """Compatibility facade over assembly and repository ports. Storage-specific atomicity, idempotency, and alias compare-and-set behavior belongs to repository adapters. This facade keeps the existing public method stable while composing those domain contracts. + + Stable publication covers local/file/S3 immutable bundles and alias results. + The ``explainability`` argument retains its alpha contract and produces the + beta Manifest v2 extension. Hook delivery and exporter orchestration are + separate contracts. Advanced assembler/router injection remains a developer + extension; existing constructor arguments are retained. """ def __init__( @@ -47,9 +53,9 @@ def __init__( assembler: BundleAssembler | None = None, repository_router: BundleRepositoryRouter | None = None, ) -> None: - # Compatibility-window argument. Manifest parsing moved to - # BundleReader; keep accepting the old Publisher constructor surface - # until the documented minor-version window closes. + # Compatibility argument. Manifest parsing moved to BundleReader. + # Keep this constructor surface in the current major version; + # removal must follow the Stable API policy and migration guide. del manifest_registry self._assembler = assembler or BundleAssembler() self._repository_router = repository_router or build_default_repository_router( diff --git a/src/tributo/exporting/repository.py b/src/tributo/exporting/repository.py index efd41037..e4ed8f2e 100644 --- a/src/tributo/exporting/repository.py +++ b/src/tributo/exporting/repository.py @@ -19,7 +19,7 @@ from tributo.util.annotations import DeveloperAPI, PublicAPI -@PublicAPI(stability="beta") +@PublicAPI(stability="stable") @dataclass(frozen=True) class ReaderResourceLimits: """Resource limits enforced before repository materialization.""" @@ -30,7 +30,7 @@ class ReaderResourceLimits: max_total_bytes: int = 50 * 1024 * 1024 * 1024 -@DeveloperAPI +@PublicAPI(stability="stable") @dataclass(frozen=True) class StagedBundle: """Storage-neutral immutable bundle assembled before commit.""" @@ -43,7 +43,7 @@ class StagedBundle: staging_root: Path -@DeveloperAPI +@PublicAPI(stability="stable") @dataclass(frozen=True) class RepositoryCommit: """Repository result after an immutable bundle commit.""" @@ -55,7 +55,7 @@ class RepositoryCommit: local_dir_ephemeral: bool -@DeveloperAPI +@PublicAPI(stability="stable") @dataclass(frozen=True) class AliasUpdate: """Storage-level alias update result.""" @@ -66,7 +66,7 @@ class AliasUpdate: @runtime_checkable -@PublicAPI(stability="beta") +@PublicAPI(stability="stable") class BundleRepository(Protocol): """Commit, read, and materialize immutable bundles for URI schemes. @@ -118,7 +118,7 @@ def materialize_artifact( @runtime_checkable -@PublicAPI(stability="beta") +@PublicAPI(stability="stable") class BundleAliasStore(Protocol): """Resolve and compare-and-set aliases in a storage namespace.""" diff --git a/tests/test_stability_inventory.py b/tests/test_stability_inventory.py index eab96132..8e96d8fc 100644 --- a/tests/test_stability_inventory.py +++ b/tests/test_stability_inventory.py @@ -114,10 +114,10 @@ "tributo.exporting.models": "beta", "tributo.exporting.protocols": "beta", "tributo.exporting.manifest": "beta", - "tributo.exporting.bundle_reader": "beta", + "tributo.exporting.bundle_reader": "stable", "tributo.exporting.planner": "beta", "tributo.exporting.executor": "beta", - "tributo.exporting.publisher": "beta", + "tributo.exporting.publisher": "stable", "tributo.exporting.validators": "beta", "tributo.exporting.registries": "beta", "tributo.exporting.options": "beta", @@ -127,7 +127,7 @@ "tributo.exporting.capabilities": "beta", "tributo.exporting.events": "beta", "tributo.exporting.dispatch": "beta", - "tributo.exporting.repository": "beta", + "tributo.exporting.repository": "stable", "tributo.exporting.runtime": "beta", "tributo.exporting.conftest": "beta", # Explainability — alpha @@ -200,7 +200,7 @@ "tributo.plugin": "beta", # Common — developer "tributo._common": "developer", - "tributo._common.storage_profiles": "beta", + "tributo._common.storage_profiles": "stable", "tributo._common.dependencies": "beta", } @@ -218,6 +218,26 @@ "tributo.data.source_config.ProviderSourceConfig": "stable", "tributo.data.source_config.source_projection": "stable", "tributo.data.source_config.apply_source_projection": "stable", + "tributo.exporting.models.AliasConfig": "stable", + "tributo.exporting.models.ArtifactFile": "stable", + "tributo.exporting.models.ProducerInfo": "stable", + "tributo.exporting.models.ArtifactRef": "stable", + "tributo.exporting.models.LogicalArtifact": "stable", + "tributo.exporting.models.ResolvedArtifact": "stable", + "tributo.exporting.models.FailureInfo": "stable", + "tributo.exporting.models.NodeResult": "stable", + "tributo.exporting.models.ExportExecutionResult": "stable", + "tributo.exporting.models.BundleResult": "stable", + "tributo.exporting.models.BundleRef": "stable", + "tributo.exporting.models.PublishedBundle": "stable", + "tributo.exporting.models.ValidationResult": "stable", + "tributo.exporting.manifest.ManifestSourceInfo": "stable", + "tributo.exporting.manifest.SignatureField": "stable", + "tributo.exporting.manifest.ManifestSignature": "stable", + "tributo.exporting.manifest.ManifestExecutionNode": "stable", + "tributo.exporting.manifest.ManifestExecution": "stable", + "tributo.exporting.manifest.ExportManifest": "stable", + "tributo.exporting.manifest.ManifestSchemaRegistry": "stable", "tributo.plugin.validate_distributed_algorithm_descriptor": "alpha", "tributo.config.AlgorithmExecutionConfig": "alpha", "tributo.config.AlgorithmInputConfig": "alpha", @@ -225,8 +245,8 @@ "tributo.config.LocalRayRuntimeConfig": "alpha", "tributo.exceptions.ResultMaterializationError": "alpha", "tributo.exceptions.BundleExportError": "beta", - "tributo.exceptions.BundleCommitBusyError": "beta", - "tributo.exceptions.AliasConflict": "beta", + "tributo.exceptions.BundleCommitBusyError": "stable", + "tributo.exceptions.AliasConflict": "stable", "tributo.exceptions.UnsupportedArtifactFormat": "beta", "tributo.exceptions.PostPublishCallbackError": "beta", "tributo.exceptions.PluginLoadIssue": "beta", diff --git a/tests/training/exporters/test_storage_contract_scope.py b/tests/training/exporters/test_storage_contract_scope.py new file mode 100644 index 00000000..93a07bba --- /dev/null +++ b/tests/training/exporters/test_storage_contract_scope.py @@ -0,0 +1,88 @@ +"""Stable storage metadata and explicit experimental extension boundaries.""" + +from __future__ import annotations + +import json + +import pytest + +from tributo._common.storage_profiles import StorageProfileResolver +from tributo.exceptions import ( + AliasConflict, + BundleCommitBusyError, + JobConfigurationError, +) +from tributo.explainability.contracts import ( + ExplainabilityConfig, + ExplainabilityDescriptor, +) +from tributo.exporting import export, load_bundle +from tributo.exporting.bundle_reader import BundleReader +from tributo.exporting.manifest import ExportManifest, ExportManifestV2 +from tributo.exporting.models import ( + BundleRef, + BundleResult, + HookReceipt, + PublishedBundle, +) +from tributo.exporting.publisher import Publisher +from tributo.exporting.repository import BundleAliasStore, BundleRepository +from tributo.util.annotations import get_stability + + +def test_core_storage_metadata_does_not_promote_extensions() -> None: + for obj in ( + BundleReader, + Publisher, + BundleRepository, + BundleAliasStore, + BundleRef, + BundleResult, + PublishedBundle, + ExportManifest, + BundleCommitBusyError, + AliasConflict, + ): + assert get_stability(obj) == "stable" + for beta_obj in (ExportManifestV2, HookReceipt, export, load_bundle): + assert get_stability(beta_obj) == "beta" + for alpha_obj in (ExplainabilityConfig, ExplainabilityDescriptor): + assert get_stability(alpha_obj) == "alpha" + + +def test_named_profile_preserves_credential_safety( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv( + "TRIBUTO_STORAGE_PROFILE_CONTRACT", + json.dumps( + { + "endpoint": "http://minio.example:9000", + "region": "us-east-1", + "path_style": True, + "access_key_id": "fixture-key", + "secret_access_key": "fixture-secret", + } + ), + ) + profile = StorageProfileResolver().resolve("contract") + assert profile.endpoint == "http://minio.example:9000" + assert profile.path_style + assert profile.access_key_id == "fixture-key" + assert profile.secret_access_key == "fixture-secret" + assert "fixture-key" not in repr(profile) + assert "fixture-secret" not in repr(profile) + assert "minio.example" not in repr(profile) + + +def test_invalid_profile_diagnostic_does_not_echo_input( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv( + "TRIBUTO_STORAGE_PROFILE_CONTRACT", + json.dumps({"unknown": "fixture-secret"}), + ) + with pytest.raises(JobConfigurationError) as error: + StorageProfileResolver().resolve("contract") + assert "invalid storage profile fields" in str(error.value) + assert "fixture-secret" not in str(error.value)