mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: Fix Python pyright package scoping and typing remediation (#4426)
* Fix Python pyright package scoping and typing remediation Implements issue #4407 by removing the root pyright include, adding package-level pyright includes, and resolving pyright/mypy typing issues across Python packages. Also cleans unnecessary casts and applies line-level, rule-specific ignores where external libraries are too dynamic. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Reduce pyright cost in handoff cloning Simplify cloned_options construction in HandoffAgentExecutor to avoid expensive TypedDict narrowing/inference in _handoff.py, which was causing pyright to spend a long time in orchestrations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix types * Fix lint and type-check regressions Resolve current Python package check failures across lint, pyright, and mypy after recent code changes, including purview/declarative pyright issues and multiple ruff simplification findings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fixed hooks * Stabilize package tests and test tasks Resolve cross-package non-integration test failures, simplify streaming type flow, harden locale/culture handling, and standardize package test poe tasks to exclude integration tests where applicable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * lots of small fixes * Fix current Python test regressions Address current failing unit tests in azure-ai, bedrock, and azure-cosmos while keeping Bedrock parsing logic inline (no new static helper methods). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * small fixes * small fixes * removed pydantic from json * final updates * fix core * fix tests * fix obser --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
4a043c6c66
commit
55ddd841b7
@@ -9,7 +9,7 @@ import os
|
||||
import re
|
||||
import sys
|
||||
from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, MutableMapping, Sequence
|
||||
from typing import Any, ClassVar, Generic, TypedDict
|
||||
from typing import Any, ClassVar, Generic, TypedDict, cast
|
||||
|
||||
from agent_framework import (
|
||||
AGENT_FRAMEWORK_USER_AGENT,
|
||||
@@ -77,9 +77,9 @@ from azure.ai.agents.models import (
|
||||
RunStatus,
|
||||
RunStep,
|
||||
RunStepDeltaChunk,
|
||||
RunStepDeltaCodeInterpreterDetailItemObject,
|
||||
RunStepDeltaCodeInterpreterImageOutput,
|
||||
RunStepDeltaCodeInterpreterLogOutput,
|
||||
RunStepDeltaToolCall,
|
||||
SubmitToolApprovalAction,
|
||||
SubmitToolOutputsAction,
|
||||
ThreadMessageOptions,
|
||||
@@ -704,7 +704,7 @@ class AzureAIAgentClient(
|
||||
args["tool_approvals"] = tool_approvals
|
||||
await self.agents_client.runs.submit_tool_outputs_stream(**args) # type: ignore[reportUnknownMemberType]
|
||||
# Pass the handler to the stream to continue processing
|
||||
stream = handler # type: ignore
|
||||
stream = handler
|
||||
final_thread_id = thread_run.thread_id
|
||||
else:
|
||||
# Handle thread creation or cancellation
|
||||
@@ -881,7 +881,7 @@ class AzureAIAgentClient(
|
||||
azure_search_tool_calls: list[dict[str, Any]] = []
|
||||
response_stream = await stream.__aenter__() if isinstance(stream, AsyncAgentRunStream) else stream # type: ignore[no-untyped-call]
|
||||
try:
|
||||
async for event_type, event_data, _ in response_stream: # type: ignore
|
||||
async for event_type, event_data, _ in response_stream:
|
||||
match event_data:
|
||||
case MessageDeltaChunk():
|
||||
# only one event_type: AgentStreamEvent.THREAD_MESSAGE_DELTA
|
||||
@@ -997,21 +997,16 @@ class AzureAIAgentClient(
|
||||
role="assistant",
|
||||
)
|
||||
case RunStepDeltaChunk(): # type: ignore
|
||||
if (
|
||||
event_data.delta.step_details is not None
|
||||
and event_data.delta.step_details.type == "tool_calls"
|
||||
and event_data.delta.step_details.tool_calls is not None # type: ignore[attr-defined]
|
||||
):
|
||||
for tool_call in event_data.delta.step_details.tool_calls: # type: ignore[attr-defined]
|
||||
if tool_call.type == "code_interpreter" and isinstance(
|
||||
tool_call.code_interpreter,
|
||||
RunStepDeltaCodeInterpreterDetailItemObject,
|
||||
):
|
||||
step_details = event_data.delta.step_details
|
||||
if step_details is not None and step_details.type == "tool_calls":
|
||||
tool_calls = cast(list[RunStepDeltaToolCall], step_details.tool_calls) # type: ignore
|
||||
for tool_call in tool_calls:
|
||||
if tool_call.type == "code_interpreter" and tool_call.code_interpreter is not None: # type: ignore[attr-defined, reportUnknownMemberType]
|
||||
code_contents: list[Content] = []
|
||||
if tool_call.code_interpreter.input is not None:
|
||||
logger.debug(f"Code Interpreter Input: {tool_call.code_interpreter.input}")
|
||||
if tool_call.code_interpreter.outputs is not None:
|
||||
for output in tool_call.code_interpreter.outputs:
|
||||
if tool_call.code_interpreter.input is not None: # type: ignore[attr-defined, reportUnknownMemberType]
|
||||
logger.debug(f"Code Interpreter Input: {tool_call.code_interpreter.input}") # type: ignore[attr-defined, reportUnknownMemberType]
|
||||
if tool_call.code_interpreter.outputs is not None: # type: ignore[attr-defined, reportUnknownMemberType]
|
||||
for output in tool_call.code_interpreter.outputs: # type: ignore[attr-defined, reportUnknownMemberType]
|
||||
if isinstance(output, RunStepDeltaCodeInterpreterLogOutput) and output.logs:
|
||||
code_contents.append(Content.from_text(text=output.logs))
|
||||
if (
|
||||
@@ -1027,7 +1022,7 @@ class AzureAIAgentClient(
|
||||
contents=code_contents,
|
||||
conversation_id=thread_id,
|
||||
message_id=response_id,
|
||||
raw_representation=tool_call.code_interpreter,
|
||||
raw_representation=tool_call.code_interpreter, # type: ignore[attr-defined, reportUnknownMemberType]
|
||||
response_id=response_id,
|
||||
)
|
||||
case _: # ThreadMessage or string
|
||||
@@ -1056,17 +1051,15 @@ class AzureAIAgentClient(
|
||||
) -> None:
|
||||
"""Capture Azure AI Search tool call data from completed steps."""
|
||||
try:
|
||||
if (
|
||||
hasattr(step_data, "step_details")
|
||||
and hasattr(step_data.step_details, "tool_calls")
|
||||
and step_data.step_details.tool_calls
|
||||
):
|
||||
for tool_call in step_data.step_details.tool_calls:
|
||||
if hasattr(tool_call, "type") and tool_call.type == "azure_ai_search":
|
||||
step_details = getattr(step_data, "step_details", None)
|
||||
tool_calls = getattr(step_details, "tool_calls", None) if step_details is not None else None
|
||||
if isinstance(tool_calls, list):
|
||||
for tool_call in cast(list[object], tool_calls):
|
||||
if getattr(tool_call, "type", None) == "azure_ai_search":
|
||||
# Store the complete tool call as a dictionary
|
||||
tool_call_dict = {
|
||||
"id": getattr(tool_call, "id", None),
|
||||
"type": tool_call.type,
|
||||
"type": getattr(tool_call, "type", None),
|
||||
"azure_ai_search": getattr(tool_call, "azure_ai_search", None),
|
||||
}
|
||||
azure_search_tool_calls.append(tool_call_dict)
|
||||
@@ -1219,19 +1212,18 @@ class AzureAIAgentClient(
|
||||
self, options: Mapping[str, Any]
|
||||
) -> AgentsToolChoiceOptionMode | AgentsNamedToolChoice | None:
|
||||
"""Prepare the tool choice mode for Azure AI Agents API."""
|
||||
tool_choice = options.get("tool_choice")
|
||||
tool_choice = cast(str | dict[str, str] | None, options.get("tool_choice"))
|
||||
if tool_choice is None:
|
||||
return None
|
||||
if tool_choice == "none":
|
||||
return AgentsToolChoiceOptionMode.NONE
|
||||
if tool_choice == "auto":
|
||||
return AgentsToolChoiceOptionMode.AUTO
|
||||
if isinstance(tool_choice, Mapping) and tool_choice.get("mode") == "required":
|
||||
if isinstance(tool_choice, str) and tool_choice in {"none", "auto"}:
|
||||
return AgentsToolChoiceOptionMode(tool_choice)
|
||||
if isinstance(tool_choice, dict):
|
||||
mode = tool_choice.get("mode")
|
||||
req_fn = tool_choice.get("required_function_name")
|
||||
if req_fn:
|
||||
if mode == "required" and req_fn is not None:
|
||||
return AgentsNamedToolChoice(
|
||||
type=AgentsNamedToolChoiceType.FUNCTION,
|
||||
function=FunctionName(name=str(req_fn)),
|
||||
function=FunctionName(name=req_fn),
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -1369,14 +1361,9 @@ class AzureAIAgentClient(
|
||||
# SDK Tool wrappers (McpTool, FileSearchTool, BingGroundingTool, etc.)
|
||||
tool_definitions.extend(tool.definitions)
|
||||
# Handle tool resources (MCP resources handled separately by _prepare_mcp_resources)
|
||||
if (
|
||||
run_options is not None
|
||||
and hasattr(tool, "resources")
|
||||
and tool.resources
|
||||
and "mcp" not in tool.resources
|
||||
):
|
||||
if "tool_resources" not in run_options:
|
||||
run_options["tool_resources"] = {}
|
||||
resources = getattr(tool, "resources", None)
|
||||
if run_options is not None and resources and isinstance(resources, Mapping) and "mcp" not in resources:
|
||||
run_options.setdefault("tool_resources", {})
|
||||
run_options["tool_resources"].update(tool.resources)
|
||||
else:
|
||||
# Pass through ToolDefinition, dict, and other types unchanged
|
||||
|
||||
@@ -6,7 +6,7 @@ import json
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
||||
from collections.abc import Awaitable, Callable, Mapping, MutableMapping, Sequence
|
||||
from contextlib import suppress
|
||||
from typing import Any, ClassVar, Generic, Literal, TypedDict, TypeVar, cast
|
||||
|
||||
@@ -304,7 +304,7 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
|
||||
|
||||
# Import Azure Monitor with proper error handling
|
||||
try:
|
||||
from azure.monitor.opentelemetry import configure_azure_monitor
|
||||
from azure.monitor.opentelemetry import configure_azure_monitor # type: ignore[import]
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"azure-monitor-opentelemetry is required for Azure Monitor integration. "
|
||||
@@ -433,31 +433,36 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
|
||||
"""Extract comparable tool names from runtime tool payloads."""
|
||||
if not isinstance(tools, Sequence) or isinstance(tools, str | bytes):
|
||||
return set()
|
||||
return {self._get_tool_name(tool) for tool in tools}
|
||||
tool_names: set[str] = set()
|
||||
for tool_item in cast(Sequence[object], tools):
|
||||
tool_names.add(self._get_tool_name(tool_item))
|
||||
return tool_names
|
||||
|
||||
def _get_tool_name(self, tool: Any) -> str:
|
||||
"""Get a stable name for a tool for runtime comparison."""
|
||||
if isinstance(tool, FunctionTool):
|
||||
return tool.name
|
||||
|
||||
if isinstance(tool, Mapping):
|
||||
tool_type = tool.get("type")
|
||||
tool_type = tool.get("type") # type: ignore[reportUnknownMemberType]
|
||||
if tool_type == "function":
|
||||
if isinstance(function_data := tool.get("function"), Mapping) and function_data.get("name"):
|
||||
return str(function_data["name"])
|
||||
if tool.get("name"):
|
||||
return str(tool["name"])
|
||||
if tool.get("name"):
|
||||
return str(tool["name"])
|
||||
if tool.get("server_label"):
|
||||
return f"mcp:{tool['server_label']}"
|
||||
function_data = tool.get("function") # type: ignore[reportUnknownMemberType]
|
||||
if isinstance(function_data, Mapping) and (function_name := function_data.get("name")): # type: ignore[assignment]
|
||||
return function_name # type: ignore[no-any-return]
|
||||
if tool_name := tool.get("name"): # type: ignore[reportUnknownMemberType]
|
||||
return tool_name # type: ignore[no-any-return]
|
||||
if server_label := tool.get("server_label"): # type: ignore[reportUnknownMemberType]
|
||||
return f"mcp:{server_label}"
|
||||
if tool_type:
|
||||
return str(tool_type)
|
||||
if getattr(tool, "name", None):
|
||||
return str(tool.name)
|
||||
if getattr(tool, "server_label", None):
|
||||
return f"mcp:{tool.server_label}"
|
||||
if getattr(tool, "type", None):
|
||||
return str(tool.type)
|
||||
return tool_type # type: ignore[no-any-return]
|
||||
raise ValueError("Dict based tool definitions must include a 'name' property for runtime comparison.")
|
||||
|
||||
if name_value := getattr(tool, "name", None):
|
||||
return name_value # type: ignore[no-any-return]
|
||||
if server_label_value := getattr(tool, "server_label", None):
|
||||
return f"mcp:{server_label_value}"
|
||||
if tool_type_value := getattr(tool, "type", None):
|
||||
return tool_type_value # type: ignore[no-any-return]
|
||||
return type(tool).__name__
|
||||
|
||||
def _get_structured_output_signature(self, chat_options: Mapping[str, Any] | None) -> str | None:
|
||||
@@ -545,14 +550,14 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
|
||||
return run_options
|
||||
|
||||
@override
|
||||
def _check_model_presence(self, run_options: dict[str, Any]) -> None:
|
||||
def _check_model_presence(self, options: dict[str, Any]) -> None:
|
||||
# Skip model check for application endpoints - model is pre-configured on server
|
||||
if self._is_application_endpoint:
|
||||
return
|
||||
if not run_options.get("model"):
|
||||
if not options.get("model"):
|
||||
if not self.model_id:
|
||||
raise ValueError("model_deployment_name must be a non-empty string")
|
||||
run_options["model"] = self.model_id
|
||||
options["model"] = self.model_id
|
||||
|
||||
def _transform_input_for_azure_ai(self, input_items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Transform input items to match Azure AI Projects expected schema.
|
||||
@@ -575,15 +580,14 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
|
||||
|
||||
# Add 'annotations' only to output_text content items (assistant messages)
|
||||
# User messages (input_text) do NOT support annotations in Azure AI
|
||||
if "content" in new_item and isinstance(new_item["content"], list):
|
||||
new_content: list[dict[str, Any] | Any] = []
|
||||
for content_item in new_item["content"]:
|
||||
if isinstance(content_item, dict):
|
||||
new_content_item: dict[str, Any] = dict(content_item)
|
||||
if (content := new_item.get("content")) and isinstance(content, list):
|
||||
new_content: list[Any] = []
|
||||
for content_item in content: # type: ignore[list-item]
|
||||
if isinstance(content_item, MutableMapping):
|
||||
# Only add annotations to output_text (assistant content)
|
||||
if new_content_item.get("type") == "output_text" and "annotations" not in new_content_item:
|
||||
new_content_item["annotations"] = []
|
||||
new_content.append(new_content_item)
|
||||
if content_item.get("type") == "output_text" and "annotations" not in content_item: # type: ignore[reportUnknownMemberType]
|
||||
content_item["annotations"] = []
|
||||
new_content.append(content_item)
|
||||
else:
|
||||
new_content.append(content_item)
|
||||
new_item["content"] = new_content
|
||||
@@ -721,9 +725,13 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
|
||||
# Streaming "added" events send output as an empty list; skip.
|
||||
continue
|
||||
if output is not None:
|
||||
urls = output.get("get_urls") if isinstance(output, dict) else output.get_urls
|
||||
if urls and isinstance(urls, list):
|
||||
get_urls.extend(urls)
|
||||
urls = output.get("get_urls") if isinstance(output, Mapping) else getattr(output, "get_urls", None) # type: ignore
|
||||
if isinstance(urls, list):
|
||||
string_urls: list[str] = []
|
||||
for url_item in urls: # type: ignore[list-item]
|
||||
if isinstance(url_item, str):
|
||||
string_urls.append(url_item)
|
||||
get_urls.extend(string_urls)
|
||||
return get_urls
|
||||
|
||||
def _get_search_doc_url(self, citation_title: str | None, get_urls: list[str]) -> str | None:
|
||||
@@ -878,7 +886,7 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
|
||||
contents=contents_list,
|
||||
conversation_id=update.conversation_id,
|
||||
response_id=update.response_id,
|
||||
role=update.role,
|
||||
role=update.role, # type: ignore[union-attr]
|
||||
model_id=update.model_id,
|
||||
continuation_token=update.continuation_token,
|
||||
additional_properties=update.additional_properties,
|
||||
|
||||
@@ -186,7 +186,7 @@ class RawAzureAIInferenceEmbeddingClient(
|
||||
values: Sequence[Content | str],
|
||||
*,
|
||||
options: AzureAIInferenceEmbeddingOptionsT | None = None,
|
||||
) -> GeneratedEmbeddings[list[float]]:
|
||||
) -> GeneratedEmbeddings[list[float], AzureAIInferenceEmbeddingOptionsT]:
|
||||
"""Generate embeddings for text and/or image inputs.
|
||||
|
||||
Text inputs (``str`` or ``Content`` with ``type="text"``) are sent to the
|
||||
|
||||
@@ -224,7 +224,7 @@ class AzureAIProjectAgentProvider(Generic[OptionsCoT]):
|
||||
if isinstance(tool, MCPTool):
|
||||
mcp_tools.append(tool)
|
||||
elif isinstance(tool, (FunctionTool, MutableMapping)):
|
||||
non_mcp_tools.append(tool)
|
||||
non_mcp_tools.append(tool) # type: ignore[reportUnknownArgumentType]
|
||||
|
||||
# Connect MCP tools and discover their functions BEFORE creating the agent
|
||||
# This is required because Azure AI Responses API doesn't accept tools at request time
|
||||
|
||||
@@ -79,7 +79,7 @@ class AzureAISettings(TypedDict, total=False):
|
||||
model_deployment_name: str | None
|
||||
|
||||
|
||||
def _extract_project_connection_id(additional_properties: dict[str, Any] | None) -> str | None:
|
||||
def _extract_project_connection_id(additional_properties: Mapping[str, Any] | None) -> str | None:
|
||||
"""Extract project_connection_id from tool additional_properties.
|
||||
|
||||
Checks for both direct 'project_connection_id' key (programmatic usage)
|
||||
@@ -95,17 +95,18 @@ def _extract_project_connection_id(additional_properties: dict[str, Any] | None)
|
||||
return None
|
||||
|
||||
# Check for direct project_connection_id (programmatic usage)
|
||||
project_connection_id = additional_properties.get("project_connection_id")
|
||||
if isinstance(project_connection_id, str):
|
||||
return project_connection_id
|
||||
|
||||
if (proj_conn_id := additional_properties.get("project_connection_id")) and isinstance(proj_conn_id, str):
|
||||
return proj_conn_id # type: ignore[no-any-return]
|
||||
|
||||
# Check for connection.name structure (declarative/YAML usage)
|
||||
if "connection" in additional_properties:
|
||||
conn = additional_properties["connection"]
|
||||
if isinstance(conn, dict):
|
||||
name = conn.get("name")
|
||||
if isinstance(name, str):
|
||||
return name
|
||||
if (
|
||||
(connection := additional_properties.get("connection"))
|
||||
and isinstance(connection, Mapping)
|
||||
and (name := connection.get("name")) # type: ignore
|
||||
and isinstance(name, str)
|
||||
):
|
||||
return name # type: ignore[no-any-return]
|
||||
|
||||
return None
|
||||
|
||||
@@ -189,9 +190,9 @@ def to_azure_ai_agent_tools(
|
||||
and tool.resources
|
||||
and "mcp" not in tool.resources
|
||||
):
|
||||
if "tool_resources" not in run_options:
|
||||
run_options["tool_resources"] = {}
|
||||
run_options["tool_resources"].update(tool.resources)
|
||||
run_options.setdefault("tool_resources", {})
|
||||
if isinstance(tool.resources, Mapping):
|
||||
run_options["tool_resources"].update(tool.resources)
|
||||
elif isinstance(tool, (dict, MutableMapping)):
|
||||
# Handle dict-based tools - pass through directly
|
||||
tool_dict = tool if isinstance(tool, dict) else dict(tool)
|
||||
@@ -422,9 +423,16 @@ def to_azure_ai_tools(
|
||||
elif isinstance(tool, Tool):
|
||||
# Pass through SDK Tool types directly (CodeInterpreterTool, FileSearchTool, etc.)
|
||||
azure_tools.append(tool)
|
||||
elif isinstance(tool, MutableMapping):
|
||||
# Convert mutable mappings into plain dicts for stable typing.
|
||||
tool_dict: dict[str, Any] = dict(tool)
|
||||
if tool_dict.get("type") == "mcp":
|
||||
azure_tools.append(_prepare_mcp_tool_dict_for_azure_ai(tool_dict))
|
||||
else:
|
||||
azure_tools.append(tool_dict)
|
||||
else:
|
||||
# Pass through dict-based tools directly
|
||||
azure_tools.append(dict(tool) if isinstance(tool, MutableMapping) else tool) # type: ignore[arg-type]
|
||||
# Pass through any other supported tool objects unchanged.
|
||||
azure_tools.append(tool)
|
||||
|
||||
return azure_tools
|
||||
|
||||
@@ -446,7 +454,16 @@ def _prepare_mcp_tool_dict_for_azure_ai(tool_dict: dict[str, Any]) -> MCPTool:
|
||||
mcp["server_description"] = description
|
||||
|
||||
# Check for project_connection_id
|
||||
if project_connection_id := tool_dict.get("project_connection_id"):
|
||||
project_connection_id = tool_dict.get("project_connection_id")
|
||||
if not isinstance(project_connection_id, str):
|
||||
additional_properties = tool_dict.get("additional_properties")
|
||||
project_connection_id = (
|
||||
_extract_project_connection_id(additional_properties) # pyright: ignore[reportUnknownArgumentType]
|
||||
if isinstance(additional_properties, Mapping)
|
||||
else None
|
||||
)
|
||||
|
||||
if project_connection_id:
|
||||
mcp["project_connection_id"] = project_connection_id
|
||||
elif headers := tool_dict.get("headers"):
|
||||
mcp["headers"] = headers
|
||||
|
||||
@@ -61,6 +61,7 @@ omit = [
|
||||
|
||||
[tool.pyright]
|
||||
extends = "../../pyproject.toml"
|
||||
include = ["agent_framework_azure_ai"]
|
||||
|
||||
[tool.mypy]
|
||||
plugins = ['pydantic.mypy']
|
||||
@@ -86,7 +87,7 @@ include = "../../shared_tasks.toml"
|
||||
|
||||
[tool.poe.tasks]
|
||||
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azure_ai"
|
||||
test = "pytest --cov=agent_framework_azure_ai --cov-report=term-missing:skip-covered tests"
|
||||
test = "pytest -m \"not integration\" --cov=agent_framework_azure_ai --cov-report=term-missing:skip-covered tests"
|
||||
|
||||
[tool.poe.tasks.integration-tests]
|
||||
cmd = """
|
||||
|
||||
Reference in New Issue
Block a user