Загрузить файлы в «venv/Lib/site-packages/pip/_internal/utils»
This commit is contained in:
88
venv/Lib/site-packages/pip/_internal/utils/entrypoints.py
Normal file
88
venv/Lib/site-packages/pip/_internal/utils/entrypoints.py
Normal file
@@ -0,0 +1,88 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
from pip._internal.cli.main import main
|
||||
from pip._internal.utils.compat import WINDOWS
|
||||
|
||||
_EXECUTABLE_NAMES = [
|
||||
"pip",
|
||||
f"pip{sys.version_info.major}",
|
||||
f"pip{sys.version_info.major}.{sys.version_info.minor}",
|
||||
]
|
||||
if WINDOWS:
|
||||
_allowed_extensions = {"", ".exe"}
|
||||
_EXECUTABLE_NAMES = [
|
||||
"".join(parts)
|
||||
for parts in itertools.product(_EXECUTABLE_NAMES, _allowed_extensions)
|
||||
]
|
||||
|
||||
|
||||
def _wrapper(args: list[str] | None = None) -> int:
|
||||
"""Central wrapper for all old entrypoints.
|
||||
|
||||
Historically pip has had several entrypoints defined. Because of issues
|
||||
arising from PATH, sys.path, multiple Pythons, their interactions, and most
|
||||
of them having a pip installed, users suffer every time an entrypoint gets
|
||||
moved.
|
||||
|
||||
To alleviate this pain, and provide a mechanism for warning users and
|
||||
directing them to an appropriate place for help, we now define all of
|
||||
our old entrypoints as wrappers for the current one.
|
||||
"""
|
||||
sys.stderr.write(
|
||||
"WARNING: pip is being invoked by an old script wrapper. This will "
|
||||
"fail in a future version of pip.\n"
|
||||
"Please see https://github.com/pypa/pip/issues/5599 for advice on "
|
||||
"fixing the underlying issue.\n"
|
||||
"To avoid this problem you can invoke Python with '-m pip' instead of "
|
||||
"running pip directly.\n"
|
||||
)
|
||||
return main(args)
|
||||
|
||||
|
||||
def get_best_invocation_for_this_pip() -> str:
|
||||
"""Try to figure out the best way to invoke pip in the current environment."""
|
||||
binary_directory = "Scripts" if WINDOWS else "bin"
|
||||
binary_prefix = os.path.join(sys.prefix, binary_directory)
|
||||
|
||||
# Try to use pip[X[.Y]] names, if those executables for this environment are
|
||||
# the first on PATH with that name.
|
||||
path_parts = os.path.normcase(os.environ.get("PATH", "")).split(os.pathsep)
|
||||
exe_are_in_PATH = os.path.normcase(binary_prefix) in path_parts
|
||||
if exe_are_in_PATH:
|
||||
for exe_name in _EXECUTABLE_NAMES:
|
||||
found_executable = shutil.which(exe_name)
|
||||
binary_executable = os.path.join(binary_prefix, exe_name)
|
||||
if (
|
||||
found_executable
|
||||
and os.path.exists(binary_executable)
|
||||
and os.path.samefile(
|
||||
found_executable,
|
||||
binary_executable,
|
||||
)
|
||||
):
|
||||
return exe_name
|
||||
|
||||
# Use the `-m` invocation, if there's no "nice" invocation.
|
||||
return f"{get_best_invocation_for_this_python()} -m pip"
|
||||
|
||||
|
||||
def get_best_invocation_for_this_python() -> str:
|
||||
"""Try to figure out the best way to invoke the current Python."""
|
||||
exe = sys.executable
|
||||
exe_name = os.path.basename(exe)
|
||||
|
||||
# Try to use the basename, if it's the first executable.
|
||||
found_executable = shutil.which(exe_name)
|
||||
# Virtual environments often symlink to their parent Python binaries, but we don't
|
||||
# want to treat the Python binaries as equivalent when the environment's Python is
|
||||
# not on PATH (not activated). Thus, we don't follow symlinks.
|
||||
if found_executable and os.path.samestat(os.lstat(found_executable), os.lstat(exe)):
|
||||
return exe_name
|
||||
|
||||
# Use the full executable name, because we couldn't find something simpler.
|
||||
return exe
|
||||
203
venv/Lib/site-packages/pip/_internal/utils/filesystem.py
Normal file
203
venv/Lib/site-packages/pip/_internal/utils/filesystem.py
Normal file
@@ -0,0 +1,203 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import os
|
||||
import os.path
|
||||
import random
|
||||
import sys
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from tempfile import NamedTemporaryFile
|
||||
from typing import Any, BinaryIO, Callable, cast
|
||||
|
||||
from pip._internal.utils.compat import get_path_uid
|
||||
from pip._internal.utils.misc import format_size
|
||||
from pip._internal.utils.retry import retry
|
||||
|
||||
|
||||
def check_path_owner(path: str) -> bool:
|
||||
# If we don't have a way to check the effective uid of this process, then
|
||||
# we'll just assume that we own the directory.
|
||||
if sys.platform == "win32" or not hasattr(os, "geteuid"):
|
||||
return True
|
||||
|
||||
assert os.path.isabs(path)
|
||||
|
||||
previous = None
|
||||
while path != previous:
|
||||
if os.path.lexists(path):
|
||||
# Check if path is writable by current user.
|
||||
if os.geteuid() == 0:
|
||||
# Special handling for root user in order to handle properly
|
||||
# cases where users use sudo without -H flag.
|
||||
try:
|
||||
path_uid = get_path_uid(path)
|
||||
except OSError:
|
||||
return False
|
||||
return path_uid == 0
|
||||
else:
|
||||
return os.access(path, os.W_OK)
|
||||
else:
|
||||
previous, path = path, os.path.dirname(path)
|
||||
return False # assume we don't own the path
|
||||
|
||||
|
||||
@contextmanager
|
||||
def adjacent_tmp_file(path: str, **kwargs: Any) -> Generator[BinaryIO, None, None]:
|
||||
"""Return a file-like object pointing to a tmp file next to path.
|
||||
|
||||
The file is created securely and is ensured to be written to disk
|
||||
after the context reaches its end.
|
||||
|
||||
kwargs will be passed to tempfile.NamedTemporaryFile to control
|
||||
the way the temporary file will be opened.
|
||||
"""
|
||||
with NamedTemporaryFile(
|
||||
delete=False,
|
||||
dir=os.path.dirname(path),
|
||||
prefix=os.path.basename(path),
|
||||
suffix=".tmp",
|
||||
**kwargs,
|
||||
) as f:
|
||||
result = cast(BinaryIO, f)
|
||||
try:
|
||||
yield result
|
||||
finally:
|
||||
result.flush()
|
||||
os.fsync(result.fileno())
|
||||
|
||||
|
||||
replace = retry(stop_after_delay=1, wait=0.25)(os.replace)
|
||||
|
||||
|
||||
# test_writable_dir and _test_writable_dir_win are copied from Flit,
|
||||
# with the author's agreement to also place them under pip's license.
|
||||
def test_writable_dir(path: str) -> bool:
|
||||
"""Check if a directory is writable.
|
||||
|
||||
Uses os.access() on POSIX, tries creating files on Windows.
|
||||
"""
|
||||
# If the directory doesn't exist, find the closest parent that does.
|
||||
while not os.path.isdir(path):
|
||||
parent = os.path.dirname(path)
|
||||
if parent == path:
|
||||
break # Should never get here, but infinite loops are bad
|
||||
path = parent
|
||||
|
||||
if os.name == "posix":
|
||||
return os.access(path, os.W_OK)
|
||||
|
||||
return _test_writable_dir_win(path)
|
||||
|
||||
|
||||
def _test_writable_dir_win(path: str) -> bool:
|
||||
# os.access doesn't work on Windows: http://bugs.python.org/issue2528
|
||||
# and we can't use tempfile: http://bugs.python.org/issue22107
|
||||
basename = "accesstest_deleteme_fishfingers_custard_"
|
||||
alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
for _ in range(10):
|
||||
name = basename + "".join(random.choice(alphabet) for _ in range(6))
|
||||
file = os.path.join(path, name)
|
||||
try:
|
||||
fd = os.open(file, os.O_RDWR | os.O_CREAT | os.O_EXCL)
|
||||
except FileExistsError:
|
||||
pass
|
||||
except PermissionError:
|
||||
# This could be because there's a directory with the same name.
|
||||
# But it's highly unlikely there's a directory called that,
|
||||
# so we'll assume it's because the parent dir is not writable.
|
||||
# This could as well be because the parent dir is not readable,
|
||||
# due to non-privileged user access.
|
||||
return False
|
||||
else:
|
||||
os.close(fd)
|
||||
os.unlink(file)
|
||||
return True
|
||||
|
||||
# This should never be reached
|
||||
raise OSError("Unexpected condition testing for writable directory")
|
||||
|
||||
|
||||
def find_files(path: str, pattern: str) -> list[str]:
|
||||
"""Returns a list of absolute paths of files beneath path, recursively,
|
||||
with filenames which match the UNIX-style shell glob pattern."""
|
||||
result: list[str] = []
|
||||
for root, _, files in os.walk(path):
|
||||
matches = fnmatch.filter(files, pattern)
|
||||
result.extend(os.path.join(root, f) for f in matches)
|
||||
return result
|
||||
|
||||
|
||||
def file_size(path: str) -> int | float:
|
||||
# If it's a symlink, return 0.
|
||||
if os.path.islink(path):
|
||||
return 0
|
||||
return os.path.getsize(path)
|
||||
|
||||
|
||||
def format_file_size(path: str) -> str:
|
||||
return format_size(file_size(path))
|
||||
|
||||
|
||||
def directory_size(path: str) -> int | float:
|
||||
size = 0.0
|
||||
for root, _dirs, files in os.walk(path):
|
||||
for filename in files:
|
||||
file_path = os.path.join(root, filename)
|
||||
size += file_size(file_path)
|
||||
return size
|
||||
|
||||
|
||||
def format_directory_size(path: str) -> str:
|
||||
return format_size(directory_size(path))
|
||||
|
||||
|
||||
def copy_directory_permissions(directory: str, target_file: BinaryIO) -> None:
|
||||
mode = (
|
||||
os.stat(directory).st_mode & 0o666 # select read/write permissions of directory
|
||||
| 0o600 # set owner read/write permissions
|
||||
)
|
||||
# Change permissions only if there is no risk of following a symlink.
|
||||
if os.chmod in os.supports_fd:
|
||||
os.chmod(target_file.fileno(), mode)
|
||||
elif os.chmod in os.supports_follow_symlinks:
|
||||
os.chmod(target_file.name, mode, follow_symlinks=False)
|
||||
|
||||
|
||||
def _subdirs_without_generic(
|
||||
path: str, predicate: Callable[[str, list[str]], bool]
|
||||
) -> Generator[Path]:
|
||||
"""Yields every subdirectory of +path+ that has no files matching the
|
||||
predicate under it."""
|
||||
|
||||
directories = []
|
||||
excluded = set()
|
||||
|
||||
for root_str, _, filenames in os.walk(Path(path).resolve()):
|
||||
root = Path(root_str)
|
||||
if predicate(root_str, filenames):
|
||||
# This directory should be excluded, so exclude it and all of its
|
||||
# parent directories.
|
||||
# The last item in root.parents is ".", so we ignore it.
|
||||
#
|
||||
# Wrapping this in `list()` is only needed for Python 3.9.
|
||||
excluded.update(list(root.parents)[:-1])
|
||||
excluded.add(root)
|
||||
directories.append(root)
|
||||
|
||||
for d in sorted(directories, reverse=True):
|
||||
if d not in excluded:
|
||||
yield d
|
||||
|
||||
|
||||
def subdirs_without_files(path: str) -> Generator[Path]:
|
||||
"""Yields every subdirectory of +path+ that has no files under it."""
|
||||
return _subdirs_without_generic(path, lambda root, filenames: len(filenames) > 0)
|
||||
|
||||
|
||||
def subdirs_without_wheels(path: str) -> Generator[Path]:
|
||||
"""Yields every subdirectory of +path+ that has no .whl files under it."""
|
||||
return _subdirs_without_generic(
|
||||
path, lambda root, filenames: any(x.endswith(".whl") for x in filenames)
|
||||
)
|
||||
24
venv/Lib/site-packages/pip/_internal/utils/filetypes.py
Normal file
24
venv/Lib/site-packages/pip/_internal/utils/filetypes.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""Filetype information."""
|
||||
|
||||
from pip._internal.utils.misc import splitext
|
||||
|
||||
WHEEL_EXTENSION = ".whl"
|
||||
BZ2_EXTENSIONS: tuple[str, ...] = (".tar.bz2", ".tbz")
|
||||
XZ_EXTENSIONS: tuple[str, ...] = (
|
||||
".tar.xz",
|
||||
".txz",
|
||||
".tlz",
|
||||
".tar.lz",
|
||||
".tar.lzma",
|
||||
)
|
||||
ZIP_EXTENSIONS: tuple[str, ...] = (".zip", WHEEL_EXTENSION)
|
||||
TAR_EXTENSIONS: tuple[str, ...] = (".tar.gz", ".tgz", ".tar")
|
||||
ARCHIVE_EXTENSIONS = ZIP_EXTENSIONS + BZ2_EXTENSIONS + TAR_EXTENSIONS + XZ_EXTENSIONS
|
||||
|
||||
|
||||
def is_archive_file(name: str) -> bool:
|
||||
"""Return True if `name` is a considered as an archive file."""
|
||||
ext = splitext(name)[1].lower()
|
||||
if ext in ARCHIVE_EXTENSIONS:
|
||||
return True
|
||||
return False
|
||||
102
venv/Lib/site-packages/pip/_internal/utils/glibc.py
Normal file
102
venv/Lib/site-packages/pip/_internal/utils/glibc.py
Normal file
@@ -0,0 +1,102 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def glibc_version_string() -> str | None:
|
||||
"Returns glibc version string, or None if not using glibc."
|
||||
return glibc_version_string_confstr() or glibc_version_string_ctypes()
|
||||
|
||||
|
||||
def glibc_version_string_confstr() -> str | None:
|
||||
"Primary implementation of glibc_version_string using os.confstr."
|
||||
# os.confstr is quite a bit faster than ctypes.DLL. It's also less likely
|
||||
# to be broken or missing. This strategy is used in the standard library
|
||||
# platform module:
|
||||
# https://github.com/python/cpython/blob/fcf1d003bf4f0100c9d0921ff3d70e1127ca1b71/Lib/platform.py#L175-L183
|
||||
if sys.platform == "win32":
|
||||
return None
|
||||
try:
|
||||
gnu_libc_version = os.confstr("CS_GNU_LIBC_VERSION")
|
||||
if gnu_libc_version is None:
|
||||
return None
|
||||
# os.confstr("CS_GNU_LIBC_VERSION") returns a string like "glibc 2.17":
|
||||
_, version = gnu_libc_version.split()
|
||||
except (AttributeError, OSError, ValueError):
|
||||
# os.confstr() or CS_GNU_LIBC_VERSION not available (or a bad value)...
|
||||
return None
|
||||
return version
|
||||
|
||||
|
||||
def glibc_version_string_ctypes() -> str | None:
|
||||
"Fallback implementation of glibc_version_string using ctypes."
|
||||
|
||||
try:
|
||||
import ctypes
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
# ctypes.CDLL(None) internally calls dlopen(NULL), and as the dlopen
|
||||
# manpage says, "If filename is NULL, then the returned handle is for the
|
||||
# main program". This way we can let the linker do the work to figure out
|
||||
# which libc our process is actually using.
|
||||
#
|
||||
# We must also handle the special case where the executable is not a
|
||||
# dynamically linked executable. This can occur when using musl libc,
|
||||
# for example. In this situation, dlopen() will error, leading to an
|
||||
# OSError. Interestingly, at least in the case of musl, there is no
|
||||
# errno set on the OSError. The single string argument used to construct
|
||||
# OSError comes from libc itself and is therefore not portable to
|
||||
# hard code here. In any case, failure to call dlopen() means we
|
||||
# can't proceed, so we bail on our attempt.
|
||||
try:
|
||||
process_namespace = ctypes.CDLL(None)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
try:
|
||||
gnu_get_libc_version = process_namespace.gnu_get_libc_version
|
||||
except AttributeError:
|
||||
# Symbol doesn't exist -> therefore, we are not linked to
|
||||
# glibc.
|
||||
return None
|
||||
|
||||
# Call gnu_get_libc_version, which returns a string like "2.5"
|
||||
gnu_get_libc_version.restype = ctypes.c_char_p
|
||||
version_str: str = gnu_get_libc_version()
|
||||
# py2 / py3 compatibility:
|
||||
if not isinstance(version_str, str):
|
||||
version_str = version_str.decode("ascii")
|
||||
|
||||
return version_str
|
||||
|
||||
|
||||
# platform.libc_ver regularly returns completely nonsensical glibc
|
||||
# versions. E.g. on my computer, platform says:
|
||||
#
|
||||
# ~$ python2.7 -c 'import platform; print(platform.libc_ver())'
|
||||
# ('glibc', '2.7')
|
||||
# ~$ python3.5 -c 'import platform; print(platform.libc_ver())'
|
||||
# ('glibc', '2.9')
|
||||
#
|
||||
# But the truth is:
|
||||
#
|
||||
# ~$ ldd --version
|
||||
# ldd (Debian GLIBC 2.22-11) 2.22
|
||||
#
|
||||
# This is unfortunate, because it means that the linehaul data on libc
|
||||
# versions that was generated by pip 8.1.2 and earlier is useless and
|
||||
# misleading. Solution: instead of using platform, use our code that actually
|
||||
# works.
|
||||
def libc_ver() -> tuple[str, str]:
|
||||
"""Try to determine the glibc version
|
||||
|
||||
Returns a tuple of strings (lib, version) which default to empty strings
|
||||
in case the lookup fails.
|
||||
"""
|
||||
glibc_version = glibc_version_string()
|
||||
if glibc_version is None:
|
||||
return ("", "")
|
||||
else:
|
||||
return ("glibc", glibc_version)
|
||||
150
venv/Lib/site-packages/pip/_internal/utils/hashes.py
Normal file
150
venv/Lib/site-packages/pip/_internal/utils/hashes.py
Normal file
@@ -0,0 +1,150 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from collections.abc import Iterable
|
||||
from typing import TYPE_CHECKING, BinaryIO, NoReturn
|
||||
|
||||
from pip._internal.exceptions import HashMismatch, HashMissing, InstallationError
|
||||
from pip._internal.utils.misc import read_chunks
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from hashlib import _Hash
|
||||
|
||||
|
||||
# The recommended hash algo of the moment. Change this whenever the state of
|
||||
# the art changes; it won't hurt backward compatibility.
|
||||
FAVORITE_HASH = "sha256"
|
||||
|
||||
|
||||
# Names of hashlib algorithms allowed by the --hash option and ``pip hash``
|
||||
# Currently, those are the ones at least as collision-resistant as sha256.
|
||||
STRONG_HASHES = ["sha256", "sha384", "sha512"]
|
||||
|
||||
|
||||
class Hashes:
|
||||
"""A wrapper that builds multiple hashes at once and checks them against
|
||||
known-good values
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, hashes: dict[str, list[str]] | None = None) -> None:
|
||||
"""
|
||||
:param hashes: A dict of algorithm names pointing to lists of allowed
|
||||
hex digests
|
||||
"""
|
||||
allowed = {}
|
||||
if hashes is not None:
|
||||
for alg, keys in hashes.items():
|
||||
# Make sure values are always sorted (to ease equality checks)
|
||||
allowed[alg] = [k.lower() for k in sorted(keys)]
|
||||
self._allowed = allowed
|
||||
|
||||
def __and__(self, other: Hashes) -> Hashes:
|
||||
if not isinstance(other, Hashes):
|
||||
return NotImplemented
|
||||
|
||||
# If either of the Hashes object is entirely empty (i.e. no hash
|
||||
# specified at all), all hashes from the other object are allowed.
|
||||
if not other:
|
||||
return self
|
||||
if not self:
|
||||
return other
|
||||
|
||||
# Otherwise only hashes that present in both objects are allowed.
|
||||
new = {}
|
||||
for alg, values in other._allowed.items():
|
||||
if alg not in self._allowed:
|
||||
continue
|
||||
new[alg] = [v for v in values if v in self._allowed[alg]]
|
||||
return Hashes(new)
|
||||
|
||||
@property
|
||||
def digest_count(self) -> int:
|
||||
return sum(len(digests) for digests in self._allowed.values())
|
||||
|
||||
def is_hash_allowed(self, hash_name: str, hex_digest: str) -> bool:
|
||||
"""Return whether the given hex digest is allowed."""
|
||||
return hex_digest in self._allowed.get(hash_name, [])
|
||||
|
||||
def check_against_chunks(self, chunks: Iterable[bytes]) -> None:
|
||||
"""Check good hashes against ones built from iterable of chunks of
|
||||
data.
|
||||
|
||||
Raise HashMismatch if none match.
|
||||
|
||||
"""
|
||||
gots = {}
|
||||
for hash_name in self._allowed.keys():
|
||||
try:
|
||||
gots[hash_name] = hashlib.new(hash_name)
|
||||
except (ValueError, TypeError):
|
||||
raise InstallationError(f"Unknown hash name: {hash_name}")
|
||||
|
||||
for chunk in chunks:
|
||||
for hash in gots.values():
|
||||
hash.update(chunk)
|
||||
|
||||
for hash_name, got in gots.items():
|
||||
if got.hexdigest() in self._allowed[hash_name]:
|
||||
return
|
||||
self._raise(gots)
|
||||
|
||||
def _raise(self, gots: dict[str, _Hash]) -> NoReturn:
|
||||
raise HashMismatch(self._allowed, gots)
|
||||
|
||||
def check_against_file(self, file: BinaryIO) -> None:
|
||||
"""Check good hashes against a file-like object
|
||||
|
||||
Raise HashMismatch if none match.
|
||||
|
||||
"""
|
||||
return self.check_against_chunks(read_chunks(file))
|
||||
|
||||
def check_against_path(self, path: str) -> None:
|
||||
with open(path, "rb") as file:
|
||||
return self.check_against_file(file)
|
||||
|
||||
def has_one_of(self, hashes: dict[str, str]) -> bool:
|
||||
"""Return whether any of the given hashes are allowed."""
|
||||
for hash_name, hex_digest in hashes.items():
|
||||
if self.is_hash_allowed(hash_name, hex_digest):
|
||||
return True
|
||||
return False
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
"""Return whether I know any known-good hashes."""
|
||||
return bool(self._allowed)
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
if not isinstance(other, Hashes):
|
||||
return NotImplemented
|
||||
return self._allowed == other._allowed
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(
|
||||
",".join(
|
||||
sorted(
|
||||
":".join((alg, digest))
|
||||
for alg, digest_list in self._allowed.items()
|
||||
for digest in digest_list
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class MissingHashes(Hashes):
|
||||
"""A workalike for Hashes used when we're missing a hash for a requirement
|
||||
|
||||
It computes the actual hash of the requirement and raises a HashMissing
|
||||
exception showing it to the user.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Don't offer the ``hashes`` kwarg."""
|
||||
# Pass our favorite hash in to generate a "gotten hash". With the
|
||||
# empty list, it will never match, so an error will always raise.
|
||||
super().__init__(hashes={FAVORITE_HASH: []})
|
||||
|
||||
def _raise(self, gots: dict[str, _Hash]) -> NoReturn:
|
||||
raise HashMissing(gots[FAVORITE_HASH].hexdigest())
|
||||
Reference in New Issue
Block a user