mirror of
https://github.com/Cog-Creators/Red-DiscordBot.git
synced 2025-11-21 18:27:59 -05:00
[Downloader] Add schema validation to info.json file processing (#3533)
* schema v1 * set hidden to True for shared libs * fix test data * add warning about invalid top-level structure * don't show full traceback for JSONDecodeError
This commit is contained in:
230
redbot/cogs/downloader/info_schemas.py
Normal file
230
redbot/cogs/downloader/info_schemas.py
Normal file
@@ -0,0 +1,230 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Callable, Dict, Tuple, Union, cast
|
||||
|
||||
from redbot import VersionInfo, version_info as red_version_info
|
||||
|
||||
from . import installable
|
||||
from .log import log
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .json_mixins import RepoJSONMixin
|
||||
|
||||
|
||||
__all__ = ("REPO_SCHEMA", "INSTALLABLE_SCHEMA", "update_mixin")
|
||||
|
||||
|
||||
class UseDefault:
|
||||
"""To be used as sentinel."""
|
||||
|
||||
|
||||
# sentinel value
|
||||
USE_DEFAULT = UseDefault()
|
||||
|
||||
|
||||
def ensure_tuple_of_str(
|
||||
info_file: Path, key_name: str, value: Union[Any, UseDefault]
|
||||
) -> Tuple[str, ...]:
|
||||
default: Tuple[str, ...] = ()
|
||||
if value is USE_DEFAULT:
|
||||
return default
|
||||
if not isinstance(value, list):
|
||||
log.warning(
|
||||
"Invalid value of '%s' key (expected list, got %s)"
|
||||
" in JSON information file at path: %s",
|
||||
key_name,
|
||||
type(value).__name__,
|
||||
info_file,
|
||||
)
|
||||
return default
|
||||
for item in value:
|
||||
if not isinstance(item, str):
|
||||
log.warning(
|
||||
"Invalid item in '%s' list (expected str, got %s)"
|
||||
" in JSON information file at path: %s",
|
||||
key_name,
|
||||
type(item).__name__,
|
||||
info_file,
|
||||
)
|
||||
return default
|
||||
return tuple(value)
|
||||
|
||||
|
||||
def ensure_str(info_file: Path, key_name: str, value: Union[Any, UseDefault]) -> str:
|
||||
default = ""
|
||||
if value is USE_DEFAULT:
|
||||
return default
|
||||
if not isinstance(value, str):
|
||||
log.warning(
|
||||
"Invalid value of '%s' key (expected str, got %s)"
|
||||
" in JSON information file at path: %s",
|
||||
key_name,
|
||||
type(value).__name__,
|
||||
info_file,
|
||||
)
|
||||
return default
|
||||
return value
|
||||
|
||||
|
||||
def ensure_red_version_info(
|
||||
info_file: Path, key_name: str, value: Union[Any, UseDefault]
|
||||
) -> VersionInfo:
|
||||
default = red_version_info
|
||||
if value is USE_DEFAULT:
|
||||
return default
|
||||
if not isinstance(value, str):
|
||||
log.warning(
|
||||
"Invalid value of '%s' key (expected str, got %s)"
|
||||
" in JSON information file at path: %s",
|
||||
key_name,
|
||||
type(value).__name__,
|
||||
info_file,
|
||||
)
|
||||
return default
|
||||
try:
|
||||
version_info = VersionInfo.from_str(value)
|
||||
except ValueError:
|
||||
log.warning(
|
||||
"Invalid value of '%s' key (given value isn't a valid version string)"
|
||||
" in JSON information file at path: %s",
|
||||
key_name,
|
||||
info_file,
|
||||
)
|
||||
return default
|
||||
return version_info
|
||||
|
||||
|
||||
def ensure_python_version_info(
|
||||
info_file: Path, key_name: str, value: Union[Any, UseDefault]
|
||||
) -> Tuple[int, int, int]:
|
||||
default = (3, 5, 1)
|
||||
if value is USE_DEFAULT:
|
||||
return default
|
||||
if not isinstance(value, list):
|
||||
log.warning(
|
||||
"Invalid value of '%s' key (expected list, got %s)"
|
||||
" in JSON information file at path: %s",
|
||||
key_name,
|
||||
type(value).__name__,
|
||||
info_file,
|
||||
)
|
||||
return default
|
||||
count = len(value)
|
||||
if count != 3:
|
||||
log.warning(
|
||||
"Invalid value of '%s' key (expected list with 3 items, got %s items)"
|
||||
" in JSON information file at path: %s",
|
||||
key_name,
|
||||
count,
|
||||
info_file,
|
||||
)
|
||||
return default
|
||||
for item in value:
|
||||
if not isinstance(item, int):
|
||||
log.warning(
|
||||
"Invalid item in '%s' list (expected int, got %s)"
|
||||
" in JSON information file at path: %s",
|
||||
key_name,
|
||||
type(item).__name__,
|
||||
info_file,
|
||||
)
|
||||
return default
|
||||
return cast(Tuple[int, int, int], tuple(value))
|
||||
|
||||
|
||||
def ensure_bool(
|
||||
info_file: Path, key_name: str, value: Union[Any, UseDefault], *, default: bool = False
|
||||
) -> bool:
|
||||
if value is USE_DEFAULT:
|
||||
return default
|
||||
if not isinstance(value, bool):
|
||||
log.warning(
|
||||
"Invalid value of '%s' key (expected bool, got %s)"
|
||||
" in JSON information file at path: %s",
|
||||
key_name,
|
||||
type(value).__name__,
|
||||
info_file,
|
||||
)
|
||||
return default
|
||||
return value
|
||||
|
||||
|
||||
def ensure_required_cogs_mapping(
|
||||
info_file: Path, key_name: str, value: Union[Any, UseDefault]
|
||||
) -> Dict[str, str]:
|
||||
default: Dict[str, str] = {}
|
||||
if value is USE_DEFAULT:
|
||||
return default
|
||||
if not isinstance(value, dict):
|
||||
log.warning(
|
||||
"Invalid value of '%s' key (expected dict, got %s)"
|
||||
" in JSON information file at path: %s",
|
||||
key_name,
|
||||
type(value).__name__,
|
||||
info_file,
|
||||
)
|
||||
return default
|
||||
# keys in json dicts are always strings
|
||||
for item in value.values():
|
||||
if not isinstance(item, str):
|
||||
log.warning(
|
||||
"Invalid item in '%s' dict (expected str, got %s)"
|
||||
" in JSON information file at path: %s",
|
||||
key_name,
|
||||
type(item).__name__,
|
||||
info_file,
|
||||
)
|
||||
return default
|
||||
return value
|
||||
|
||||
|
||||
def ensure_installable_type(
|
||||
info_file: Path, key_name: str, value: Union[Any, UseDefault]
|
||||
) -> installable.InstallableType:
|
||||
default = installable.InstallableType.COG
|
||||
if value is USE_DEFAULT:
|
||||
return default
|
||||
if not isinstance(value, str):
|
||||
log.warning(
|
||||
"Invalid value of '%s' key (expected str, got %s)"
|
||||
" in JSON information file at path: %s",
|
||||
key_name,
|
||||
type(value).__name__,
|
||||
info_file,
|
||||
)
|
||||
return default # NOTE: old behavior was to use InstallableType.UNKNOWN
|
||||
if value in ("", "COG"):
|
||||
return installable.InstallableType.COG
|
||||
if value == "SHARED_LIBRARY":
|
||||
return installable.InstallableType.SHARED_LIBRARY
|
||||
return installable.InstallableType.UNKNOWN
|
||||
|
||||
|
||||
EnsureCallable = Callable[[Path, str, Union[Any, UseDefault]], Any]
|
||||
SchemaType = Dict[str, EnsureCallable]
|
||||
|
||||
REPO_SCHEMA: SchemaType = {
|
||||
"author": ensure_tuple_of_str,
|
||||
"description": ensure_str,
|
||||
"install_msg": ensure_str,
|
||||
"short": ensure_str,
|
||||
}
|
||||
INSTALLABLE_SCHEMA: SchemaType = {
|
||||
"min_bot_version": ensure_red_version_info,
|
||||
"max_bot_version": ensure_red_version_info,
|
||||
"min_python_version": ensure_python_version_info,
|
||||
"hidden": ensure_bool,
|
||||
"disabled": ensure_bool,
|
||||
"required_cogs": ensure_required_cogs_mapping,
|
||||
"requirements": ensure_tuple_of_str,
|
||||
"tags": ensure_tuple_of_str,
|
||||
"type": ensure_installable_type,
|
||||
}
|
||||
|
||||
|
||||
def update_mixin(repo_or_installable: RepoJSONMixin, schema: SchemaType) -> None:
|
||||
info = repo_or_installable._info
|
||||
info_file = repo_or_installable._info_file
|
||||
for key, callback in schema.items():
|
||||
setattr(repo_or_installable, key, callback(info_file, key, info.get(key, USE_DEFAULT)))
|
||||
Reference in New Issue
Block a user