feat(webdav): Add search and list favorite response tools
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
"""Integration tests for WebDAV search operations."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mark all tests in this module as integration tests
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_search_setup(nc_client: NextcloudClient):
|
||||
"""Create test files and directories for search testing."""
|
||||
test_dir = f"mcp_search_test_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Create base directory
|
||||
await nc_client.webdav.create_directory(test_dir)
|
||||
|
||||
# Create various test files
|
||||
test_files = [
|
||||
# Text files
|
||||
(f"{test_dir}/document1.txt", b"Sample document content", "text/plain"),
|
||||
(f"{test_dir}/document2.txt", b"Another document", "text/plain"),
|
||||
(f"{test_dir}/report.txt", b"Report content", "text/plain"),
|
||||
# Markdown files
|
||||
(f"{test_dir}/readme.md", b"# README\nMarkdown content", "text/markdown"),
|
||||
(f"{test_dir}/notes.md", b"# Notes\nSome notes here", "text/markdown"),
|
||||
# PDF (simulated as binary)
|
||||
(
|
||||
f"{test_dir}/presentation.pdf",
|
||||
b"%PDF-1.4 fake pdf content",
|
||||
"application/pdf",
|
||||
),
|
||||
# Subdirectory with files
|
||||
(f"{test_dir}/subdir/nested.txt", b"Nested file content", "text/plain"),
|
||||
]
|
||||
|
||||
# Create subdirectory
|
||||
await nc_client.webdav.create_directory(f"{test_dir}/subdir")
|
||||
|
||||
# Write all test files
|
||||
for file_path, content, content_type in test_files:
|
||||
await nc_client.webdav.write_file(file_path, content, content_type)
|
||||
|
||||
logger.info(f"Created test directory with {len(test_files)} files: {test_dir}")
|
||||
|
||||
yield test_dir
|
||||
|
||||
# Cleanup
|
||||
try:
|
||||
await nc_client.webdav.delete_resource(test_dir)
|
||||
logger.info(f"Cleaned up test directory: {test_dir}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup test directory {test_dir}: {e}")
|
||||
|
||||
|
||||
async def test_find_by_name_exact(nc_client: NextcloudClient, test_search_setup: str):
|
||||
"""Test finding files by exact name."""
|
||||
results = await nc_client.webdav.find_by_name("readme.md", scope=test_search_setup)
|
||||
|
||||
assert len(results) >= 1, "Should find at least one readme.md file"
|
||||
|
||||
# Check that we found the right file
|
||||
readme_files = [r for r in results if r.get("name") == "readme.md"]
|
||||
assert len(readme_files) >= 1, "Should find readme.md"
|
||||
|
||||
logger.info(f"Found {len(results)} files matching 'readme.md'")
|
||||
|
||||
|
||||
async def test_find_by_name_wildcard_extension(
|
||||
nc_client: NextcloudClient, test_search_setup: str
|
||||
):
|
||||
"""Test finding files by extension using wildcard."""
|
||||
# Find all .txt files
|
||||
results = await nc_client.webdav.find_by_name("%.txt", scope=test_search_setup)
|
||||
|
||||
assert len(results) >= 3, "Should find at least 3 .txt files"
|
||||
|
||||
# Verify all results are .txt files
|
||||
for result in results:
|
||||
name = result.get("name", "")
|
||||
assert name.endswith(".txt"), f"Expected .txt file, got {name}"
|
||||
|
||||
logger.info(f"Found {len(results)} .txt files")
|
||||
|
||||
|
||||
async def test_find_by_name_wildcard_prefix(
|
||||
nc_client: NextcloudClient, test_search_setup: str
|
||||
):
|
||||
"""Test finding files by name prefix using wildcard."""
|
||||
# Find all files starting with "document"
|
||||
results = await nc_client.webdav.find_by_name("document%", scope=test_search_setup)
|
||||
|
||||
assert len(results) >= 2, "Should find at least 2 files starting with 'document'"
|
||||
|
||||
# Verify all results start with "document"
|
||||
for result in results:
|
||||
name = result.get("name", "")
|
||||
assert name.startswith("document"), (
|
||||
f"Expected name to start with 'document', got {name}"
|
||||
)
|
||||
|
||||
logger.info(f"Found {len(results)} files starting with 'document'")
|
||||
|
||||
|
||||
async def test_find_by_type_text(nc_client: NextcloudClient, test_search_setup: str):
|
||||
"""Test finding files by MIME type (text files)."""
|
||||
# Find all text files
|
||||
results = await nc_client.webdav.find_by_type("text/%", scope=test_search_setup)
|
||||
|
||||
assert len(results) >= 5, "Should find at least 5 text files"
|
||||
|
||||
# Verify all results are text files
|
||||
for result in results:
|
||||
content_type = result.get("content_type", "")
|
||||
assert content_type.startswith("text/"), (
|
||||
f"Expected text/* type, got {content_type}"
|
||||
)
|
||||
|
||||
logger.info(f"Found {len(results)} text files")
|
||||
|
||||
|
||||
async def test_find_by_type_specific(
|
||||
nc_client: NextcloudClient, test_search_setup: str
|
||||
):
|
||||
"""Test finding files by specific MIME type."""
|
||||
# Find PDF files
|
||||
results = await nc_client.webdav.find_by_type(
|
||||
"application/pdf", scope=test_search_setup
|
||||
)
|
||||
|
||||
assert len(results) >= 1, "Should find at least 1 PDF file"
|
||||
|
||||
# Verify result is PDF
|
||||
for result in results:
|
||||
content_type = result.get("content_type", "")
|
||||
assert content_type == "application/pdf", (
|
||||
f"Expected application/pdf, got {content_type}"
|
||||
)
|
||||
|
||||
logger.info(f"Found {len(results)} PDF files")
|
||||
|
||||
|
||||
async def test_search_with_limit(nc_client: NextcloudClient, test_search_setup: str):
|
||||
"""Test search with result limit."""
|
||||
# Search for .txt files with limit of 2
|
||||
results = await nc_client.webdav.find_by_name(
|
||||
"%.txt", scope=test_search_setup, limit=2
|
||||
)
|
||||
|
||||
# Should return at most 2 results
|
||||
assert len(results) <= 2, f"Should return at most 2 results, got {len(results)}"
|
||||
assert len(results) > 0, "Should return at least 1 result"
|
||||
|
||||
logger.info(f"Found {len(results)} files with limit=2")
|
||||
|
||||
|
||||
async def test_search_files_combined_filters(
|
||||
nc_client: NextcloudClient, test_search_setup: str
|
||||
):
|
||||
"""Test search with multiple filters combined."""
|
||||
# This test uses the search_files method directly to test combined conditions
|
||||
# Search for .txt files that match a specific pattern
|
||||
where_conditions = """
|
||||
<d:and>
|
||||
<d:like>
|
||||
<d:prop>
|
||||
<d:displayname/>
|
||||
</d:prop>
|
||||
<d:literal>%.txt</d:literal>
|
||||
</d:like>
|
||||
<d:like>
|
||||
<d:prop>
|
||||
<d:displayname/>
|
||||
</d:prop>
|
||||
<d:literal>document%</d:literal>
|
||||
</d:like>
|
||||
</d:and>
|
||||
"""
|
||||
|
||||
results = await nc_client.webdav.search_files(
|
||||
scope=test_search_setup, where_conditions=where_conditions
|
||||
)
|
||||
|
||||
# Should find document1.txt and document2.txt
|
||||
assert len(results) >= 2, "Should find at least 2 files matching both conditions"
|
||||
|
||||
# Verify results match both conditions
|
||||
for result in results:
|
||||
name = result.get("name", "")
|
||||
assert name.endswith(".txt"), f"Expected .txt file, got {name}"
|
||||
assert name.startswith("document"), (
|
||||
f"Expected name to start with 'document', got {name}"
|
||||
)
|
||||
|
||||
logger.info(f"Found {len(results)} files matching combined filters")
|
||||
|
||||
|
||||
async def test_search_empty_scope(nc_client: NextcloudClient, test_search_setup: str):
|
||||
"""Test search in empty scope (user root)."""
|
||||
# Search entire user root for a unique filename
|
||||
unique_name = "readme.md"
|
||||
results = await nc_client.webdav.find_by_name(unique_name, scope="")
|
||||
|
||||
# Should find at least the one we created
|
||||
assert len(results) >= 1, f"Should find at least 1 file named {unique_name}"
|
||||
|
||||
logger.info(f"Found {len(results)} files in root scope")
|
||||
|
||||
|
||||
async def test_search_subdirectory(nc_client: NextcloudClient, test_search_setup: str):
|
||||
"""Test search within a subdirectory."""
|
||||
# Search in the subdir for the nested file
|
||||
results = await nc_client.webdav.find_by_name(
|
||||
"nested.txt", scope=f"{test_search_setup}/subdir"
|
||||
)
|
||||
|
||||
assert len(results) >= 1, "Should find nested.txt in subdirectory"
|
||||
|
||||
# Verify the file path
|
||||
nested_file = results[0]
|
||||
assert "nested.txt" in nested_file.get("name", ""), "Should find nested.txt"
|
||||
|
||||
logger.info(f"Found file in subdirectory: {nested_file.get('name')}")
|
||||
|
||||
|
||||
async def test_search_no_results(nc_client: NextcloudClient, test_search_setup: str):
|
||||
"""Test search that returns no results."""
|
||||
# Search for a non-existent pattern
|
||||
results = await nc_client.webdav.find_by_name(
|
||||
"nonexistent_file_xyz123.txt", scope=test_search_setup
|
||||
)
|
||||
|
||||
assert len(results) == 0, "Should return empty results for non-existent file"
|
||||
|
||||
logger.info("Search correctly returned no results for non-existent file")
|
||||
|
||||
|
||||
async def test_search_properties_returned(
|
||||
nc_client: NextcloudClient, test_search_setup: str
|
||||
):
|
||||
"""Test that search returns expected properties."""
|
||||
results = await nc_client.webdav.find_by_name("readme.md", scope=test_search_setup)
|
||||
|
||||
assert len(results) >= 1, "Should find at least one file"
|
||||
|
||||
result = results[0]
|
||||
|
||||
# Check for expected properties
|
||||
assert "name" in result, "Should include name property"
|
||||
assert "path" in result, "Should include path property"
|
||||
assert "is_directory" in result, "Should include is_directory property"
|
||||
assert result["is_directory"] is False, "readme.md should not be a directory"
|
||||
|
||||
# Optional properties that may be present
|
||||
optional_props = ["size", "content_type", "last_modified", "etag"]
|
||||
logger.info(f"Result properties: {list(result.keys())}")
|
||||
|
||||
# At least some optional properties should be present
|
||||
present_optional = [prop for prop in optional_props if prop in result]
|
||||
assert len(present_optional) > 0, f"Should have at least one of {optional_props}"
|
||||
|
||||
logger.info(f"Search returned properties: {list(result.keys())}")
|
||||
@@ -40,6 +40,12 @@ async def test_mcp_connectivity(nc_mcp_client: ClientSession):
|
||||
"nc_webdav_write_file",
|
||||
"nc_webdav_create_directory",
|
||||
"nc_webdav_delete_resource",
|
||||
"nc_webdav_move_resource",
|
||||
"nc_webdav_copy_resource",
|
||||
"nc_webdav_search_files",
|
||||
"nc_webdav_find_by_name",
|
||||
"nc_webdav_find_by_type",
|
||||
"nc_webdav_list_favorites",
|
||||
"nc_calendar_list_calendars",
|
||||
"nc_calendar_create_event",
|
||||
"nc_calendar_list_events",
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
"""Integration tests for WebDAV search MCP tools."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from mcp import ClientSession
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def normalize_search_response(data):
|
||||
"""Extract results list from SearchFilesResponse.
|
||||
|
||||
The response is a SearchFilesResponse with a 'results' field containing the list of files.
|
||||
"""
|
||||
if isinstance(data, dict) and "results" in data:
|
||||
return data["results"]
|
||||
else:
|
||||
# Fallback for unexpected format
|
||||
return []
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def search_test_files(nc_client: NextcloudClient):
|
||||
"""Create test files for WebDAV search testing via MCP."""
|
||||
test_dir = f"mcp_webdav_search_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Create base directory
|
||||
await nc_client.webdav.create_directory(test_dir)
|
||||
|
||||
# Create various test files
|
||||
test_files = [
|
||||
# Text files
|
||||
(f"{test_dir}/search_test1.txt", b"Sample document", "text/plain"),
|
||||
(f"{test_dir}/search_test2.txt", b"Another document", "text/plain"),
|
||||
(f"{test_dir}/search_report.txt", b"Report content", "text/plain"),
|
||||
# Markdown files
|
||||
(f"{test_dir}/search_readme.md", b"# README", "text/markdown"),
|
||||
(f"{test_dir}/search_notes.md", b"# Notes", "text/markdown"),
|
||||
# Images (simulated)
|
||||
(f"{test_dir}/search_image.jpg", b"\xff\xd8\xff fake jpg", "image/jpeg"),
|
||||
(f"{test_dir}/search_photo.png", b"\x89PNG fake png", "image/png"),
|
||||
# PDF (simulated)
|
||||
(f"{test_dir}/search_presentation.pdf", b"%PDF-1.4", "application/pdf"),
|
||||
]
|
||||
|
||||
# Write all test files
|
||||
for file_path, content, content_type in test_files:
|
||||
await nc_client.webdav.write_file(file_path, content, content_type)
|
||||
|
||||
logger.info(f"Created {len(test_files)} test files in {test_dir}")
|
||||
|
||||
yield test_dir
|
||||
|
||||
# Cleanup
|
||||
try:
|
||||
await nc_client.webdav.delete_resource(test_dir)
|
||||
logger.info(f"Cleaned up test directory: {test_dir}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup {test_dir}: {e}")
|
||||
|
||||
|
||||
async def test_nc_webdav_find_by_name(
|
||||
nc_mcp_client: ClientSession, search_test_files: str
|
||||
):
|
||||
"""Test nc_webdav_find_by_name MCP tool."""
|
||||
# Find all .txt files in the test directory
|
||||
result = await nc_mcp_client.call_tool(
|
||||
"nc_webdav_find_by_name",
|
||||
arguments={
|
||||
"pattern": "search_%.txt",
|
||||
"scope": search_test_files,
|
||||
},
|
||||
)
|
||||
|
||||
# Parse the result
|
||||
content = result.content[0].text
|
||||
files = normalize_search_response(json.loads(content))
|
||||
|
||||
logger.info(f"Found {len(files)} files matching 'search_%.txt'")
|
||||
|
||||
# Should find at least 3 .txt files
|
||||
assert len(files) >= 3, f"Expected at least 3 .txt files, got {len(files)}"
|
||||
|
||||
# Verify all results end with .txt
|
||||
for file in files:
|
||||
name = file.get("name", "")
|
||||
assert name.endswith(".txt"), f"Expected .txt file, got {name}"
|
||||
assert name.startswith("search_"), (
|
||||
f"Expected name to start with 'search_', got {name}"
|
||||
)
|
||||
|
||||
|
||||
async def test_nc_webdav_find_by_name_with_limit(
|
||||
nc_mcp_client: ClientSession, search_test_files: str
|
||||
):
|
||||
"""Test nc_webdav_find_by_name with limit parameter."""
|
||||
# Find files with limit
|
||||
result = await nc_mcp_client.call_tool(
|
||||
"nc_webdav_find_by_name",
|
||||
arguments={
|
||||
"pattern": "search_%.txt",
|
||||
"scope": search_test_files,
|
||||
"limit": 2,
|
||||
},
|
||||
)
|
||||
|
||||
content = result.content[0].text
|
||||
files = normalize_search_response(json.loads(content))
|
||||
|
||||
logger.info(f"Found {len(files)} files with limit=2")
|
||||
|
||||
# Should return at most 2 results
|
||||
assert len(files) <= 2, f"Expected at most 2 files, got {len(files)}"
|
||||
assert len(files) > 0, "Expected at least 1 file"
|
||||
|
||||
|
||||
async def test_nc_webdav_find_by_type_images(
|
||||
nc_mcp_client: ClientSession, search_test_files: str
|
||||
):
|
||||
"""Test nc_webdav_find_by_type for images."""
|
||||
# Find all images
|
||||
result = await nc_mcp_client.call_tool(
|
||||
"nc_webdav_find_by_type",
|
||||
arguments={
|
||||
"mime_type": "image/%",
|
||||
"scope": search_test_files,
|
||||
},
|
||||
)
|
||||
|
||||
content = result.content[0].text
|
||||
files = normalize_search_response(json.loads(content))
|
||||
|
||||
logger.info(f"Found {len(files)} image files")
|
||||
|
||||
# Should find at least 2 image files (jpg and png)
|
||||
assert len(files) >= 2, f"Expected at least 2 image files, got {len(files)}"
|
||||
|
||||
# Verify all results are images
|
||||
for file in files:
|
||||
content_type = file.get("content_type", "")
|
||||
assert content_type.startswith("image/"), (
|
||||
f"Expected image/* type, got {content_type}"
|
||||
)
|
||||
|
||||
|
||||
async def test_nc_webdav_find_by_type_specific(
|
||||
nc_mcp_client: ClientSession, search_test_files: str
|
||||
):
|
||||
"""Test nc_webdav_find_by_type for specific MIME type."""
|
||||
# Find PDF files
|
||||
result = await nc_mcp_client.call_tool(
|
||||
"nc_webdav_find_by_type",
|
||||
arguments={
|
||||
"mime_type": "application/pdf",
|
||||
"scope": search_test_files,
|
||||
},
|
||||
)
|
||||
|
||||
content = result.content[0].text
|
||||
files = normalize_search_response(json.loads(content))
|
||||
|
||||
logger.info(f"Found {len(files)} PDF files")
|
||||
|
||||
# Should find at least 1 PDF
|
||||
assert len(files) >= 1, f"Expected at least 1 PDF file, got {len(files)}"
|
||||
|
||||
# Verify result is PDF
|
||||
for file in files:
|
||||
content_type = file.get("content_type", "")
|
||||
assert content_type == "application/pdf", (
|
||||
f"Expected application/pdf, got {content_type}"
|
||||
)
|
||||
|
||||
|
||||
async def test_nc_webdav_search_files_basic(
|
||||
nc_mcp_client: ClientSession, search_test_files: str
|
||||
):
|
||||
"""Test nc_webdav_search_files with basic filters."""
|
||||
# Search for markdown files
|
||||
result = await nc_mcp_client.call_tool(
|
||||
"nc_webdav_search_files",
|
||||
arguments={
|
||||
"scope": search_test_files,
|
||||
"name_pattern": "%.md",
|
||||
},
|
||||
)
|
||||
|
||||
content = result.content[0].text
|
||||
files = normalize_search_response(json.loads(content))
|
||||
|
||||
logger.info(f"Found {len(files)} markdown files")
|
||||
|
||||
# Should find at least 2 .md files
|
||||
assert len(files) >= 2, f"Expected at least 2 .md files, got {len(files)}"
|
||||
|
||||
# Verify all results are .md files
|
||||
for file in files:
|
||||
name = file.get("name", "")
|
||||
assert name.endswith(".md"), f"Expected .md file, got {name}"
|
||||
|
||||
|
||||
async def test_nc_webdav_search_files_combined(
|
||||
nc_mcp_client: ClientSession, search_test_files: str
|
||||
):
|
||||
"""Test nc_webdav_search_files with combined filters."""
|
||||
# Search for text files with specific name pattern
|
||||
result = await nc_mcp_client.call_tool(
|
||||
"nc_webdav_search_files",
|
||||
arguments={
|
||||
"scope": search_test_files,
|
||||
"name_pattern": "search_test%.txt",
|
||||
"mime_type": "text/plain",
|
||||
},
|
||||
)
|
||||
|
||||
content = result.content[0].text
|
||||
files = normalize_search_response(json.loads(content))
|
||||
|
||||
logger.info(f"Found {len(files)} files matching combined filters")
|
||||
|
||||
# Should find search_test1.txt and search_test2.txt
|
||||
assert len(files) >= 2, f"Expected at least 2 files, got {len(files)}"
|
||||
|
||||
# Verify all results match both conditions
|
||||
for file in files:
|
||||
name = file.get("name", "")
|
||||
content_type = file.get("content_type", "")
|
||||
assert name.endswith(".txt"), f"Expected .txt file, got {name}"
|
||||
assert name.startswith("search_test"), (
|
||||
f"Expected 'search_test' prefix, got {name}"
|
||||
)
|
||||
assert content_type == "text/plain", f"Expected text/plain, got {content_type}"
|
||||
|
||||
|
||||
async def test_nc_webdav_search_files_with_limit(
|
||||
nc_mcp_client: ClientSession, search_test_files: str
|
||||
):
|
||||
"""Test nc_webdav_search_files with result limit."""
|
||||
# Search with limit
|
||||
result = await nc_mcp_client.call_tool(
|
||||
"nc_webdav_search_files",
|
||||
arguments={
|
||||
"scope": search_test_files,
|
||||
"name_pattern": "search_%",
|
||||
"limit": 3,
|
||||
},
|
||||
)
|
||||
|
||||
content = result.content[0].text
|
||||
files = normalize_search_response(json.loads(content))
|
||||
|
||||
logger.info(f"Found {len(files)} files with limit=3")
|
||||
|
||||
# Should return at most 3 results
|
||||
assert len(files) <= 3, f"Expected at most 3 files, got {len(files)}"
|
||||
assert len(files) > 0, "Expected at least 1 file"
|
||||
|
||||
|
||||
async def test_nc_webdav_search_no_results(
|
||||
nc_mcp_client: ClientSession, search_test_files: str
|
||||
):
|
||||
"""Test search that returns no results."""
|
||||
# Search for non-existent pattern
|
||||
result = await nc_mcp_client.call_tool(
|
||||
"nc_webdav_find_by_name",
|
||||
arguments={
|
||||
"pattern": "nonexistent_xyz123.txt",
|
||||
"scope": search_test_files,
|
||||
},
|
||||
)
|
||||
|
||||
# Handle case where empty results might return empty content
|
||||
if result.content and len(result.content) > 0:
|
||||
content = result.content[0].text
|
||||
files = normalize_search_response(json.loads(content))
|
||||
else:
|
||||
files = []
|
||||
|
||||
logger.info("Search correctly returned no results")
|
||||
|
||||
# Should return empty array
|
||||
assert len(files) == 0, f"Expected no results, got {len(files)}"
|
||||
|
||||
|
||||
async def test_search_result_properties(
|
||||
nc_mcp_client: ClientSession, search_test_files: str
|
||||
):
|
||||
"""Test that search results include expected properties."""
|
||||
# Search for a specific file
|
||||
result = await nc_mcp_client.call_tool(
|
||||
"nc_webdav_find_by_name",
|
||||
arguments={
|
||||
"pattern": "search_readme.md",
|
||||
"scope": search_test_files,
|
||||
},
|
||||
)
|
||||
|
||||
content = result.content[0].text
|
||||
files = normalize_search_response(json.loads(content))
|
||||
|
||||
assert len(files) >= 1, "Should find at least one file"
|
||||
|
||||
file = files[0]
|
||||
|
||||
# Check for expected properties
|
||||
assert "name" in file, "Should include name property"
|
||||
assert "path" in file, "Should include path property"
|
||||
assert "is_directory" in file, "Should include is_directory property"
|
||||
assert file["is_directory"] is False, "File should not be a directory"
|
||||
|
||||
# Check for extended properties from search
|
||||
extended_props = ["file_id", "etag", "size", "content_type", "last_modified"]
|
||||
present_props = [prop for prop in extended_props if prop in file]
|
||||
|
||||
logger.info(f"Search result properties: {list(file.keys())}")
|
||||
assert len(present_props) > 0, f"Should have at least one of {extended_props}"
|
||||
Reference in New Issue
Block a user