refactor(extensions): add Pydantic validation for extension configuration (#36767)

This commit is contained in:
Michael S. Molina
2025-12-19 13:33:10 -03:00
committed by GitHub
parent fb6f3fbb4d
commit 5920cb57ea
8 changed files with 517 additions and 116 deletions

View File

@@ -15,49 +15,190 @@
# specific language governing permissions and limitations
# under the License.
from typing import TypedDict
"""
Pydantic models for extension configuration and manifest validation.
Two distinct schemas:
- ExtensionConfig: Source configuration (extension.json) authored by developers
- Manifest: Built output (manifest.json) generated by the build tool
"""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field # noqa: I001
# =============================================================================
# Shared components
# =============================================================================
class ModuleFederationConfig(TypedDict):
exposes: dict[str, str]
filename: str
shared: dict[str, str]
remotes: dict[str, str]
class ModuleFederationConfig(BaseModel):
"""Configuration for Webpack Module Federation."""
exposes: list[str] = Field(
default_factory=list,
description="Modules exposed by this extension",
)
filename: str = Field(
default="remoteEntry.js",
description="Remote entry filename",
)
shared: dict[str, Any] = Field(
default_factory=dict,
description="Shared dependencies configuration",
)
remotes: dict[str, str] = Field(
default_factory=dict,
description="Remote module references",
)
class FrontendContributionConfig(TypedDict):
commands: dict[str, list[dict[str, str]]]
views: dict[str, list[dict[str, str]]]
menus: dict[str, list[dict[str, str]]]
class ContributionConfig(BaseModel):
"""Configuration for frontend UI contributions."""
commands: list[dict[str, Any]] = Field(
default_factory=list,
description="Command contributions",
)
views: dict[str, list[dict[str, Any]]] = Field(
default_factory=dict,
description="View contributions by location",
)
menus: dict[str, Any] = Field(
default_factory=dict,
description="Menu contributions",
)
class FrontendManifest(TypedDict):
contributions: FrontendContributionConfig
moduleFederation: ModuleFederationConfig
remoteEntry: str
class BaseExtension(BaseModel):
"""Base fields shared by ExtensionConfig and Manifest."""
id: str = Field(
...,
description="Unique extension identifier",
min_length=1,
)
name: str = Field(
...,
description="Human-readable extension name",
min_length=1,
)
version: str = Field(
default="0.0.0",
description="Semantic version string",
pattern=r"^\d+\.\d+\.\d+$",
)
license: str | None = Field(
default=None,
description="License identifier (e.g., 'Apache-2.0')",
)
description: str | None = Field(
default=None,
description="Extension description",
)
dependencies: list[str] = Field(
default_factory=list,
description="List of extension IDs this extension depends on",
)
permissions: list[str] = Field(
default_factory=list,
description="Permissions required by this extension",
)
class BackendManifest(TypedDict):
entryPoints: list[str]
# =============================================================================
# ExtensionConfig (extension.json)
# =============================================================================
class SharedBase(TypedDict, total=False):
id: str
name: str
dependencies: list[str]
description: str
version: str
frontend: FrontendManifest
permissions: list[str]
class ExtensionConfigFrontend(BaseModel):
"""Frontend section in extension.json."""
contributions: ContributionConfig = Field(
default_factory=ContributionConfig,
description="UI contribution points",
)
moduleFederation: ModuleFederationConfig = Field( # noqa: N815
default_factory=ModuleFederationConfig,
description="Module Federation configuration",
)
class Manifest(SharedBase, total=False):
backend: BackendManifest
class ExtensionConfigBackend(BaseModel):
"""Backend section in extension.json."""
entryPoints: list[str] = Field( # noqa: N815
default_factory=list,
description="Python module entry points to load",
)
files: list[str] = Field(
default_factory=list,
description="Glob patterns for backend Python files",
)
class BackendMetadata(BackendManifest):
files: list[str]
class ExtensionConfig(BaseExtension):
"""
Schema for extension.json (source configuration).
This file is authored by developers to define extension metadata.
"""
frontend: ExtensionConfigFrontend | None = Field(
default=None,
description="Frontend configuration",
)
backend: ExtensionConfigBackend | None = Field(
default=None,
description="Backend configuration",
)
class Metadata(SharedBase):
backend: BackendMetadata
# =============================================================================
# Manifest (manifest.json)
# =============================================================================
class ManifestFrontend(BaseModel):
"""Frontend section in manifest.json."""
contributions: ContributionConfig = Field(
default_factory=ContributionConfig,
description="UI contribution points",
)
moduleFederation: ModuleFederationConfig = Field( # noqa: N815
default_factory=ModuleFederationConfig,
description="Module Federation configuration",
)
remoteEntry: str = Field( # noqa: N815
...,
description="Path to the built remote entry file",
)
class ManifestBackend(BaseModel):
"""Backend section in manifest.json."""
entryPoints: list[str] = Field( # noqa: N815
default_factory=list,
description="Python module entry points to load",
)
class Manifest(BaseExtension):
"""
Schema for manifest.json (built output).
This file is generated by the build tool from extension.json.
"""
frontend: ManifestFrontend | None = Field(
default=None,
description="Frontend manifest",
)
backend: ManifestBackend | None = Field(
default=None,
description="Backend manifest",
)

View File

@@ -23,12 +23,17 @@ import sys
import time
import zipfile
from pathlib import Path
from typing import Any, Callable, cast
from typing import Any, Callable
import click
import semver
from jinja2 import Environment, FileSystemLoader
from superset_core.extensions.types import Manifest, Metadata
from superset_core.extensions.types import (
ExtensionConfig,
Manifest,
ManifestBackend,
ManifestFrontend,
)
from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer
@@ -125,40 +130,40 @@ def clean_dist_frontend(cwd: Path) -> None:
def build_manifest(cwd: Path, remote_entry: str | None) -> Manifest:
extension: Metadata = cast(Metadata, read_json(cwd / "extension.json"))
if not extension:
extension_data = read_json(cwd / "extension.json")
if not extension_data:
click.secho("❌ extension.json not found.", err=True, fg="red")
sys.exit(1)
manifest: Manifest = {
"id": extension["id"],
"name": extension["name"],
"version": extension["version"],
"permissions": extension["permissions"],
"dependencies": extension.get("dependencies", []),
}
if (
(frontend := extension.get("frontend"))
and (contributions := frontend.get("contributions"))
and (module_federation := frontend.get("moduleFederation"))
and remote_entry
):
manifest["frontend"] = {
"contributions": contributions,
"moduleFederation": module_federation,
"remoteEntry": remote_entry,
}
extension = ExtensionConfig.model_validate(extension_data)
if entry_points := extension.get("backend", {}).get("entryPoints"):
manifest["backend"] = {"entryPoints": entry_points}
frontend: ManifestFrontend | None = None
if extension.frontend and remote_entry:
frontend = ManifestFrontend(
contributions=extension.frontend.contributions,
moduleFederation=extension.frontend.moduleFederation,
remoteEntry=remote_entry,
)
return manifest
backend: ManifestBackend | None = None
if extension.backend and extension.backend.entryPoints:
backend = ManifestBackend(entryPoints=extension.backend.entryPoints)
return Manifest(
id=extension.id,
name=extension.name,
version=extension.version,
permissions=extension.permissions,
dependencies=extension.dependencies,
frontend=frontend,
backend=backend,
)
def write_manifest(cwd: Path, manifest: Manifest) -> None:
dist_dir = cwd / "dist"
(dist_dir / "manifest.json").write_text(
json.dumps(manifest, indent=2, sort_keys=True)
manifest.model_dump_json(indent=2, exclude_none=True, by_alias=True)
)
click.secho("✅ Manifest updated", fg="green")

View File

@@ -236,7 +236,7 @@ def test_build_manifest_creates_correct_manifest_structure(isolated_filesystem):
"permissions": ["read_data"],
"dependencies": ["some_dep"],
"frontend": {
"contributions": {"commands": ["test_command"]},
"contributions": {"commands": [{"id": "test_command", "title": "Test"}]},
"moduleFederation": {"exposes": ["./index"]},
},
"backend": {"entryPoints": ["test_extension.entrypoint"]},
@@ -247,23 +247,23 @@ def test_build_manifest_creates_correct_manifest_structure(isolated_filesystem):
manifest = build_manifest(isolated_filesystem, "remoteEntry.abc123.js")
# Verify manifest structure
manifest_dict = dict(manifest)
assert manifest_dict["id"] == "test_extension"
assert manifest_dict["name"] == "Test Extension"
assert manifest_dict["version"] == "1.0.0"
assert manifest_dict["permissions"] == ["read_data"]
assert manifest_dict["dependencies"] == ["some_dep"]
assert manifest.id == "test_extension"
assert manifest.name == "Test Extension"
assert manifest.version == "1.0.0"
assert manifest.permissions == ["read_data"]
assert manifest.dependencies == ["some_dep"]
# Verify frontend section
assert "frontend" in manifest
frontend = manifest["frontend"]
assert frontend["contributions"] == {"commands": ["test_command"]}
assert frontend["moduleFederation"] == {"exposes": ["./index"]}
assert frontend["remoteEntry"] == "remoteEntry.abc123.js"
assert manifest.frontend is not None
assert manifest.frontend.contributions.commands == [
{"id": "test_command", "title": "Test"}
]
assert manifest.frontend.moduleFederation.exposes == ["./index"]
assert manifest.frontend.remoteEntry == "remoteEntry.abc123.js"
# Verify backend section
assert "backend" in manifest
assert manifest["backend"]["entryPoints"] == ["test_extension.entrypoint"]
assert manifest.backend is not None
assert manifest.backend.entryPoints == ["test_extension.entrypoint"]
@pytest.mark.unit
@@ -280,14 +280,13 @@ def test_build_manifest_handles_minimal_extension(isolated_filesystem):
manifest = build_manifest(isolated_filesystem, None)
manifest_dict = dict(manifest)
assert manifest_dict["id"] == "minimal_extension"
assert manifest_dict["name"] == "Minimal Extension"
assert manifest_dict["version"] == "0.1.0"
assert manifest_dict["permissions"] == []
assert manifest_dict["dependencies"] == [] # Default empty list
assert "frontend" not in manifest
assert "backend" not in manifest
assert manifest.id == "minimal_extension"
assert manifest.name == "Minimal Extension"
assert manifest.version == "0.1.0"
assert manifest.permissions == []
assert manifest.dependencies == [] # Default empty list
assert manifest.frontend is None
assert manifest.backend is None
@pytest.mark.unit

View File

@@ -23,6 +23,7 @@ import time
from unittest.mock import Mock, patch
import pytest
from superset_core.extensions.types import Manifest
from superset_extensions_cli.cli import app, FrontendChangeHandler
@@ -48,7 +49,7 @@ def test_dev_command_starts_watchers(
"""Test dev command starts file watchers."""
# Setup mocks
mock_rebuild_frontend.return_value = "remoteEntry.abc123.js"
mock_build_manifest.return_value = {"name": "test", "version": "1.0.0"}
mock_build_manifest.return_value = Manifest(id="test", name="test", version="1.0.0")
mock_observer = Mock()
mock_observer_class.return_value = mock_observer
@@ -100,7 +101,7 @@ def test_dev_command_initial_build(
"""Test dev command performs initial build setup."""
# Setup mocks
mock_rebuild_frontend.return_value = "remoteEntry.abc123.js"
mock_build_manifest.return_value = {"name": "test", "version": "1.0.0"}
mock_build_manifest.return_value = Manifest(id="test", name="test", version="1.0.0")
extension_setup_for_dev(isolated_filesystem)
@@ -188,11 +189,12 @@ def test_frontend_watcher_function_coverage(isolated_filesystem):
dist_dir = isolated_filesystem / "dist"
dist_dir.mkdir()
mock_manifest = Manifest(id="test", name="test", version="1.0.0")
with patch("superset_extensions_cli.cli.rebuild_frontend") as mock_rebuild:
with patch("superset_extensions_cli.cli.build_manifest") as mock_build:
with patch("superset_extensions_cli.cli.write_manifest") as mock_write:
mock_rebuild.return_value = "remoteEntry.abc123.js"
mock_build.return_value = {"name": "test", "version": "1.0.0"}
mock_build.return_value = mock_manifest
# Simulate frontend watcher function logic
frontend_dir = isolated_filesystem / "frontend"
@@ -209,9 +211,7 @@ def test_frontend_watcher_function_coverage(isolated_filesystem):
mock_build.assert_called_once_with(
isolated_filesystem, "remoteEntry.abc123.js"
)
mock_write.assert_called_once_with(
isolated_filesystem, {"name": "test", "version": "1.0.0"}
)
mock_write.assert_called_once_with(isolated_filesystem, mock_manifest)
@pytest.mark.unit

View File

@@ -61,9 +61,8 @@ def discover_and_load_extensions(
with ZipFile(supx_file, "r") as zip_file:
files = get_bundle_files_from_zip(zip_file)
extension = get_loaded_extension(files)
extension_id = extension.manifest["id"]
logger.info(
"Loaded extension '%s' from %s", extension_id, supx_file
"Loaded extension '%s' from %s", extension.id, supx_file
)
yield extension
except Exception as e:

View File

@@ -26,6 +26,7 @@ from typing import Any, Generator, Iterable, Tuple
from zipfile import ZipFile
from flask import current_app
from pydantic import ValidationError
from superset_core.extensions.types import Manifest
from superset.extensions.types import BundleFile, LoadedExtension
@@ -121,7 +122,7 @@ def get_bundle_files_from_path(base_path: str) -> Generator[BundleFile, None, No
def get_loaded_extension(files: Iterable[BundleFile]) -> LoadedExtension:
manifest: Manifest = {}
manifest: Manifest | None = None
frontend: dict[str, bytes] = {}
backend: dict[str, bytes] = {}
@@ -131,13 +132,12 @@ def get_loaded_extension(files: Iterable[BundleFile]) -> LoadedExtension:
if filename == "manifest.json":
try:
manifest = json.loads(content)
if "id" not in manifest:
raise Exception("Missing 'id' in manifest")
if "name" not in manifest:
raise Exception("Missing 'name' in manifest")
manifest_data = json.loads(content)
manifest = Manifest.model_validate(manifest_data)
except ValidationError as e:
raise Exception(f"Invalid manifest.json: {e}") from e
except Exception as e:
raise Exception("Invalid manifest.json: %s" % e) from e
raise Exception(f"Failed to parse manifest.json: {e}") from e
elif (match := FRONTEND_REGEX.match(filename)) is not None:
frontend[match.group(1)] = content
@@ -146,40 +146,39 @@ def get_loaded_extension(files: Iterable[BundleFile]) -> LoadedExtension:
backend[match.group(1)] = content
else:
raise Exception("Unexpected file in bundle: %s" % filename)
raise Exception(f"Unexpected file in bundle: {filename}")
if manifest is None:
raise Exception("Missing manifest.json in extension bundle")
id_ = manifest["id"]
name = manifest["name"]
version = manifest["version"]
return LoadedExtension(
id=id_,
name=name,
id=manifest.id,
name=manifest.name,
manifest=manifest,
frontend=frontend,
backend=backend,
version=version,
version=manifest.version,
)
def build_extension_data(extension: LoadedExtension) -> dict[str, Any]:
manifest: Manifest = extension.manifest
manifest = extension.manifest
extension_data: dict[str, Any] = {
"id": manifest["id"],
"id": manifest.id,
"name": extension.name,
"version": extension.version,
"description": manifest.get("description", ""),
"dependencies": manifest.get("dependencies", []),
"extensionDependencies": manifest.get("extensionDependencies", []),
"description": manifest.description or "",
"dependencies": manifest.dependencies,
}
if frontend := manifest.get("frontend"):
module_federation = frontend.get("moduleFederation", {})
remote_entry = frontend["remoteEntry"]
if manifest.frontend:
frontend = manifest.frontend
module_federation = frontend.moduleFederation
remote_entry_url = f"/api/v1/extensions/{manifest.id}/{frontend.remoteEntry}"
extension_data.update(
{
"remoteEntry": "/api/v1/extensions/%s/%s"
% (manifest["id"], remote_entry), # noqa: E501
"exposedModules": module_federation.get("exposes", []),
"contributions": frontend.get("contributions", {}),
"remoteEntry": remote_entry_url,
"exposedModules": module_federation.exposes,
"contributions": frontend.contributions.model_dump(),
}
)
return extension_data
@@ -192,7 +191,7 @@ def get_extensions() -> dict[str, LoadedExtension]:
for path in current_app.config["LOCAL_EXTENSIONS"]:
files = get_bundle_files_from_path(path)
extension = get_loaded_extension(files)
extension_id = extension.manifest["id"]
extension_id = extension.manifest.id
extensions[extension_id] = extension
logger.info(
"Loading extension %s (ID: %s) from local filesystem",
@@ -205,7 +204,7 @@ def get_extensions() -> dict[str, LoadedExtension]:
from superset.extensions.discovery import discover_and_load_extensions
for extension in discover_and_load_extensions(extensions_path):
extension_id = extension.manifest["id"]
extension_id = extension.manifest.id
if extension_id not in extensions: # Don't override LOCAL_EXTENSIONS
extensions[extension_id] = extension
logger.info(

View File

@@ -564,9 +564,9 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
if backend_files := extension.backend:
install_in_memory_importer(backend_files)
backend = extension.manifest.get("backend")
backend = extension.manifest.backend
if backend and (entrypoints := backend.get("entryPoints")):
if backend and (entrypoints := backend.entryPoints):
for entrypoint in entrypoints:
try:
eager_import(entrypoint)

View File

@@ -0,0 +1,258 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Tests for extension configuration and manifest Pydantic models."""
import pytest
from pydantic import ValidationError
from superset_core.extensions.types import (
ContributionConfig,
ExtensionConfig,
ExtensionConfigBackend,
ExtensionConfigFrontend,
Manifest,
ManifestBackend,
ModuleFederationConfig,
)
# =============================================================================
# ExtensionConfig (extension.json) tests
# =============================================================================
def test_extension_config_minimal():
"""Test ExtensionConfig with minimal required fields."""
config = ExtensionConfig.model_validate(
{
"id": "my-extension",
"name": "My Extension",
}
)
assert config.id == "my-extension"
assert config.name == "My Extension"
assert config.version == "0.0.0"
assert config.dependencies == []
assert config.permissions == []
assert config.frontend is None
assert config.backend is None
def test_extension_config_full():
"""Test ExtensionConfig with all fields populated."""
config = ExtensionConfig.model_validate(
{
"id": "query_insights",
"name": "Query Insights",
"version": "1.0.0",
"license": "Apache-2.0",
"description": "A query insights extension",
"dependencies": ["other-extension"],
"permissions": ["can_read", "can_view"],
"frontend": {
"contributions": {
"views": {
"sqllab.panels": [
{"id": "query_insights.main", "name": "Query Insights"}
]
}
},
"moduleFederation": {"exposes": ["./index"]},
},
"backend": {
"entryPoints": ["query_insights.entrypoint"],
"files": ["backend/src/query_insights/**/*.py"],
},
}
)
assert config.id == "query_insights"
assert config.name == "Query Insights"
assert config.version == "1.0.0"
assert config.license == "Apache-2.0"
assert config.description == "A query insights extension"
assert config.dependencies == ["other-extension"]
assert config.permissions == ["can_read", "can_view"]
assert config.frontend is not None
assert config.frontend.moduleFederation.exposes == ["./index"]
assert config.backend is not None
assert config.backend.entryPoints == ["query_insights.entrypoint"]
assert config.backend.files == ["backend/src/query_insights/**/*.py"]
def test_extension_config_missing_id():
"""Test ExtensionConfig raises error when id is missing."""
with pytest.raises(ValidationError) as exc_info:
ExtensionConfig.model_validate({"name": "My Extension"})
assert "id" in str(exc_info.value)
def test_extension_config_missing_name():
"""Test ExtensionConfig raises error when name is missing."""
with pytest.raises(ValidationError) as exc_info:
ExtensionConfig.model_validate({"id": "my-extension"})
assert "name" in str(exc_info.value)
def test_extension_config_empty_id():
"""Test ExtensionConfig raises error when id is empty."""
with pytest.raises(ValidationError) as exc_info:
ExtensionConfig.model_validate({"id": "", "name": "My Extension"})
assert "id" in str(exc_info.value)
def test_extension_config_invalid_version():
"""Test ExtensionConfig raises error for invalid version format."""
with pytest.raises(ValidationError) as exc_info:
ExtensionConfig.model_validate(
{"id": "my-extension", "name": "My Extension", "version": "invalid"}
)
assert "version" in str(exc_info.value)
def test_extension_config_valid_versions():
"""Test ExtensionConfig accepts valid semantic versions (major.minor.patch only)."""
for version in ["1.0.0", "0.1.0", "10.20.30"]:
config = ExtensionConfig.model_validate(
{"id": "my-extension", "name": "My Extension", "version": version}
)
assert config.version == version
def test_extension_config_prerelease_version_rejected():
"""Test ExtensionConfig rejects prerelease versions."""
with pytest.raises(ValidationError) as exc_info:
ExtensionConfig.model_validate(
{"id": "my-extension", "name": "My Extension", "version": "1.0.0-beta"}
)
assert "version" in str(exc_info.value)
# =============================================================================
# Manifest (manifest.json) tests
# =============================================================================
def test_manifest_minimal():
"""Test Manifest with minimal required fields."""
manifest = Manifest.model_validate(
{
"id": "my-extension",
"name": "My Extension",
}
)
assert manifest.id == "my-extension"
assert manifest.name == "My Extension"
assert manifest.frontend is None
assert manifest.backend is None
def test_manifest_with_frontend():
"""Test Manifest with frontend section requires remoteEntry."""
manifest = Manifest.model_validate(
{
"id": "my-extension",
"name": "My Extension",
"frontend": {
"remoteEntry": "remoteEntry.abc123.js",
"contributions": {},
"moduleFederation": {"exposes": ["./index"]},
},
}
)
assert manifest.frontend is not None
assert manifest.frontend.remoteEntry == "remoteEntry.abc123.js"
assert manifest.frontend.moduleFederation.exposes == ["./index"]
def test_manifest_frontend_missing_remote_entry():
"""Test Manifest raises error when frontend is missing remoteEntry."""
with pytest.raises(ValidationError) as exc_info:
Manifest.model_validate(
{
"id": "my-extension",
"name": "My Extension",
"frontend": {"contributions": {}, "moduleFederation": {}},
}
)
assert "remoteEntry" in str(exc_info.value)
def test_manifest_with_backend():
"""Test Manifest with backend section."""
manifest = Manifest.model_validate(
{
"id": "my-extension",
"name": "My Extension",
"backend": {"entryPoints": ["my_extension.entrypoint"]},
}
)
assert manifest.backend is not None
assert manifest.backend.entryPoints == ["my_extension.entrypoint"]
def test_manifest_backend_no_files_field():
"""Test ManifestBackend does not have files field (only in ExtensionConfig)."""
manifest = Manifest.model_validate(
{
"id": "my-extension",
"name": "My Extension",
"backend": {"entryPoints": ["my_extension.entrypoint"]},
}
)
# ManifestBackend should not have a 'files' field
assert not hasattr(manifest.backend, "files")
# =============================================================================
# Shared component tests
# =============================================================================
def test_module_federation_config_defaults():
"""Test ModuleFederationConfig has correct defaults."""
config = ModuleFederationConfig.model_validate({})
assert config.exposes == []
assert config.filename == "remoteEntry.js"
assert config.shared == {}
assert config.remotes == {}
def test_contribution_config_defaults():
"""Test ContributionConfig has correct defaults."""
config = ContributionConfig.model_validate({})
assert config.commands == []
assert config.views == {}
assert config.menus == {}
def test_extension_config_frontend_defaults():
"""Test ExtensionConfigFrontend has correct defaults."""
frontend = ExtensionConfigFrontend.model_validate({})
assert frontend.contributions.commands == []
assert frontend.moduleFederation.exposes == []
def test_extension_config_backend_defaults():
"""Test ExtensionConfigBackend has correct defaults."""
backend = ExtensionConfigBackend.model_validate({})
assert backend.entryPoints == []
assert backend.files == []
def test_manifest_backend_defaults():
"""Test ManifestBackend has correct defaults."""
backend = ManifestBackend.model_validate({})
assert backend.entryPoints == []