mirror of
https://github.com/arcan1s/ahriman.git
synced 2025-05-05 20:53:50 +00:00
Compare commits
12 Commits
dbfb460557
...
528aba4b42
Author | SHA1 | Date | |
---|---|---|---|
528aba4b42 | |||
6b64089d59 | |||
e45383418b | |||
dd5baaa07e | |||
d4a6f031c2 | |||
4e9beca339 | |||
9bbbd9da2e | |||
985307a89e | |||
e2efe21a8b | |||
5995b78572 | |||
ac19c407d3 | |||
c74cd68ad6 |
@ -17,7 +17,6 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
import itertools
|
|
||||||
import shutil
|
import shutil
|
||||||
import tarfile
|
import tarfile
|
||||||
|
|
||||||
@ -178,48 +177,39 @@ class Pacman(LazyLogging):
|
|||||||
PacmanDatabase(database, self.configuration).sync(force=force)
|
PacmanDatabase(database, self.configuration).sync(force=force)
|
||||||
transaction.release()
|
transaction.release()
|
||||||
|
|
||||||
def files(self, packages: Iterable[str]) -> dict[str, set[str]]:
|
def files(self, packages: Iterable[str] | None = None) -> dict[str, set[str]]:
|
||||||
"""
|
"""
|
||||||
extract list of known packages from the databases
|
extract list of known packages from the databases
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
packages(Iterable[str]): filter by package names
|
packages(Iterable[str] | None, optional): filter by package names (Default value = None)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict[str, set[str]]: map of package name to its list of files
|
dict[str, set[str]]: map of package name to its list of files
|
||||||
"""
|
"""
|
||||||
def extract(tar: tarfile.TarFile, package_names: dict[str, str]) -> Generator[tuple[str, set[str]], None, None]:
|
packages = packages or []
|
||||||
for package_name, version in package_names.items():
|
|
||||||
path = Path(f"{package_name}-{version}") / "files"
|
def extract(tar: tarfile.TarFile) -> Generator[tuple[str, set[str]], None, None]:
|
||||||
try:
|
for descriptor in filter(lambda info: info.path.endswith("/files"), tar.getmembers()):
|
||||||
content = tar.extractfile(str(path))
|
package, *_ = str(Path(descriptor.path).parent).rsplit("-", 2)
|
||||||
except KeyError:
|
if packages and package not in packages:
|
||||||
# in case if database and its files has been desync somehow, the extractfile will raise
|
continue # skip unused packages
|
||||||
# KeyError because the entry doesn't exist
|
content = tar.extractfile(descriptor)
|
||||||
content = None
|
|
||||||
if content is None:
|
if content is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# this is just array of files, however, the directories are with trailing slash,
|
# this is just array of files, however, the directories are with trailing slash,
|
||||||
# which previously has been removed by the conversion to ``pathlib.Path``
|
# which previously has been removed by the conversion to ``pathlib.Path``
|
||||||
files = {filename.decode("utf8").rstrip().removesuffix("/") for filename in content.readlines()}
|
files = {filename.decode("utf8").rstrip().removesuffix("/") for filename in content.readlines()}
|
||||||
yield package_name, files
|
|
||||||
|
|
||||||
# sort is required for the following group by operation
|
yield package, files
|
||||||
descriptors = sorted(
|
|
||||||
(package for package_name in packages for package in self.package(package_name)),
|
|
||||||
key=lambda package: package.db.name
|
|
||||||
)
|
|
||||||
|
|
||||||
result: dict[str, set[str]] = {}
|
result: dict[str, set[str]] = {}
|
||||||
for database_name, pacman_packages in itertools.groupby(descriptors, lambda package: package.db.name):
|
for database in self.handle.get_syncdbs():
|
||||||
database_file = self.repository_paths.pacman / "sync" / f"{database_name}.files.tar.gz"
|
database_file = self.repository_paths.pacman / "sync" / f"{database.name}.files.tar.gz"
|
||||||
if not database_file.is_file():
|
if not database_file.is_file():
|
||||||
continue # no database file found
|
continue # no database file found
|
||||||
|
|
||||||
package_names = {package.name: package.version for package in pacman_packages}
|
|
||||||
with tarfile.open(database_file, "r:gz") as archive:
|
with tarfile.open(database_file, "r:gz") as archive:
|
||||||
result.update(extract(archive, package_names))
|
result.update(extract(archive))
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
@ -22,3 +22,4 @@ from collections.abc import Awaitable, Callable
|
|||||||
|
|
||||||
|
|
||||||
HandlerType = Callable[[Request], Awaitable[StreamResponse]]
|
HandlerType = Callable[[Request], Awaitable[StreamResponse]]
|
||||||
|
MiddlewareType = Callable[[Request, HandlerType], Awaitable[StreamResponse]]
|
||||||
|
@ -21,7 +21,6 @@ import aiohttp_security
|
|||||||
import socket
|
import socket
|
||||||
import types
|
import types
|
||||||
|
|
||||||
from aiohttp.typedefs import Middleware
|
|
||||||
from aiohttp.web import Application, Request, StaticResource, StreamResponse, middleware
|
from aiohttp.web import Application, Request, StaticResource, StreamResponse, middleware
|
||||||
from aiohttp_session import setup as setup_session
|
from aiohttp_session import setup as setup_session
|
||||||
from aiohttp_session.cookie_storage import EncryptedCookieStorage
|
from aiohttp_session.cookie_storage import EncryptedCookieStorage
|
||||||
@ -31,7 +30,7 @@ from enum import Enum
|
|||||||
from ahriman.core.auth import Auth
|
from ahriman.core.auth import Auth
|
||||||
from ahriman.core.configuration import Configuration
|
from ahriman.core.configuration import Configuration
|
||||||
from ahriman.models.user_access import UserAccess
|
from ahriman.models.user_access import UserAccess
|
||||||
from ahriman.web.middlewares import HandlerType
|
from ahriman.web.middlewares import HandlerType, MiddlewareType
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["setup_auth"]
|
__all__ = ["setup_auth"]
|
||||||
@ -85,7 +84,7 @@ class _AuthorizationPolicy(aiohttp_security.AbstractAuthorizationPolicy):
|
|||||||
return await self.validator.verify_access(identity, permission, context)
|
return await self.validator.verify_access(identity, permission, context)
|
||||||
|
|
||||||
|
|
||||||
def _auth_handler(allow_read_only: bool) -> Middleware:
|
def _auth_handler(allow_read_only: bool) -> MiddlewareType:
|
||||||
"""
|
"""
|
||||||
authorization and authentication middleware
|
authorization and authentication middleware
|
||||||
|
|
||||||
@ -93,7 +92,7 @@ def _auth_handler(allow_read_only: bool) -> Middleware:
|
|||||||
allow_read_only: allow
|
allow_read_only: allow
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Middleware: built middleware
|
MiddlewareType: built middleware
|
||||||
"""
|
"""
|
||||||
@middleware
|
@middleware
|
||||||
async def handle(request: Request, handler: HandlerType) -> StreamResponse:
|
async def handle(request: Request, handler: HandlerType) -> StreamResponse:
|
||||||
|
@ -20,11 +20,10 @@
|
|||||||
import aiohttp_jinja2
|
import aiohttp_jinja2
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from aiohttp.typedefs import Middleware
|
|
||||||
from aiohttp.web import HTTPClientError, HTTPException, HTTPMethodNotAllowed, HTTPNoContent, HTTPServerError, \
|
from aiohttp.web import HTTPClientError, HTTPException, HTTPMethodNotAllowed, HTTPNoContent, HTTPServerError, \
|
||||||
HTTPUnauthorized, Request, StreamResponse, json_response, middleware
|
HTTPUnauthorized, Request, StreamResponse, json_response, middleware
|
||||||
|
|
||||||
from ahriman.web.middlewares import HandlerType
|
from ahriman.web.middlewares import HandlerType, MiddlewareType
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["exception_handler"]
|
__all__ = ["exception_handler"]
|
||||||
@ -44,7 +43,7 @@ def _is_templated_unauthorized(request: Request) -> bool:
|
|||||||
and "application/json" not in request.headers.getall("accept", [])
|
and "application/json" not in request.headers.getall("accept", [])
|
||||||
|
|
||||||
|
|
||||||
def exception_handler(logger: logging.Logger) -> Middleware:
|
def exception_handler(logger: logging.Logger) -> MiddlewareType:
|
||||||
"""
|
"""
|
||||||
exception handler middleware. Just log any exception (except for client ones)
|
exception handler middleware. Just log any exception (except for client ones)
|
||||||
|
|
||||||
@ -52,7 +51,7 @@ def exception_handler(logger: logging.Logger) -> Middleware:
|
|||||||
logger(logging.Logger): class logger
|
logger(logging.Logger): class logger
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Middleware: built middleware
|
MiddlewareType: built middleware
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTPNoContent: OPTIONS method response
|
HTTPNoContent: OPTIONS method response
|
||||||
|
@ -4,7 +4,7 @@ import pytest
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from pytest_mock import MockerFixture
|
from pytest_mock import MockerFixture
|
||||||
from typing import Any, TypeVar
|
from typing import Any, TypeVar
|
||||||
from unittest.mock import MagicMock, PropertyMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
from ahriman.core.alpm.pacman import Pacman
|
from ahriman.core.alpm.pacman import Pacman
|
||||||
from ahriman.core.alpm.remote import AUR
|
from ahriman.core.alpm.remote import AUR
|
||||||
@ -476,41 +476,6 @@ def passwd() -> MagicMock:
|
|||||||
return passwd
|
return passwd
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def pyalpm_package_ahriman(aur_package_ahriman: AURPackage) -> MagicMock:
|
|
||||||
"""
|
|
||||||
mock object for pyalpm package
|
|
||||||
|
|
||||||
Args:
|
|
||||||
aur_package_ahriman(AURPackage): package fixture
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
MagicMock: pyalpm package mock
|
|
||||||
"""
|
|
||||||
mock = MagicMock()
|
|
||||||
db = type(mock).db = MagicMock()
|
|
||||||
|
|
||||||
type(mock).base = PropertyMock(return_value=aur_package_ahriman.package_base)
|
|
||||||
type(mock).builddate = PropertyMock(
|
|
||||||
return_value=aur_package_ahriman.last_modified.replace(tzinfo=datetime.timezone.utc).timestamp())
|
|
||||||
type(mock).conflicts = PropertyMock(return_value=aur_package_ahriman.conflicts)
|
|
||||||
type(db).name = PropertyMock(return_value="aur")
|
|
||||||
type(mock).depends = PropertyMock(return_value=aur_package_ahriman.depends)
|
|
||||||
type(mock).desc = PropertyMock(return_value=aur_package_ahriman.description)
|
|
||||||
type(mock).licenses = PropertyMock(return_value=aur_package_ahriman.license)
|
|
||||||
type(mock).makedepends = PropertyMock(return_value=aur_package_ahriman.make_depends)
|
|
||||||
type(mock).name = PropertyMock(return_value=aur_package_ahriman.name)
|
|
||||||
type(mock).optdepends = PropertyMock(return_value=aur_package_ahriman.opt_depends)
|
|
||||||
type(mock).checkdepends = PropertyMock(return_value=aur_package_ahriman.check_depends)
|
|
||||||
type(mock).packager = PropertyMock(return_value="packager")
|
|
||||||
type(mock).provides = PropertyMock(return_value=aur_package_ahriman.provides)
|
|
||||||
type(mock).version = PropertyMock(return_value=aur_package_ahriman.version)
|
|
||||||
type(mock).url = PropertyMock(return_value=aur_package_ahriman.url)
|
|
||||||
type(mock).groups = PropertyMock(return_value=aur_package_ahriman.groups)
|
|
||||||
|
|
||||||
return mock
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def remote_source() -> RemoteSource:
|
def remote_source() -> RemoteSource:
|
||||||
"""
|
"""
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import pyalpm
|
|
||||||
import pytest
|
import pytest
|
||||||
import tarfile
|
import tarfile
|
||||||
|
|
||||||
@ -176,12 +175,31 @@ def test_database_sync_forced(pacman: Pacman, mocker: MockerFixture) -> None:
|
|||||||
sync_mock.assert_called_once_with(force=True)
|
sync_mock.assert_called_once_with(force=True)
|
||||||
|
|
||||||
|
|
||||||
def test_files_package(pacman: Pacman, package_ahriman: Package, pyalpm_package_ahriman: pyalpm.Package,
|
def test_files(pacman: Pacman, package_ahriman: Package, mocker: MockerFixture, resource_path_root: Path) -> None:
|
||||||
mocker: MockerFixture, resource_path_root: Path) -> None:
|
"""
|
||||||
|
must load files from databases
|
||||||
|
"""
|
||||||
|
handle_mock = MagicMock()
|
||||||
|
handle_mock.get_syncdbs.return_value = [MagicMock()]
|
||||||
|
pacman.handle = handle_mock
|
||||||
|
tarball = resource_path_root / "core" / "arcanisrepo.files.tar.gz"
|
||||||
|
|
||||||
|
with tarfile.open(tarball, "r:gz") as fd:
|
||||||
|
mocker.patch("pathlib.Path.is_file", return_value=True)
|
||||||
|
open_mock = mocker.patch("ahriman.core.alpm.pacman.tarfile.open", return_value=fd)
|
||||||
|
|
||||||
|
files = pacman.files()
|
||||||
|
assert len(files) == 2
|
||||||
|
assert package_ahriman.base in files
|
||||||
|
assert "usr/bin/ahriman" in files[package_ahriman.base]
|
||||||
|
open_mock.assert_called_once_with(pytest.helpers.anyvar(int), "r:gz")
|
||||||
|
|
||||||
|
|
||||||
|
def test_files_package(pacman: Pacman, package_ahriman: Package, mocker: MockerFixture,
|
||||||
|
resource_path_root: Path) -> None:
|
||||||
"""
|
"""
|
||||||
must load files only for the specified package
|
must load files only for the specified package
|
||||||
"""
|
"""
|
||||||
mocker.patch("ahriman.core.alpm.pacman.Pacman.package", return_value=[pyalpm_package_ahriman])
|
|
||||||
handle_mock = MagicMock()
|
handle_mock = MagicMock()
|
||||||
handle_mock.get_syncdbs.return_value = [MagicMock()]
|
handle_mock.get_syncdbs.return_value = [MagicMock()]
|
||||||
pacman.handle = handle_mock
|
pacman.handle = handle_mock
|
||||||
@ -192,35 +210,34 @@ def test_files_package(pacman: Pacman, package_ahriman: Package, pyalpm_package_
|
|||||||
mocker.patch("pathlib.Path.is_file", return_value=True)
|
mocker.patch("pathlib.Path.is_file", return_value=True)
|
||||||
mocker.patch("ahriman.core.alpm.pacman.tarfile.open", return_value=fd)
|
mocker.patch("ahriman.core.alpm.pacman.tarfile.open", return_value=fd)
|
||||||
|
|
||||||
files = pacman.files([package_ahriman.base])
|
files = pacman.files(package_ahriman.base)
|
||||||
assert len(files) == 1
|
assert len(files) == 1
|
||||||
assert package_ahriman.base in files
|
assert package_ahriman.base in files
|
||||||
|
|
||||||
|
|
||||||
def test_files_skip(pacman: Pacman, pyalpm_package_ahriman: pyalpm.Package, mocker: MockerFixture) -> None:
|
def test_files_skip(pacman: Pacman, mocker: MockerFixture) -> None:
|
||||||
"""
|
"""
|
||||||
must return empty list if no database found
|
must return empty list if no database found
|
||||||
"""
|
"""
|
||||||
mocker.patch("ahriman.core.alpm.pacman.Pacman.package", return_value=[pyalpm_package_ahriman])
|
|
||||||
handle_mock = MagicMock()
|
handle_mock = MagicMock()
|
||||||
handle_mock.get_syncdbs.return_value = [MagicMock()]
|
handle_mock.get_syncdbs.return_value = [MagicMock()]
|
||||||
pacman.handle = handle_mock
|
pacman.handle = handle_mock
|
||||||
|
|
||||||
mocker.patch("pathlib.Path.is_file", return_value=False)
|
mocker.patch("pathlib.Path.is_file", return_value=False)
|
||||||
|
|
||||||
assert not pacman.files([pyalpm_package_ahriman.name])
|
assert not pacman.files()
|
||||||
|
|
||||||
|
|
||||||
def test_files_no_content(pacman: Pacman, pyalpm_package_ahriman: pyalpm.Package, mocker: MockerFixture) -> None:
|
def test_files_no_content(pacman: Pacman, mocker: MockerFixture) -> None:
|
||||||
"""
|
"""
|
||||||
must skip package if no content can be loaded
|
must skip package if no content can be loaded
|
||||||
"""
|
"""
|
||||||
mocker.patch("ahriman.core.alpm.pacman.Pacman.package", return_value=[pyalpm_package_ahriman])
|
|
||||||
handle_mock = MagicMock()
|
handle_mock = MagicMock()
|
||||||
handle_mock.get_syncdbs.return_value = [MagicMock()]
|
handle_mock.get_syncdbs.return_value = [MagicMock()]
|
||||||
pacman.handle = handle_mock
|
pacman.handle = handle_mock
|
||||||
|
|
||||||
tar_mock = MagicMock()
|
tar_mock = MagicMock()
|
||||||
|
tar_mock.getmembers.return_value = [MagicMock()]
|
||||||
tar_mock.extractfile.return_value = None
|
tar_mock.extractfile.return_value = None
|
||||||
|
|
||||||
open_mock = MagicMock()
|
open_mock = MagicMock()
|
||||||
@ -229,28 +246,7 @@ def test_files_no_content(pacman: Pacman, pyalpm_package_ahriman: pyalpm.Package
|
|||||||
mocker.patch("pathlib.Path.is_file", return_value=True)
|
mocker.patch("pathlib.Path.is_file", return_value=True)
|
||||||
mocker.patch("ahriman.core.alpm.pacman.tarfile.open", return_value=open_mock)
|
mocker.patch("ahriman.core.alpm.pacman.tarfile.open", return_value=open_mock)
|
||||||
|
|
||||||
assert not pacman.files([pyalpm_package_ahriman.name])
|
assert not pacman.files()
|
||||||
|
|
||||||
|
|
||||||
def test_files_no_entry(pacman: Pacman, pyalpm_package_ahriman: pyalpm.Package, mocker: MockerFixture) -> None:
|
|
||||||
"""
|
|
||||||
must skip package if it wasn't found in the archive
|
|
||||||
"""
|
|
||||||
mocker.patch("ahriman.core.alpm.pacman.Pacman.package", return_value=[pyalpm_package_ahriman])
|
|
||||||
handle_mock = MagicMock()
|
|
||||||
handle_mock.get_syncdbs.return_value = [MagicMock()]
|
|
||||||
pacman.handle = handle_mock
|
|
||||||
|
|
||||||
tar_mock = MagicMock()
|
|
||||||
tar_mock.extractfile.side_effect = KeyError()
|
|
||||||
|
|
||||||
open_mock = MagicMock()
|
|
||||||
open_mock.__enter__.return_value = tar_mock
|
|
||||||
|
|
||||||
mocker.patch("pathlib.Path.is_file", return_value=True)
|
|
||||||
mocker.patch("ahriman.core.alpm.pacman.tarfile.open", return_value=open_mock)
|
|
||||||
|
|
||||||
assert not pacman.files([pyalpm_package_ahriman.name])
|
|
||||||
|
|
||||||
|
|
||||||
def test_package(pacman: Pacman) -> None:
|
def test_package(pacman: Pacman) -> None:
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import datetime
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@ -7,6 +8,7 @@ from pytest_mock import MockerFixture
|
|||||||
from ahriman import __version__
|
from ahriman import __version__
|
||||||
from ahriman.core.alpm.pacman import Pacman
|
from ahriman.core.alpm.pacman import Pacman
|
||||||
from ahriman.core.alpm.remote import AUR
|
from ahriman.core.alpm.remote import AUR
|
||||||
|
from ahriman.models.aur_package import AURPackage
|
||||||
from ahriman.models.build_status import BuildStatus, BuildStatusEnum
|
from ahriman.models.build_status import BuildStatus, BuildStatusEnum
|
||||||
from ahriman.models.counters import Counters
|
from ahriman.models.counters import Counters
|
||||||
from ahriman.models.filesystem_package import FilesystemPackage
|
from ahriman.models.filesystem_package import FilesystemPackage
|
||||||
@ -132,6 +134,41 @@ def pyalpm_handle(pyalpm_package_ahriman: MagicMock) -> MagicMock:
|
|||||||
return mock
|
return mock
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def pyalpm_package_ahriman(aur_package_ahriman: AURPackage) -> MagicMock:
|
||||||
|
"""
|
||||||
|
mock object for pyalpm package
|
||||||
|
|
||||||
|
Args:
|
||||||
|
aur_package_ahriman(AURPackage): package fixture
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
MagicMock: pyalpm package mock
|
||||||
|
"""
|
||||||
|
mock = MagicMock()
|
||||||
|
db = type(mock).db = MagicMock()
|
||||||
|
|
||||||
|
type(mock).base = PropertyMock(return_value=aur_package_ahriman.package_base)
|
||||||
|
type(mock).builddate = PropertyMock(
|
||||||
|
return_value=aur_package_ahriman.last_modified.replace(tzinfo=datetime.timezone.utc).timestamp())
|
||||||
|
type(mock).conflicts = PropertyMock(return_value=aur_package_ahriman.conflicts)
|
||||||
|
type(db).name = PropertyMock(return_value="aur")
|
||||||
|
type(mock).depends = PropertyMock(return_value=aur_package_ahriman.depends)
|
||||||
|
type(mock).desc = PropertyMock(return_value=aur_package_ahriman.description)
|
||||||
|
type(mock).licenses = PropertyMock(return_value=aur_package_ahriman.license)
|
||||||
|
type(mock).makedepends = PropertyMock(return_value=aur_package_ahriman.make_depends)
|
||||||
|
type(mock).name = PropertyMock(return_value=aur_package_ahriman.name)
|
||||||
|
type(mock).optdepends = PropertyMock(return_value=aur_package_ahriman.opt_depends)
|
||||||
|
type(mock).checkdepends = PropertyMock(return_value=aur_package_ahriman.check_depends)
|
||||||
|
type(mock).packager = PropertyMock(return_value="packager")
|
||||||
|
type(mock).provides = PropertyMock(return_value=aur_package_ahriman.provides)
|
||||||
|
type(mock).version = PropertyMock(return_value=aur_package_ahriman.version)
|
||||||
|
type(mock).url = PropertyMock(return_value=aur_package_ahriman.url)
|
||||||
|
type(mock).groups = PropertyMock(return_value=aur_package_ahriman.groups)
|
||||||
|
|
||||||
|
return mock
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def pyalpm_package_description_ahriman(package_description_ahriman: PackageDescription) -> MagicMock:
|
def pyalpm_package_description_ahriman(package_description_ahriman: PackageDescription) -> MagicMock:
|
||||||
"""
|
"""
|
||||||
|
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user