mirror of
https://github.com/Cog-Creators/Red-DiscordBot.git
synced 2026-05-22 23:14:50 -04:00
Add Environment abstraction to internal Downloader API (#6710)
This commit is contained in:
@@ -4,7 +4,8 @@ import re
|
|||||||
from typing import Tuple, Iterable, Collection, Optional, Set, List
|
from typing import Tuple, Iterable, Collection, Optional, Set, List
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
from redbot.core import _downloader, commands, version_info as red_version_info
|
from redbot import __version__
|
||||||
|
from redbot.core import _downloader, commands
|
||||||
from redbot.core._downloader import errors
|
from redbot.core._downloader import errors
|
||||||
from redbot.core._downloader.installable import InstalledModule
|
from redbot.core._downloader.installable import InstalledModule
|
||||||
from redbot.core.bot import Red
|
from redbot.core.bot import Red
|
||||||
@@ -956,9 +957,7 @@ class Downloader(commands.Cog):
|
|||||||
) + humanize_list(
|
) + humanize_list(
|
||||||
[
|
[
|
||||||
inline(cog.name)
|
inline(cog.name)
|
||||||
+ _(" (Minimum: {min_version})").format(
|
+ _(" (Minimum: {min_version})").format(min_version=cog.min_python_version)
|
||||||
min_version=".".join([str(n) for n in cog.min_python_version])
|
|
||||||
)
|
|
||||||
for cog in update_check_result.incompatible_python_version
|
for cog in update_check_result.incompatible_python_version
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -973,7 +972,7 @@ class Downloader(commands.Cog):
|
|||||||
"\nThis cog requires different Red version than you currently "
|
"\nThis cog requires different Red version than you currently "
|
||||||
"have ({current_version}): "
|
"have ({current_version}): "
|
||||||
)
|
)
|
||||||
).format(current_version=red_version_info) + humanize_list(
|
).format(current_version=__version__) + humanize_list(
|
||||||
[
|
[
|
||||||
inline(cog.name)
|
inline(cog.name)
|
||||||
+ _(" (Minimum: {min_version}").format(min_version=cog.min_bot_version)
|
+ _(" (Minimum: {min_version}").format(min_version=cog.min_bot_version)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
import dataclasses
|
import dataclasses
|
||||||
|
import functools
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
@@ -35,7 +36,11 @@ from typing import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
from redbot.core import commands, Config, version_info as red_version_info
|
from packaging.version import Version
|
||||||
|
from typing_extensions import Self
|
||||||
|
|
||||||
|
from redbot import __version__
|
||||||
|
from redbot.core import commands, Config
|
||||||
from redbot.core._cog_manager import CogManager
|
from redbot.core._cog_manager import CogManager
|
||||||
from redbot.core.data_manager import cog_data_path
|
from redbot.core.data_manager import cog_data_path
|
||||||
from redbot.core.utils._internal_utils import detailed_progress
|
from redbot.core.utils._internal_utils import detailed_progress
|
||||||
@@ -554,8 +559,34 @@ async def reinstall_requirements() -> Tuple[Tuple[str, ...], Tuple[Installable,
|
|||||||
return failed_reqs, tuple(all_failed_libs)
|
return failed_reqs, tuple(all_failed_libs)
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: make kw_only
|
||||||
|
@dataclasses.dataclass(frozen=True)
|
||||||
|
class Environment:
|
||||||
|
"""
|
||||||
|
Environment to check version bounds against.
|
||||||
|
|
||||||
|
Usually the metadata from current environment is used but this allows for alternative uses
|
||||||
|
such as checking, if the cog will work on a Red version you want to update to.
|
||||||
|
"""
|
||||||
|
|
||||||
|
red_version: Version
|
||||||
|
python_version: Version
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@functools.lru_cache(maxsize=None)
|
||||||
|
def current(cls) -> Self:
|
||||||
|
return cls(
|
||||||
|
red_version=Version(__version__),
|
||||||
|
python_version=Version(".".join(map(str, sys.version_info[:3]))),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def install_cogs(
|
async def install_cogs(
|
||||||
repo: Repo, rev: Optional[str], cog_names: Iterable[str]
|
repo: Repo,
|
||||||
|
rev: Optional[str],
|
||||||
|
cog_names: Iterable[str],
|
||||||
|
*,
|
||||||
|
env: Environment = Environment.current(),
|
||||||
) -> CogInstallResult:
|
) -> CogInstallResult:
|
||||||
commit = None
|
commit = None
|
||||||
|
|
||||||
@@ -588,12 +619,12 @@ async def install_cogs(
|
|||||||
already_installed.append(cog)
|
already_installed.append(cog)
|
||||||
elif discord.utils.get(_installed_cogs, name=cog.name):
|
elif discord.utils.get(_installed_cogs, name=cog.name):
|
||||||
name_already_used.append(cog)
|
name_already_used.append(cog)
|
||||||
elif cog.min_python_version > sys.version_info:
|
elif cog.min_python_version > env.python_version:
|
||||||
incompatible_python_version.append(cog)
|
incompatible_python_version.append(cog)
|
||||||
elif cog.min_bot_version > red_version_info or (
|
elif cog.min_bot_version > env.red_version or (
|
||||||
# max version should be ignored when it's lower than min version
|
# max version should be ignored when it's lower than min version
|
||||||
cog.min_bot_version <= cog.max_bot_version
|
cog.min_bot_version <= cog.max_bot_version
|
||||||
and cog.max_bot_version < red_version_info
|
and cog.max_bot_version < env.red_version
|
||||||
):
|
):
|
||||||
incompatible_bot_version.append(cog)
|
incompatible_bot_version.append(cog)
|
||||||
else:
|
else:
|
||||||
@@ -653,6 +684,7 @@ async def check_cog_updates(
|
|||||||
repos: Optional[Iterable[Repo]] = None,
|
repos: Optional[Iterable[Repo]] = None,
|
||||||
cogs: Optional[Iterable[InstalledModule]] = None,
|
cogs: Optional[Iterable[InstalledModule]] = None,
|
||||||
update_repos: bool = True,
|
update_repos: bool = True,
|
||||||
|
env: Environment = Environment.current(),
|
||||||
) -> CogUpdateCheckResult:
|
) -> CogUpdateCheckResult:
|
||||||
cogs_to_check, failed_repos = await _get_cogs_to_check(
|
cogs_to_check, failed_repos = await _get_cogs_to_check(
|
||||||
repos=repos, cogs=cogs, update_repos=update_repos
|
repos=repos, cogs=cogs, update_repos=update_repos
|
||||||
@@ -663,12 +695,12 @@ async def check_cog_updates(
|
|||||||
incompatible_python_version: List[Installable] = []
|
incompatible_python_version: List[Installable] = []
|
||||||
incompatible_bot_version: List[Installable] = []
|
incompatible_bot_version: List[Installable] = []
|
||||||
for cog in outdated_cogs:
|
for cog in outdated_cogs:
|
||||||
if cog.min_python_version > sys.version_info:
|
if cog.min_python_version > env.python_version:
|
||||||
incompatible_python_version.append(cog)
|
incompatible_python_version.append(cog)
|
||||||
elif cog.min_bot_version > red_version_info or (
|
elif cog.min_bot_version > env.red_version or (
|
||||||
# max version should be ignored when it's lower than min version
|
# max version should be ignored when it's lower than min version
|
||||||
cog.min_bot_version <= cog.max_bot_version
|
cog.min_bot_version <= cog.max_bot_version
|
||||||
and cog.max_bot_version < red_version_info
|
and cog.max_bot_version < env.red_version
|
||||||
):
|
):
|
||||||
incompatible_bot_version.append(cog)
|
incompatible_bot_version.append(cog)
|
||||||
else:
|
else:
|
||||||
@@ -686,19 +718,26 @@ async def check_cog_updates(
|
|||||||
|
|
||||||
# update given cogs or all cogs
|
# update given cogs or all cogs
|
||||||
async def update_cogs(
|
async def update_cogs(
|
||||||
*, cogs: Optional[List[InstalledModule]] = None, repos: Optional[List[Repo]] = None
|
*,
|
||||||
|
cogs: Optional[List[InstalledModule]] = None,
|
||||||
|
repos: Optional[List[Repo]] = None,
|
||||||
|
env: Environment = Environment.current(),
|
||||||
) -> CogUpdateResult:
|
) -> CogUpdateResult:
|
||||||
if cogs is not None and repos is not None:
|
if cogs is not None and repos is not None:
|
||||||
raise ValueError("You can specify cogs or repos argument, not both")
|
raise ValueError("You can specify cogs or repos argument, not both")
|
||||||
|
|
||||||
cogs_to_check, failed_repos = await _get_cogs_to_check(repos=repos, cogs=cogs)
|
cogs_to_check, failed_repos = await _get_cogs_to_check(repos=repos, cogs=cogs)
|
||||||
return await _update_cogs(cogs_to_check, failed_repos=failed_repos)
|
return await _update_cogs(cogs_to_check, failed_repos=failed_repos, env=env)
|
||||||
|
|
||||||
|
|
||||||
# update given cogs or all cogs from the specified repo
|
# update given cogs or all cogs from the specified repo
|
||||||
# using the specified revision (or latest if not specified)
|
# using the specified revision (or latest if not specified)
|
||||||
async def update_repo_cogs(
|
async def update_repo_cogs(
|
||||||
repo: Repo, cogs: Optional[List[InstalledModule]] = None, *, rev: Optional[str] = None
|
repo: Repo,
|
||||||
|
cogs: Optional[List[InstalledModule]] = None,
|
||||||
|
*,
|
||||||
|
rev: Optional[str] = None,
|
||||||
|
env: Environment = Environment.current(),
|
||||||
) -> CogUpdateResult:
|
) -> CogUpdateResult:
|
||||||
try:
|
try:
|
||||||
await repo.update()
|
await repo.update()
|
||||||
@@ -712,11 +751,14 @@ async def update_repo_cogs(
|
|||||||
commit = await repo.get_full_sha1(rev)
|
commit = await repo.get_full_sha1(rev)
|
||||||
async with repo.checkout(commit, exit_to_rev=repo.branch):
|
async with repo.checkout(commit, exit_to_rev=repo.branch):
|
||||||
cogs_to_check, __ = await _get_cogs_to_check(repos=[repo], cogs=cogs, update_repos=False)
|
cogs_to_check, __ = await _get_cogs_to_check(repos=[repo], cogs=cogs, update_repos=False)
|
||||||
return await _update_cogs(cogs_to_check, failed_repos=())
|
return await _update_cogs(cogs_to_check, failed_repos=(), env=env)
|
||||||
|
|
||||||
|
|
||||||
async def _update_cogs(
|
async def _update_cogs(
|
||||||
cogs_to_check: Set[InstalledModule], *, failed_repos: Sequence[str]
|
cogs_to_check: Set[InstalledModule],
|
||||||
|
*,
|
||||||
|
failed_repos: Sequence[str],
|
||||||
|
env: Environment = Environment.current(),
|
||||||
) -> CogUpdateResult:
|
) -> CogUpdateResult:
|
||||||
pinned_cogs = {cog for cog in cogs_to_check if cog.pinned}
|
pinned_cogs = {cog for cog in cogs_to_check if cog.pinned}
|
||||||
cogs_to_check -= pinned_cogs
|
cogs_to_check -= pinned_cogs
|
||||||
@@ -737,12 +779,12 @@ async def _update_cogs(
|
|||||||
outdated_cogs, outdated_libs = await _available_updates(cogs_to_check)
|
outdated_cogs, outdated_libs = await _available_updates(cogs_to_check)
|
||||||
|
|
||||||
for cog in outdated_cogs:
|
for cog in outdated_cogs:
|
||||||
if cog.min_python_version > sys.version_info:
|
if cog.min_python_version > env.python_version:
|
||||||
incompatible_python_version.append(cog)
|
incompatible_python_version.append(cog)
|
||||||
elif cog.min_bot_version > red_version_info or (
|
elif cog.min_bot_version > env.red_version or (
|
||||||
# max version should be ignored when it's lower than min version
|
# max version should be ignored when it's lower than min version
|
||||||
cog.min_bot_version <= cog.max_bot_version
|
cog.min_bot_version <= cog.max_bot_version
|
||||||
and cog.max_bot_version < red_version_info
|
and cog.max_bot_version < env.red_version
|
||||||
):
|
):
|
||||||
incompatible_bot_version.append(cog)
|
incompatible_bot_version.append(cog)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Callable, Dict, Tuple, Union, cast
|
from typing import TYPE_CHECKING, Any, Callable, Dict, Tuple, Union
|
||||||
|
|
||||||
from redbot import VersionInfo, version_info as red_version_info
|
from packaging.version import Version
|
||||||
|
|
||||||
from . import installable
|
from . import installable
|
||||||
from .log import log
|
from .log import log
|
||||||
@@ -67,38 +67,40 @@ def ensure_str(info_file: Path, key_name: str, value: Union[Any, UseDefault]) ->
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def ensure_red_version_info(
|
def create_ensure_red_version(default: Version) -> EnsureCallable:
|
||||||
info_file: Path, key_name: str, value: Union[Any, UseDefault]
|
def ensure_red_version(
|
||||||
) -> VersionInfo:
|
info_file: Path, key_name: str, value: Union[Any, UseDefault]
|
||||||
default = red_version_info
|
) -> Version:
|
||||||
if value is USE_DEFAULT:
|
if value is USE_DEFAULT:
|
||||||
return default
|
return default
|
||||||
if not isinstance(value, str):
|
if not isinstance(value, str):
|
||||||
log.warning(
|
log.warning(
|
||||||
"Invalid value of '%s' key (expected str, got %s)"
|
"Invalid value of '%s' key (expected str, got %s)"
|
||||||
" in JSON information file at path: %s",
|
" in JSON information file at path: %s",
|
||||||
key_name,
|
key_name,
|
||||||
type(value).__name__,
|
type(value).__name__,
|
||||||
info_file,
|
info_file,
|
||||||
)
|
)
|
||||||
return default
|
return default
|
||||||
try:
|
try:
|
||||||
version_info = VersionInfo.from_str(value)
|
version_info = Version(value)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
log.warning(
|
log.warning(
|
||||||
"Invalid value of '%s' key (given value isn't a valid version string)"
|
"Invalid value of '%s' key (given value isn't a valid version string)"
|
||||||
" in JSON information file at path: %s",
|
" in JSON information file at path: %s",
|
||||||
key_name,
|
key_name,
|
||||||
info_file,
|
info_file,
|
||||||
)
|
)
|
||||||
return default
|
return default
|
||||||
return version_info
|
return version_info
|
||||||
|
|
||||||
|
return ensure_red_version
|
||||||
|
|
||||||
|
|
||||||
def ensure_python_version_info(
|
def ensure_python_version(
|
||||||
info_file: Path, key_name: str, value: Union[Any, UseDefault]
|
info_file: Path, key_name: str, value: Union[Any, UseDefault]
|
||||||
) -> Tuple[int, int, int]:
|
) -> Version:
|
||||||
default = (3, 5, 1)
|
default = Version("3.5.1")
|
||||||
if value is USE_DEFAULT:
|
if value is USE_DEFAULT:
|
||||||
return default
|
return default
|
||||||
if not isinstance(value, list):
|
if not isinstance(value, list):
|
||||||
@@ -130,7 +132,7 @@ def ensure_python_version_info(
|
|||||||
info_file,
|
info_file,
|
||||||
)
|
)
|
||||||
return default
|
return default
|
||||||
return cast(Tuple[int, int, int], tuple(value))
|
return Version(".".join(map(str, value)))
|
||||||
|
|
||||||
|
|
||||||
def ensure_bool(
|
def ensure_bool(
|
||||||
@@ -211,9 +213,13 @@ REPO_SCHEMA: SchemaType = {
|
|||||||
"short": ensure_str,
|
"short": ensure_str,
|
||||||
}
|
}
|
||||||
INSTALLABLE_SCHEMA: SchemaType = {
|
INSTALLABLE_SCHEMA: SchemaType = {
|
||||||
"min_bot_version": ensure_red_version_info,
|
"min_bot_version": create_ensure_red_version(Version("0.0.dev0")),
|
||||||
"max_bot_version": ensure_red_version_info,
|
# Using little-known version epoch feature to represent something that,
|
||||||
"min_python_version": ensure_python_version_info,
|
# for all practical purposes, will be considered higher than any version number
|
||||||
|
# that we may ever have.
|
||||||
|
# https://packaging.python.org/en/latest/specifications/version-specifiers/#version-epochs
|
||||||
|
"max_bot_version": create_ensure_red_version(Version("99999!99999.99999.post99999+hi.mom")),
|
||||||
|
"min_python_version": ensure_python_version,
|
||||||
"hidden": ensure_bool,
|
"hidden": ensure_bool,
|
||||||
"disabled": ensure_bool,
|
"disabled": ensure_bool,
|
||||||
"required_cogs": ensure_required_cogs_mapping,
|
"required_cogs": ensure_required_cogs_mapping,
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ from enum import Enum
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Tuple, Union, cast
|
from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Tuple, Union, cast
|
||||||
|
|
||||||
|
from packaging.version import Version
|
||||||
|
|
||||||
from .log import log
|
from .log import log
|
||||||
from .info_schemas import INSTALLABLE_SCHEMA, update_mixin
|
from .info_schemas import INSTALLABLE_SCHEMA, update_mixin
|
||||||
from .json_mixins import RepoJSONMixin
|
from .json_mixins import RepoJSONMixin
|
||||||
|
|
||||||
from redbot.core import VersionInfo
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .repo_manager import RepoManager, Repo
|
from .repo_manager import RepoManager, Repo
|
||||||
|
|
||||||
@@ -44,12 +44,12 @@ class Installable(RepoJSONMixin):
|
|||||||
Name(s) of the author(s).
|
Name(s) of the author(s).
|
||||||
end_user_data_statement : `str`
|
end_user_data_statement : `str`
|
||||||
End user data statement of the module.
|
End user data statement of the module.
|
||||||
min_bot_version : `VersionInfo`
|
min_bot_version : `packaging.version.Version`
|
||||||
The minimum bot version required for this Installable.
|
The minimum bot version required for this Installable.
|
||||||
max_bot_version : `VersionInfo`
|
max_bot_version : `packaging.version.Version`
|
||||||
The maximum bot version required for this Installable.
|
The maximum bot version required for this Installable.
|
||||||
Ignored if `min_bot_version` is newer than `max_bot_version`.
|
Ignored if `min_bot_version` is newer than `max_bot_version`.
|
||||||
min_python_version : `tuple` of `int`
|
min_python_version : `packaging.version.Version`
|
||||||
The minimum python version required for this cog.
|
The minimum python version required for this cog.
|
||||||
hidden : `bool`
|
hidden : `bool`
|
||||||
Whether or not this cog will be hidden from the user when they use
|
Whether or not this cog will be hidden from the user when they use
|
||||||
@@ -87,9 +87,9 @@ class Installable(RepoJSONMixin):
|
|||||||
self.commit = commit
|
self.commit = commit
|
||||||
|
|
||||||
self.end_user_data_statement: str
|
self.end_user_data_statement: str
|
||||||
self.min_bot_version: VersionInfo
|
self.min_bot_version: Version
|
||||||
self.max_bot_version: VersionInfo
|
self.max_bot_version: Version
|
||||||
self.min_python_version: Tuple[int, int, int]
|
self.min_python_version: Version
|
||||||
self.hidden: bool
|
self.hidden: bool
|
||||||
self.disabled: bool
|
self.disabled: bool
|
||||||
self.required_cogs: Dict[str, str] # Cog name -> repo URL
|
self.required_cogs: Dict[str, str] # Cog name -> repo URL
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ INFO_JSON = {
|
|||||||
"author": ("tekulvw",),
|
"author": ("tekulvw",),
|
||||||
"min_bot_version": "3.0.0",
|
"min_bot_version": "3.0.0",
|
||||||
"max_bot_version": "3.0.2",
|
"max_bot_version": "3.0.2",
|
||||||
|
"min_python_version": [3, 7, 1],
|
||||||
"description": "A long description",
|
"description": "A long description",
|
||||||
"hidden": False,
|
"hidden": False,
|
||||||
"install_msg": "A post-installation message",
|
"install_msg": "A post-installation message",
|
||||||
@@ -101,6 +102,7 @@ LIBRARY_INFO_JSON = {
|
|||||||
"author": ("seputaes",),
|
"author": ("seputaes",),
|
||||||
"min_bot_version": "3.0.0",
|
"min_bot_version": "3.0.0",
|
||||||
"max_bot_version": "3.0.2",
|
"max_bot_version": "3.0.2",
|
||||||
|
"min_python_version": [3, 7, 1],
|
||||||
"description": "A long library description",
|
"description": "A long library description",
|
||||||
"hidden": False, # libraries are always hidden, this tests it will be flipped
|
"hidden": False, # libraries are always hidden, this tests it will be flipped
|
||||||
"install_msg": "A library install message",
|
"install_msg": "A library install message",
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import json
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from packaging.version import Version
|
||||||
|
|
||||||
from redbot.pytest.downloader import *
|
from redbot.pytest.downloader import *
|
||||||
from redbot.core._downloader.installable import Installable, InstallableType
|
from redbot.core._downloader.installable import Installable, InstallableType
|
||||||
from redbot.core import VersionInfo
|
|
||||||
|
|
||||||
|
|
||||||
def test_process_info_file(installable):
|
def test_process_info_file(installable):
|
||||||
@@ -13,7 +13,9 @@ def test_process_info_file(installable):
|
|||||||
if k == "type":
|
if k == "type":
|
||||||
assert installable.type is InstallableType.COG
|
assert installable.type is InstallableType.COG
|
||||||
elif k in ("min_bot_version", "max_bot_version"):
|
elif k in ("min_bot_version", "max_bot_version"):
|
||||||
assert getattr(installable, k) == VersionInfo.from_str(v)
|
assert getattr(installable, k) == Version(v)
|
||||||
|
elif k == "min_python_version":
|
||||||
|
assert installable.min_python_version == Version(".".join(map(str, v)))
|
||||||
else:
|
else:
|
||||||
assert getattr(installable, k) == v
|
assert getattr(installable, k) == v
|
||||||
|
|
||||||
@@ -23,7 +25,9 @@ def test_process_lib_info_file(library_installable):
|
|||||||
if k == "type":
|
if k == "type":
|
||||||
assert library_installable.type is InstallableType.SHARED_LIBRARY
|
assert library_installable.type is InstallableType.SHARED_LIBRARY
|
||||||
elif k in ("min_bot_version", "max_bot_version"):
|
elif k in ("min_bot_version", "max_bot_version"):
|
||||||
assert getattr(library_installable, k) == VersionInfo.from_str(v)
|
assert getattr(library_installable, k) == Version(v)
|
||||||
|
elif k == "min_python_version":
|
||||||
|
assert library_installable.min_python_version == Version(".".join(map(str, v)))
|
||||||
elif k == "hidden":
|
elif k == "hidden":
|
||||||
# libraries are always hidden, even if False
|
# libraries are always hidden, even if False
|
||||||
assert library_installable.hidden is True
|
assert library_installable.hidden is True
|
||||||
|
|||||||
Reference in New Issue
Block a user