diff --git a/docs/tutorials/python/tutorial_scripts/wiki.py b/docs/tutorials/python/tutorial_scripts/wiki.py new file mode 100644 index 000000000..9f23e4817 --- /dev/null +++ b/docs/tutorials/python/tutorial_scripts/wiki.py @@ -0,0 +1,284 @@ +#!/usr/bin/env python3 +""" +Tutorial script demonstrating the Synapse Wiki models functionality. + +This script shows how to: +1. Create, read, and update wiki pages +2. Work with WikiPage Markdown +3. Work with WikiPage Attachments +4. Work with WikiHeader +5. Work with WikiHistorySnapshot +6. Work with WikiOrderHint +7. Delete wiki pages +""" +import gzip +import os +import uuid + +from synapseclient import Synapse +from synapseclient.models import ( + Project, + WikiHeader, + WikiHistorySnapshot, + WikiOrderHint, + WikiPage, +) + +# Initialize Synapse client +syn = Synapse() +syn.login() + +# Create a Synapse Project to work with +my_test_project = Project( + name=f"My Test Project_{uuid.uuid4()}", + description="This is a test project for the wiki tutorial.", +).store() +print(f"Created project: {my_test_project.name} with ID: {my_test_project.id}") + +# Section1: Create, read, and update wiki pages +# Create a new wiki page for the project with plain text markdown +root_wiki_page = WikiPage( + owner_id=my_test_project.id, + title="My Root Wiki Page", + markdown="# Welcome to My Root Wiki\n\nThis is a sample root wiki page created with the Synapse client.", +).store() + +# OR you can create a wiki page with an existing markdown file. More instructions can be found in section 2. +markdown_file_path = "path/to/your_markdown_file.md" +root_wiki_page = WikiPage( + owner_id=my_test_project.id, + title="My First Root Wiki Page Version with existing markdown file", + markdown=markdown_file_path, +).store() + +# Create a new wiki page with updated content +root_wiki_page_new = WikiPage( + owner_id=my_test_project.id, + title="My First Root Wiki Page NEW", + markdown="# Welcome to My Root Wiki NEW\n\nThis is a sample root wiki page created with the Synapse client.", + id=root_wiki_page.id, +).store() + +# Restore the wiki page to the original version +wiki_page_restored = WikiPage( + owner_id=my_test_project.id, id=root_wiki_page.id, wiki_version="0" +).restore() + +# check if the content is restored +comparisons = [ + root_wiki_page.markdown_file_handle_id + == wiki_page_restored.markdown_file_handle_id, + root_wiki_page.id == wiki_page_restored.id, + root_wiki_page.title == wiki_page_restored.title, +] +print(f"All fields match: {all(comparisons)}") + +# Create a sub-wiki page +sub_wiki_1 = WikiPage( + owner_id=my_test_project.id, + title="Sub Wiki Page 1", + parent_id=root_wiki_page.id, + markdown="# Sub Page 1\n\nThis is a sub-page of another wiki.", +).store() + +# Get an existing wiki page for the project, now you can see one root wiki page and one sub-wiki page +wiki_header_tree = WikiHeader.get(owner_id=my_test_project.id) +print(wiki_header_tree) + +# Once you know the wiki page id, you can retrieve the wiki page with the id +retrieved_wiki = WikiPage(owner_id=my_test_project.id, id=sub_wiki_1.id).get() +print(f"Retrieved wiki page with title: {retrieved_wiki.title}") + +# Or you can retrieve the wiki page with the title +retrieved_wiki = WikiPage(owner_id=my_test_project.id, title=sub_wiki_1.title).get() +print(f"Retrieved wiki page with title: {retrieved_wiki.title}") + +# Check if the retrieved wiki page is the same as the original wiki page +comparisons = [ + sub_wiki_1.markdown_file_handle_id == retrieved_wiki.markdown_file_handle_id, + sub_wiki_1.id == retrieved_wiki.id, + sub_wiki_1.title == retrieved_wiki.title, +] +print(f"All fields match: {all(comparisons)}") + +# Section 2: WikiPage Markdown Operations +# Create wiki page from markdown text +markdown_content = """# Sample Markdown Content + +## Section 1 +This is a sample markdown file with multiple sections. + +## Section 2 +- List item 1 +- List item 2 +- List item 3 + +## Code Example +```python +def hello_world(): + print("Hello, World!") +``` +""" + +# Create wiki page from markdown text +sub_wiki_2 = WikiPage( + owner_id=my_test_project.id, + parent_id=root_wiki_page.id, + title="Sub Page 2 created from markdown text", + markdown=markdown_content, +).store() + +# Create a wiki page from a markdown file +# Create a temporary markdown gzipped file from the markdown_content +markdown_file_path = "temp_markdown_file.md.gz" +with gzip.open(markdown_file_path, "wt", encoding="utf-8") as gz: + gz.write("This is a markdown file") + +# Create wiki page from markdown file +sub_wiki_3 = WikiPage( + owner_id=my_test_project.id, + parent_id=root_wiki_page.id, + title="Sub Page 3 created from markdown file", + markdown=markdown_file_path, +).store() + +# Download the markdown file +# delete the markdown file after uploading to test the download function +os.remove(markdown_file_path) +# Note: If the markdown is generated from plain text using the client, the downloaded file will be named wiki_markdown_.md.gz. If it is generated from an existing markdown file, the downloaded file will retain the original filename with the .gz suffix appended. +# Download the markdown file for sub_wiki_2 that is created from markdown text +wiki_page_markdown_2 = WikiPage( + owner_id=my_test_project.id, id=sub_wiki_2.id +).get_markdown( + download_file=True, + download_location=".", + download_file_name=f"wiki_markdown_{sub_wiki_2.title}.md.gz", +) +print( + f"Wiki page markdown for sub_wiki_2 successfully downloaded: {os.path.exists(f'wiki_markdown_{sub_wiki_2.title}.md.gz')}" +) +# clean up the downloaded markdown file +os.remove(f"wiki_markdown_{sub_wiki_2.title}.md.gz") + +# Download the markdown file for sub_wiki_3 that is created from a markdown file +wiki_page_markdown_3 = WikiPage( + owner_id=my_test_project.id, id=sub_wiki_3.id +).get_markdown( + download_file=True, download_location=".", download_file_name=markdown_file_path +) +print( + f"Wiki page markdown for sub_wiki_3 successfully downloaded: {os.path.exists(markdown_file_path)}" +) +# clean up the downloaded markdown file +os.remove(markdown_file_path) + +# Section 3: WikiPage with Attachments +# Create a temporary file for the attachment +attachment_file_name = "temp_attachment.txt.gz" +with gzip.open(attachment_file_name, "wt", encoding="utf-8") as gz: + gz.write("This is a sample attachment.") + +# reformat '.' and '_' in the attachment file name to be a valid attachment path +attachment_file_name_reformatted = attachment_file_name.replace(".", "%2E") +attachment_file_name_reformatted = attachment_file_name_reformatted.replace("_", "%5F") + +sub_wiki_4 = WikiPage( + owner_id=my_test_project.id, + parent_id=root_wiki_page.id, + title="Sub Page 4 with Attachments", + markdown=f"# Sub Page 4 with Attachments\n\nThis is a attachment: ${{previewattachment?fileName={attachment_file_name_reformatted}}}", + attachments=[attachment_file_name], +).store() + +# Get attachment handles +attachment_handles = WikiPage( + owner_id=my_test_project.id, id=sub_wiki_4.id +).get_attachment_handles() +print(f"Attachment handles: {attachment_handles['list']}") + +# Get attachment URL without downloading +wiki_page_attachment_url = WikiPage( + owner_id=my_test_project.id, id=sub_wiki_4.id +).get_attachment( + file_name="temp_attachment.txt.gz", + download_file=False, +) +print(f"Attachment URL: {wiki_page_attachment_url}") + +# Download an attachment +# Delete the attachment file after uploading to test the download function +os.remove(attachment_file_name) +wiki_page_attachment = WikiPage( + owner_id=my_test_project.id, id=sub_wiki_4.id +).get_attachment( + file_name=attachment_file_name, + download_file=True, + download_location=".", +) +print(f"Attachment downloaded: {os.path.exists(attachment_file_name)}") +os.remove(attachment_file_name) + +# Download an attachment preview. Instead of using the file_name from the attachmenthandle response when isPreview=True, you should use the original file name in the get_attachment_preview request. The downloaded file will still be named according to the file_name provided in the response when isPreview=True. +# Get attachment preview URL without downloading +attachment_preview_url = WikiPage( + owner_id=my_test_project.id, id=sub_wiki_4.id +).get_attachment_preview( + file_name="temp_attachment.txt.gz", + download_file=False, +) +print(f"Attachment preview URL: {attachment_preview_url}") + +# Download an attachment preview +attachment_preview = WikiPage( + owner_id=my_test_project.id, id=sub_wiki_4.id +).get_attachment_preview( + file_name="temp_attachment.txt.gz", + download_file=True, + download_location=".", +) +# From the attachment preview URL or attachment handle response, the downloaded preview file is preview.txt +os.remove("preview.txt") + +# Section 4: WikiHeader - Working with Wiki Hierarchy +# Get wiki header tree (hierarchy) +headers = WikiHeader.get(owner_id=my_test_project.id) +print(f"Found {len(headers)} wiki pages in hierarchy") +print(f"Wiki header tree: {headers}") + +# Section 5. WikiHistorySnapshot - Version History +# Get wiki history for root_wiki_page +history = WikiHistorySnapshot.get(owner_id=my_test_project.id, id=root_wiki_page.id) +print(f"History: {history}") + +# Section 6. WikiOrderHint - Ordering Wiki Pages +# Set the wiki order hint +order_hint = WikiOrderHint(owner_id=my_test_project.id).get() +print(f"Order hint for {my_test_project.id}: {order_hint.id_list}") +# As you can see from the printed message, the order hint is not set by default, so you need to set it explicitly at the beginning. +order_hint.id_list = [ + root_wiki_page.id, + sub_wiki_3.id, + sub_wiki_4.id, + sub_wiki_1.id, + sub_wiki_2.id, +] +order_hint.store() +print(f"Order hint for {my_test_project.id}: {order_hint}") + +# Update wiki order hint +order_hint = WikiOrderHint(owner_id=my_test_project.id).get() +order_hint.id_list = [ + root_wiki_page.id, + sub_wiki_1.id, + sub_wiki_2.id, + sub_wiki_3.id, + sub_wiki_4.id, +] +order_hint.store() +print(f"Order hint for {my_test_project.id}: {order_hint}") + +# Delete a wiki page +wiki_page_to_delete = WikiPage(owner_id=my_test_project.id, id=sub_wiki_3.id).delete() + +# clean up +my_test_project.delete() diff --git a/docs/tutorials/python/wiki.md b/docs/tutorials/python/wiki.md index 7969eaa0c..17de93209 100644 --- a/docs/tutorials/python/wiki.md +++ b/docs/tutorials/python/wiki.md @@ -1,2 +1,157 @@ # Wikis on Projects -![Under Construction](../../assets/under_construction.png) + +# Synapse Wiki Models Tutorial + +This tutorial demonstrates how to work with Wiki models in the Synapse Python client. Wikis in Synapse provide a way to create rich documentation and collaborative content for projects, folders, files, datasets, and other entities. + +## Overview + +The Synapse Wiki models include: +- **WikiPage**: The main wiki page model for creating and managing wiki content +- **WikiHeader**: Represents wiki page headers and hierarchy information +- **WikiHistorySnapshot**: Provides access to wiki version history +- **WikiOrderHint**: Manages the order of wiki pages within an entity + +This tutorial shows how to: +1. Create, read, and update wiki pages +2. Work with WikiPage Markdown +3. Work with WikiPage Attachments +4. Work with WikiHeader +5. Work with WikiHistorySnapshot +6. Work with WikiOrderHint +7. Delete wiki pages + +## Basic Setup +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=14-36} +``` + +## 1. Create, read, and update wiki pages +### Create a new wiki page for the project with plain text markdown +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=40-44} +``` + +### OR you can create a wiki page with an existing markdown file. More instructions can be found in section 2. +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=47-52} +``` + +### Create a new wiki page with updated content +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=55-60} +``` + +### Restore the wiki page to the original version +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=63-74} +``` + +### Create a sub-wiki page +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=77-82} +``` + +### Get an existing wiki page for the project, now you can see one root wiki page and one sub-wiki page +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=85-86} +``` + +### Retrieving a Wiki Page +Note: You need to know the wiki page ID or wiki page title to retrieve it +#### Retrieve a Wiki Page with wiki page ID +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=89-90} +``` + +#### Retrieve a Wiki Page with wiki page title +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=93-94} +``` + +#### Check if the retrieved wiki page is the same as the original wiki page +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=97-102} +``` + +## 2. WikiPage Markdown Operations +### Create wiki page from markdown text +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=106-129} +``` + +### Create wiki page from a markdown file +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=133-143} +``` + +### Download the markdown file +Note: If the markdown is generated from plain text using the client, the downloaded file will be named wiki_markdown_.md.gz. If it is generated from an existing markdown file, the downloaded file will retain the original filename with the .gz suffix appended. +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=146-173} +``` + +## 3. WikiPage Attachments Operations +### Create a wiki page with attachments +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=176-191} +``` +### Get the file handles of all attachments on this wiki page. +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=194-197} +``` + +### Get attachment URL without downloading +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=200-206} +``` +### Download an attachment +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=209-219} +``` + +### Download an attachment preview URL without downloading +Download an attachment preview. Instead of using the file_name from the attachmenthandle response when isPreview=True, you should use the original file name in the get_attachment_preview request. The downloaded file will still be named according to the file_name provided in the response when isPreview=True. +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=223-229} +``` + +#### Download an attachment preview +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=232-240} +``` + +## 4. WikiHeader - Working with Wiki Hierarchy +### Getting Wiki Header Tree +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=244-246} +``` + +## 5. WikiHistorySnapshot - Version History +### Accessing Wiki History +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=250-251} +``` + +## 6. WikiOrderHint - Managing Wiki Order +Note: You need to have order hint set before pulling. +### Set the wiki order hint +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=255-266} +``` + +### Update wiki order hint +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=269-278} +``` + +### Deleting a Wiki Page +Note: You need to know the owner ID and wiki page ID to delete a wiki page +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=281} +``` + +## clean up +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=284} +``` diff --git a/synapseclient/api/__init__.py b/synapseclient/api/__init__.py index 702fc3f85..56af83037 100644 --- a/synapseclient/api/__init__.py +++ b/synapseclient/api/__init__.py @@ -55,6 +55,21 @@ get_default_columns, post_columns, ) +from .wiki_service import ( + delete_wiki_page, + get_attachment_handles, + get_attachment_preview_url, + get_attachment_url, + get_markdown_url, + get_wiki_header_tree, + get_wiki_history, + get_wiki_order_hint, + get_wiki_page, + post_wiki_page, + put_wiki_order_hint, + put_wiki_page, + put_wiki_version, +) __all__ = [ # annotations @@ -110,4 +125,18 @@ "get_default_columns", "ViewTypeMask", "ViewEntityType", + # wiki_service + "post_wiki_page", + "get_wiki_page", + "put_wiki_page", + "put_wiki_version", + "delete_wiki_page", + "get_wiki_header_tree", + "get_wiki_history", + "get_attachment_handles", + "get_attachment_url", + "get_attachment_preview_url", + "get_markdown_url", + "get_wiki_order_hint", + "put_wiki_order_hint", ] diff --git a/synapseclient/api/wiki_service.py b/synapseclient/api/wiki_service.py new file mode 100644 index 000000000..ef298f419 --- /dev/null +++ b/synapseclient/api/wiki_service.py @@ -0,0 +1,444 @@ +"""This module is responsible for exposing the services defined at: + +""" + +import json +from typing import TYPE_CHECKING, Any, AsyncGenerator, Dict, List, Optional + +if TYPE_CHECKING: + from synapseclient import Synapse + + +async def post_wiki_page( + owner_id: str, + request: Dict[str, Any], + *, + synapse_client: Optional["Synapse"] = None, +) -> Dict[str, Any]: + """Create a new wiki page. + + + Arguments: + owner_id: The ID of the owner entity. + request: The wiki page to create. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The created wiki page. + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + return await client.rest_post_async( + uri=f"/entity/{owner_id}/wiki2", + body=json.dumps(request), + ) + + +async def get_wiki_page( + owner_id: str, + wiki_id: Optional[str] = None, + wiki_version: Optional[int] = None, + *, + synapse_client: Optional["Synapse"] = None, +) -> Dict[str, Any]: + """Get a wiki page. + + + + Arguments: + owner_id: The ID of the owner entity. + wiki_id: Optional ID of the wiki. If not provided, returns the root wiki page. + wiki_version: Optional version of the wiki page. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The requested wiki page. + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + + # Construct the URI based on whether wiki_id is provided + uri = f"/entity/{owner_id}/wiki2" + if wiki_id is not None: + uri = f"{uri}/{wiki_id}" + + # Add version as a query parameter if provided + params = {} + if wiki_version is not None: + params["wikiVersion"] = wiki_version + + return await client.rest_get_async( + uri=uri, + params=params, + ) + + +async def put_wiki_page( + owner_id: str, + wiki_id: str, + request: Dict[str, Any], + *, + synapse_client: Optional["Synapse"] = None, +) -> Dict[str, Any]: + """Update a wiki page. + + + Arguments: + owner_id: The ID of the owner entity. + wiki_id: The ID of the wiki. + request: The updated wiki page. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The updated wiki page. + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + return await client.rest_put_async( + uri=f"/entity/{owner_id}/wiki2/{wiki_id}", + body=json.dumps(request), + ) + + +async def put_wiki_version( + owner_id: str, + wiki_id: str, + wiki_version: int, + request: Dict[str, Any], + *, + synapse_client: Optional["Synapse"] = None, +) -> Dict[str, Any]: + """Update a specific version of a wiki page. + + + Arguments: + owner_id: The ID of the owner entity. + wiki_id: The ID of the wiki. + wiki_version: The version number to update. + request: The updated wiki page. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The updated wiki page version. + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + return await client.rest_put_async( + uri=f"/entity/{owner_id}/wiki2/{wiki_id}/{wiki_version}", + body=json.dumps(request), + ) + + +async def delete_wiki_page( + owner_id: str, + wiki_id: str, + *, + synapse_client: Optional["Synapse"] = None, +) -> None: + """Delete a wiki page. + + + Arguments: + owner_id: The ID of the owner entity. + wiki_id: The ID of the wiki. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + await client.rest_delete_async( + uri=f"/entity/{owner_id}/wiki2/{wiki_id}", + ) + + +async def get_wiki_header_tree( + owner_id: str, + offset: Optional[int] = 0, + limit: Optional[int] = 20, + *, + synapse_client: Optional["Synapse"] = None, +) -> AsyncGenerator[Dict[str, Any], None]: + """Get the header tree (hierarchy) of wiki pages for an entity. + + + Arguments: + owner_id: The ID of the owner entity. + offset: The index of the pagination offset. For a page size of 10, the first page would be at offset = 0, + and the second page would be at offset = 10. Default is 0. + limit: Limits the size of the page returned. For example, a page size of 10 requires limit = 10. + Limit must be 50 or less. Default is 20. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + A generator over the wiki header tree for the entity. The tree contains the hierarchy of wiki pages + including their IDs, titles, and parent-child relationships. + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + + async for item in client.rest_get_paginated_async( + uri=f"/entity/{owner_id}/wikiheadertree2", + limit=limit, + offset=offset, + ): + yield item + + +async def get_wiki_history( + owner_id: str = None, + wiki_id: str = None, + offset: Optional[int] = 0, + limit: Optional[int] = 20, + *, + synapse_client: Optional["Synapse"] = None, +) -> AsyncGenerator[Dict[str, Any], None]: + """Get the history of a wiki page. + + + Arguments: + owner_id: The ID of the owner entity. + wiki_id: The ID of the wiki. + offset: The index of the pagination offset. For a page size of 10, the first page would be at offset = 0, + and the second page would be at offset = 10. Default is 0. + limit: Limits the size of the page returned. For example, a page size of 10 requires limit = 10. + Limit must be 50 or less. Default is 20. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + A generator over the history of the wiki page. The history contains the history of the wiki page + including their IDs, titles, and parent-child relationships. + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + + async for item in client.rest_get_paginated_async( + uri=f"/entity/{owner_id}/wiki2/{wiki_id}/wikihistory", + limit=limit, + offset=offset, + ): + yield item + + +async def get_attachment_handles( + owner_id: str, + wiki_id: str, + wiki_version: Optional[int] = None, + *, + synapse_client: Optional["Synapse"] = None, +) -> List[Dict[str, Any]]: + """Get the file handles of all attachments on a wiki page. + + + Arguments: + owner_id: The ID of the owner entity. + wiki_id: The ID of the wiki. + wiki_version: Optional version of the wiki page. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The the list of FileHandles for all file attachments of a specific WikiPage for a given owning Entity. + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + + # Add version as a query parameter if provided + params = {} + if wiki_version is not None: + params["wikiVersion"] = wiki_version + + return await client.rest_get_async( + uri=f"/entity/{owner_id}/wiki2/{wiki_id}/attachmenthandles", + params=params, + ) + + +async def get_attachment_url( + owner_id: str, + wiki_id: str, + file_name: str, + wiki_version: Optional[int] = None, + *, + synapse_client: Optional["Synapse"] = None, +) -> Dict[str, Any]: + """Get the URL of a wiki page attachment. + + + Arguments: + owner_id: The ID of the owner entity. + wiki_id: The ID of the wiki. + file_name: The name of the file to get. + The file names can be found in the FileHandles from the GET /entity/{ownerId}/wiki/{wikiId}/attachmenthandles method. + wiki_version: Optional version of the wiki page. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The URL that can be used to download a file for a given WikiPage file attachment. + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + + # Add version as a query parameter if provided + params = {} + params["fileName"] = file_name + if wiki_version is not None: + params["wikiVersion"] = wiki_version + + return await client.rest_get_async( + uri=f"/entity/{owner_id}/wiki2/{wiki_id}/attachment", + params=params, + ) + + +async def get_attachment_preview_url( + owner_id: str, + wiki_id: str, + file_name: str, + wiki_version: Optional[int] = None, + *, + synapse_client: Optional["Synapse"] = None, +) -> Dict[str, Any]: + """Get the preview of a wiki page attachment. + + + Arguments: + owner_id: The ID of the owner entity. + wiki_id: The ID of the wiki. + file_name: The name of the file to get. + The file names can be found in the FileHandles from the GET /entity/{ownerId}/wiki/{wikiId}/attachmenthandles method. + wiki_version: Optional version of the wiki page. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The URL that can be used to download a preview file for a given WikiPage file attachment. + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + + # Add version as a query parameter if provided + params = {} + params["fileName"] = file_name + if wiki_version is not None: + params["wikiVersion"] = wiki_version + + return await client.rest_get_async( + uri=f"/entity/{owner_id}/wiki2/{wiki_id}/attachmentpreview", + params=params, + ) + + +async def get_markdown_url( + owner_id: str, + wiki_id: str, + wiki_version: Optional[int] = None, + *, + synapse_client: Optional["Synapse"] = None, +) -> Dict[str, Any]: + """Get the markdown of a wiki page. + + + Arguments: + owner_id: The ID of the owner entity. + wiki_id: The ID of the wiki. + wiki_version: Optional version of the wiki page. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The URL that can be used to download the markdown file for a given WikiPage. + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + + # Add version as a query parameter if provided + params = {} + if wiki_version is not None: + params["wikiVersion"] = wiki_version + + return await client.rest_get_async( + uri=f"/entity/{owner_id}/wiki2/{wiki_id}/markdown", + params=params, + ) + + +async def get_wiki_order_hint( + owner_id: str, + *, + synapse_client: Optional["Synapse"] = None, +) -> Dict[str, Any]: + """Get the order hint of a wiki page tree. + + + Arguments: + owner_id: The ID of the owner entity. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The order hint that corresponds to the given owner Entity. + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + return await client.rest_get_async( + uri=f"/entity/{owner_id}/wiki2orderhint", + ) + + +async def put_wiki_order_hint( + owner_id: str, + request: Dict[str, Any], + *, + synapse_client: Optional["Synapse"] = None, +) -> Dict[str, Any]: + """Update the order hint of a wiki page tree. + + + Arguments: + owner_id: The ID of the owner entity. + request: The updated order hint. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The updated order hint that corresponds to the given owner Entity. + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + return await client.rest_put_async( + uri=f"/entity/{owner_id}/wiki2orderhint", + body=json.dumps(request), + ) diff --git a/synapseclient/client.py b/synapseclient/client.py index 1cf8921f2..0f420c881 100644 --- a/synapseclient/client.py +++ b/synapseclient/client.py @@ -32,7 +32,7 @@ from copy import deepcopy from dataclasses import is_dataclass from http.client import HTTPResponse -from typing import Any, Dict, List, Optional, Tuple, Union +from typing import Any, AsyncGenerator, Dict, List, Optional, Tuple, Union import asyncio_atexit import httpx @@ -6706,6 +6706,37 @@ async def rest_delete_async( **kwargs, ) + async def rest_get_paginated_async( + self, uri: str, limit: int = 20, offset: int = 0 + ) -> AsyncGenerator[Dict[str, Any], None]: + """ + Get paginated results asynchronously + + Arguments: + uri: A URI that returns paginated results + limit: How many records should be returned per request + offset: At what record offset from the first should iteration start + + Returns: + A generator over paginated results + + The limit parameter is set at 20 by default. Using a larger limit results in fewer calls to the service, but if + responses are large enough to be a burden on the service they may be truncated. + """ + prev_num_results = sys.maxsize + while prev_num_results > 0: + params = {"offset": offset, "limit": limit} + page = await self.rest_get_async( + uri=uri, + params=params, + ) + results = page["results"] if "results" in page else page["children"] + prev_num_results = len(results) + + for result in results: + offset += 1 + yield result + async def async_request_hook_httpx(span: Span, request: httpx.Request) -> None: """Hook used to encapsulate a span for this library. The request hook is called diff --git a/synapseclient/core/download/download_async.py b/synapseclient/core/download/download_async.py index 7ecd183d4..2de4c19b5 100644 --- a/synapseclient/core/download/download_async.py +++ b/synapseclient/core/download/download_async.py @@ -59,6 +59,7 @@ class DownloadRequest(NamedTuple): object_type: str path: str debug: bool = False + presigned_url: Optional["PresignedUrlInfo"] = None async def download_file( @@ -270,7 +271,16 @@ async def download_file(self) -> None: """ Splits up and downloads a file in chunks from a URL. """ - url_provider = PresignedUrlProvider(self._syn, request=self._download_request) + if self._download_request.presigned_url: + url_provider = PresignedUrlProvider( + self._syn, + request=self._download_request, + _cached_info=self._download_request.presigned_url, + ) + else: + url_provider = PresignedUrlProvider( + self._syn, request=self._download_request + ) file_size = await with_retry_time_based_async( function=lambda: _get_file_size_wrapper( @@ -282,9 +292,14 @@ async def download_file(self) -> None: retry_max_wait_before_failure=30, read_response_content=False, ) + # set postfix to object_id if not presigned url, otherwise set to file_name + if not self._download_request.presigned_url: + postfix = self._download_request.object_id + else: + postfix = self._download_request.presigned_url.file_name self._progress_bar = get_or_create_download_progress_bar( file_size=file_size, - postfix=self._download_request.object_id, + postfix=postfix, synapse_client=self._syn, ) self._prep_file() diff --git a/synapseclient/core/download/download_functions.py b/synapseclient/core/download/download_functions.py index 1456d2f7d..b7ecc3868 100644 --- a/synapseclient/core/download/download_functions.py +++ b/synapseclient/core/download/download_functions.py @@ -29,6 +29,7 @@ from synapseclient.core.download import ( SYNAPSE_DEFAULT_DOWNLOAD_PART_SIZE, DownloadRequest, + PresignedUrlInfo, PresignedUrlProvider, _pre_signed_url_expiration_time, download_file, @@ -569,25 +570,26 @@ def download_fn( async def download_from_url_multi_threaded( - file_handle_id: str, - object_id: str, - object_type: str, + file_handle_id: Optional[str], destination: str, + object_id: Optional[str] = None, + object_type: Optional[str] = None, *, expected_md5: str = None, synapse_client: Optional["Synapse"] = None, + presigned_url: Optional[PresignedUrlInfo] = None, ) -> str: """ Download a file from the given URL using multiple threads. Arguments: file_handle_id: The id of the FileHandle to download - object_id: The id of the Synapse object that uses the FileHandle + destination: The destination on local file system + object_id: Optional. The id of the Synapse object that uses the FileHandle e.g. "syn123" - object_type: The type of the Synapse object that uses the + object_type: Optional. The type of the Synapse object that uses the FileHandle e.g. "FileEntity". Any of - destination: The destination on local file system expected_md5: The expected MD5 content_size: The size of the content synapse_client: If not passed in and caching was not disabled by @@ -603,17 +605,24 @@ async def download_from_url_multi_threaded( client = Synapse.get_client(synapse_client=synapse_client) destination = os.path.abspath(destination) - temp_destination = utils.temp_download_filename( - destination=destination, file_handle_id=file_handle_id - ) - request = DownloadRequest( - file_handle_id=int(file_handle_id), - object_id=object_id, - object_type=object_type, - path=temp_destination, - debug=client.debug, - ) + if not presigned_url: + temp_destination = utils.temp_download_filename( + destination=destination, file_handle_id=file_handle_id + ) + request = DownloadRequest( + file_handle_id=int(file_handle_id), + object_id=object_id, + object_type=object_type, + path=temp_destination, + debug=client.debug, + ) + else: + request = DownloadRequest( + path=destination, + debug=client.debug, + presigned_url=presigned_url, + ) await download_file(client=client, download_request=request) @@ -638,11 +647,12 @@ async def download_from_url_multi_threaded( def download_from_url( url: str, destination: str, - entity_id: Optional[str], - file_handle_associate_type: Optional[str], + entity_id: Optional[str] = None, + file_handle_associate_type: Optional[str] = None, file_handle_id: Optional[str] = None, expected_md5: Optional[str] = None, progress_bar: Optional[tqdm] = None, + url_is_presigned: Optional[bool] = False, *, synapse_client: Optional["Synapse"] = None, ) -> Union[str, None]: @@ -652,15 +662,17 @@ def download_from_url( Arguments: url: The source of download destination: The destination on local file system - entity_id: The id of the Synapse object that uses the FileHandle + entity_id: Optional. The id of the Synapse object that uses the FileHandle e.g. "syn123" - file_handle_associate_type: The type of the Synapse object that uses the + file_handle_associate_type: Optional. The type of the Synapse object that uses the FileHandle e.g. "FileEntity". Any of file_handle_id: Optional. If given, the file will be given a temporary name that includes the file handle id which allows resuming partial downloads of the same file from previous sessions expected_md5: Optional. If given, check that the MD5 of the downloaded file matches the expected MD5 + progress_bar: Optional progress bar to update during download + url_is_presigned: If True, the URL is already a pre-signed URL. synapse_client: If not passed in and caching was not disabled by `Synapse.allow_client_caching(False)` this will use the last created instance from the Synapse class constructor. @@ -682,7 +694,10 @@ def download_from_url( actual_md5 = None redirect_count = 0 delete_on_md5_mismatch = True - client.logger.debug(f"[{entity_id}]: Downloading from {url} to {destination}") + if entity_id is not None: + client.logger.debug(f"[{entity_id}]: Downloading from {url} to {destination}") + else: + client.logger.debug(f"Downloading from {url} to {destination}") while redirect_count < REDIRECT_LIMIT: redirect_count += 1 scheme = urllib_urlparse.urlparse(url).scheme @@ -768,13 +783,21 @@ def _ftp_report_hook( url ) if url_is_expired: - response = get_file_handle_for_download( - file_handle_id=file_handle_id, - synapse_id=entity_id, - entity_type=file_handle_associate_type, - synapse_client=client, - ) - url = response["preSignedURL"] + if url_is_presigned: + raise SynapseError( + "The provided pre-signed URL has expired. Please provide a new pre-signed URL." + ) + else: + # Get a fresh URL if expired and not presigned + response = get_file_handle_for_download( + file_handle_id=file_handle_id, + synapse_id=entity_id, + entity_type=file_handle_associate_type, + synapse_client=client, + ) + url = response["preSignedURL"] + + # Make the request with retry response = with_retry( lambda url=url, range_header=range_header, auth=auth: client._requests_session.get( url=url, diff --git a/synapseclient/models/__init__.py b/synapseclient/models/__init__.py index 3ecefa185..b8efc2d33 100644 --- a/synapseclient/models/__init__.py +++ b/synapseclient/models/__init__.py @@ -37,6 +37,12 @@ from synapseclient.models.team import Team, TeamMember from synapseclient.models.user import UserPreference, UserProfile from synapseclient.models.virtualtable import VirtualTable +from synapseclient.models.wiki import ( + WikiHeader, + WikiHistorySnapshot, + WikiOrderHint, + WikiPage, +) __all__ = [ "Activity", @@ -88,6 +94,11 @@ "DatasetCollection", # Submission models "SubmissionView", + # Wiki models + "WikiPage", + "WikiOrderHint", + "WikiHistorySnapshot", + "WikiHeader", ] # Static methods to expose as functions diff --git a/synapseclient/models/protocols/wikipage_protocol.py b/synapseclient/models/protocols/wikipage_protocol.py new file mode 100644 index 000000000..232a02476 --- /dev/null +++ b/synapseclient/models/protocols/wikipage_protocol.py @@ -0,0 +1,217 @@ +"""Protocol for the specific methods of this class that have synchronous counterparts +generated at runtime.""" + +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Protocol, Union + +from synapseclient import Synapse + +if TYPE_CHECKING: + from synapseclient.models import ( + WikiHeader, + WikiHistorySnapshot, + WikiOrderHint, + WikiPage, + ) + + +class WikiOrderHintSynchronousProtocol(Protocol): + """Protocol for the methods of the WikiOrderHint class that have synchronous counterparts + generated at runtime.""" + + def store( + self, + *, + synapse_client: Optional["Synapse"] = None, + ) -> "WikiOrderHint": + """ + Update the order hint of a wiki page tree. + Arguments: + synapse_client: Optionally provide a Synapse client. + Returns: + The updated WikiOrderHint object for the entity. + """ + return self + + def get( + self, + *, + synapse_client: Optional[Synapse] = None, + ) -> "WikiOrderHint": + """ + Get the order hint of a wiki page tree. + Arguments: + synapse_client: Optionally provide a Synapse client. + Returns: + A WikiOrderHint object for the entity. + """ + return self + + +class WikiHistorySnapshotSynchronousProtocol(Protocol): + """Protocol for the methods of the WikiHistorySnapshot class that have synchronous counterparts + generated at runtime.""" + + @classmethod + def get( + cls, + owner_id: str, + wiki_id: str, + *, + offset: int = 0, + limit: int = 20, + synapse_client: Optional["Synapse"] = None, + ) -> List["WikiHistorySnapshot"]: + """ + Get the history of a wiki page as a list of WikiHistorySnapshot objects. + Arguments: + owner_id: The ID of the owner entity. + wiki_id: The ID of the wiki page. + offset: The index of the pagination offset. + limit: Limits the size of the page returned. + synapse_client: Optionally provide a Synapse client. + Returns: + A list of WikiHistorySnapshot objects for the wiki page. + """ + return list({}) + + +class WikiHeaderSynchronousProtocol(Protocol): + """Protocol for the methods of the WikiHeader class that have synchronous counterparts + generated at runtime.""" + + @classmethod + def get( + cls, + owner_id: str, + *, + offset: int = 0, + limit: int = 20, + synapse_client: Optional["Synapse"] = None, + ) -> List["WikiHeader"]: + """ + Get the header tree (hierarchy) of wiki pages for an entity. + Arguments: + owner_id: The ID of the owner entity. + offset: The index of the pagination offset. + limit: Limits the size of the page returned. + synapse_client: Optionally provide a Synapse client. + Returns: + A list of WikiHeader objects for the entity. + """ + return list({}) + + +class WikiPageSynchronousProtocol(Protocol): + """Protocol for the methods of the WikiPage class that have synchronous counterparts + generated at runtime.""" + + def store(self, *, synapse_client: Optional["Synapse"] = None) -> "WikiPage": + """ + Store the wiki page. If there is no wiki page, a new wiki page will be created. + If the wiki page already exists, it will be updated. + Arguments: + synapse_client: Optionally provide a Synapse client. + Returns: + The created WikiPage object. + """ + return self + + def restore(self, *, synapse_client: Optional["Synapse"] = None) -> "WikiPage": + """ + Restore a specific version of the wiki page. + Arguments: + synapse_client: Optionally provide a Synapse client. + Returns: + The restored WikiPage object. + """ + return self + + def get(self, *, synapse_client: Optional["Synapse"] = None) -> "WikiPage": + """ + Get a wiki page from Synapse. + Arguments: + synapse_client: Optionally provide a Synapse client. + Returns: + The WikiPage object. + """ + return self + + def delete(self, *, synapse_client: Optional["Synapse"] = None) -> None: + """ + Delete this wiki page. + Arguments: + synapse_client: Optionally provide a Synapse client. + Returns: + None + """ + return None + + def get_attachment_handles( + self, *, synapse_client: Optional["Synapse"] = None + ) -> List[Dict[str, Any]]: + """ + Get the file handles of all attachments on this wiki page. + Arguments: + synapse_client: Optionally provide a Synapse client. + Returns: + The list of FileHandles for all file attachments of this WikiPage. + """ + return list({}) + + def get_attachment( + self, + file_name: str, + *, + download_file: bool = True, + download_location: Optional[str] = None, + redirect: Optional[bool] = False, + synapse_client: Optional["Synapse"] = None, + ) -> Union[str, None]: + """ + Download the wiki page attachment to a local file or return the URL. + Arguments: + file_name: The name of the file to get. + download_file: Whether to download the file. Default is True. + download_location: The location to download the file to. Required if download_file is True. + redirect: When set to false, the URL will be returned as text/plain instead of redirecting. Default is False. + synapse_client: Optionally provide a Synapse client. + Returns: + If download_file is True, the attachment file will be downloaded to the download_location. Otherwise, the URL will be returned. + """ + return "" + + def get_attachment_preview( + self, + file_name: str, + *, + wiki_version: Optional[int] = None, + redirect: Optional[bool] = False, + synapse_client: Optional["Synapse"] = None, + ) -> Union[str, None]: + """ + Download the wiki page attachment preview to a local file or return the URL. + Arguments: + file_name: The name of the file to get. + wiki_version: Optional version of the wiki page. If not provided, uses self.wiki_version. + redirect: When set to false, the URL will be returned as text/plain instead of redirecting. Default is False. + synapse_client: Optionally provide a Synapse client. + Returns: + If download_file is True, the attachment preview file will be downloaded to the download_location. Otherwise, the URL will be returned. + """ + return "" + + def get_markdown( + self, + *, + redirect: Optional[bool] = False, + synapse_client: Optional["Synapse"] = None, + ) -> Union[str, None]: + """ + Download the markdown file to a local file or return the URL. + Arguments: + redirect: When set to false, the URL will be returned as text/plain instead of redirecting. Default is False. + synapse_client: Optionally provide a Synapse client. + Returns: + If download_file is True, the markdown file will be downloaded to the download_location. Otherwise, the URL will be returned. + """ + return "" diff --git a/synapseclient/models/wiki.py b/synapseclient/models/wiki.py index 464090415..ce5cfa217 100644 --- a/synapseclient/models/wiki.py +++ b/synapseclient/models/wiki.py @@ -1 +1,1064 @@ -# TODO +"""Script to work with Synapse wiki pages.""" + +import asyncio +import gzip +import os +from dataclasses import dataclass, field +from typing import Any, Dict, List, Literal, Optional, Union + +from synapseclient import Synapse +from synapseclient.api import ( + delete_wiki_page, + get_attachment_handles, + get_attachment_preview_url, + get_attachment_url, + get_markdown_url, + get_wiki_header_tree, + get_wiki_history, + get_wiki_order_hint, + get_wiki_page, + post_wiki_page, + put_wiki_order_hint, + put_wiki_page, + put_wiki_version, +) +from synapseclient.core.async_utils import async_to_sync, otel_trace_method +from synapseclient.core.download import ( + PresignedUrlInfo, + _pre_signed_url_expiration_time, + download_from_url, + download_from_url_multi_threaded, +) +from synapseclient.core.exceptions import SynapseHTTPError +from synapseclient.core.upload.upload_functions_async import upload_file_handle +from synapseclient.core.utils import delete_none_keys, merge_dataclass_entities +from synapseclient.models.protocols.wikipage_protocol import ( + WikiHeaderSynchronousProtocol, + WikiHistorySnapshotSynchronousProtocol, + WikiOrderHintSynchronousProtocol, + WikiPageSynchronousProtocol, +) + +# File size threshold for using single-threaded vs multi-threaded download (8 MiB) +SINGLE_THREAD_DOWNLOAD_SIZE_LIMIT = 8 * 1024 * 1024 # 8 MiB in bytes + + +@dataclass +@async_to_sync +class WikiOrderHint(WikiOrderHintSynchronousProtocol): + """ + A WikiOrderHint contains the order hint for the root wiki that corresponds to the given owner ID and type. + + Attributes: + owner_id: The Synapse ID of the owner object (e.g., entity, evaluation, etc.). + owner_object_type: The type of the owner object. + id_list: The list of sub wiki ids that in the order that they should be placed relative to their siblings. + etag: The etag of this object. + """ + + owner_id: Optional[str] = None + """The Synapse ID of the owner object (e.g., entity, evaluation, etc.).""" + + owner_object_type: Optional[str] = None + """The type of the owner object.""" + + id_list: List[str] = field(default_factory=list) + """The list of sub wiki ids that in the order that they should be placed relative to their siblings.""" + + etag: Optional[str] = field(default=None, compare=False) + """The etag of this object.""" + + def fill_from_dict( + self, + wiki_order_hint: Dict[str, Union[str, List[str]]], + ) -> "WikiOrderHint": + """ + Converts a response from the REST API into this dataclass. + + Arguments: + wiki_order_hint: The response from the REST API. + + Returns: + The WikiOrderHint object. + """ + self.owner_id = wiki_order_hint.get("ownerId", None) + self.owner_object_type = wiki_order_hint.get("ownerObjectType", None) + self.id_list = wiki_order_hint.get("idList", []) + self.etag = wiki_order_hint.get("etag", None) + return self + + def to_synapse_request(self) -> Dict[str, List[str]]: + """ + Convert the WikiOrderHint object to a request for the REST API. + """ + result = { + "ownerId": self.owner_id, + "ownerObjectType": self.owner_object_type, + "idList": self.id_list, + "etag": self.etag, + } + delete_none_keys(result) + return result + + @otel_trace_method( + method_to_trace_name=lambda self, **kwargs: f"Store_Wiki_Order_Hint: {self.owner_id}" + ) + async def store_async( + self, + *, + synapse_client: Optional["Synapse"] = None, + ) -> "WikiOrderHint": + """ + Store the order hint of a wiki page tree. + + Arguments: + synapse_client: Optionally provide a Synapse client. + Returns: + The updated WikiOrderHint object for the entity. + Raises: + ValueError: If owner_id or request is not provided. + """ + if not self.owner_id: + raise ValueError("Must provide owner_id to store wiki order hint.") + + order_hint_dict = await put_wiki_order_hint( + owner_id=self.owner_id, + request=self.to_synapse_request(), + synapse_client=synapse_client, + ) + self.fill_from_dict(order_hint_dict) + return self + + @otel_trace_method( + method_to_trace_name=lambda self, **kwargs: f"Get_Wiki_Order_Hint: {self.owner_id}" + ) + async def get_async( + self, + *, + synapse_client: Optional["Synapse"] = None, + ) -> "WikiOrderHint": + """ + Get the order hint of a wiki page tree. + + Arguments: + synapse_client: Optionally provide a Synapse client. + Returns: + A WikiOrderHint object for the entity. + Raises: + ValueError: If owner_id is not provided. + """ + if not self.owner_id: + raise ValueError("Must provide owner_id to get wiki order hint.") + + order_hint_dict = await get_wiki_order_hint( + owner_id=self.owner_id, + synapse_client=synapse_client, + ) + return self.fill_from_dict(order_hint_dict) + + +@dataclass +@async_to_sync +class WikiHistorySnapshot(WikiHistorySnapshotSynchronousProtocol): + """ + A WikiHistorySnapshot contains basic information about an update to a WikiPage. + + Attributes: + version: The version number of the wiki page. + modified_on: The timestamp when this version was created. + modified_by: The ID of the user that created this version. + """ + + version: Optional[str] = None + """The version number of the wiki page.""" + + modified_on: Optional[str] = None + """The timestamp when this version was created.""" + + modified_by: Optional[str] = None + """The ID of the user that created this version.""" + + def fill_from_dict( + self, + wiki_history: Dict[str, str], + ) -> "WikiHistorySnapshot": + """ + Converts a response from the REST API into this dataclass. + + Arguments: + wiki_history: The response from the REST API. + + Returns: + The WikiHistorySnapshot object. + """ + self.version = wiki_history.get("version", None) + self.modified_on = wiki_history.get("modifiedOn", None) + self.modified_by = wiki_history.get("modifiedBy", None) + return self + + @classmethod + @otel_trace_method( + method_to_trace_name=lambda self, **kwargs: f"Get_Wiki_History for Owner ID {kwargs['owner_id']}, Wiki ID {kwargs['id']}" + ) + async def get_async( + cls, + owner_id: str = None, + id: str = None, + *, + offset: int = 0, + limit: int = 20, + synapse_client: Optional["Synapse"] = None, + ) -> List["WikiHistorySnapshot"]: + """ + Get the history of a wiki page as a list of WikiHistorySnapshot objects. + + Arguments: + owner_id: The Synapse ID of the owner entity. + id: The ID of the wiki page. + offset: The index of the pagination offset. + limit: Limits the size of the page returned. + synapse_client: Optionally provide a Synapse client. + Returns: + A list of WikiHistorySnapshot objects for the wiki page. + """ + if not owner_id: + raise ValueError("Must provide owner_id to get wiki history.") + if not id: + raise ValueError("Must provide id to get wiki history.") + snapshots = [] + async for item in get_wiki_history( + owner_id=owner_id, + wiki_id=id, # use id instead of wiki_id to match other classes + offset=offset, + limit=limit, + synapse_client=synapse_client, + ): + snapshots.append(cls().fill_from_dict(item)) + return snapshots + + +@dataclass +@async_to_sync +class WikiHeader(WikiHeaderSynchronousProtocol): + """ + A WikiHeader contains basic metadata about a WikiPage. + + Attributes: + id: The unique identifier for this wiki page. + title: The title of this page. + parent_id: When set, the WikiPage is a sub-page of the indicated parent WikiPage. + """ + + id: Optional[str] = None + """The unique identifier for this wiki page.""" + + title: Optional[str] = None + """The title of this page.""" + + parent_id: Optional[str] = None + """When set, the WikiPage is a sub-page of the indicated parent WikiPage.""" + + def fill_from_dict( + self, + wiki_header: Dict[str, str], + ) -> "WikiHeader": + """ + Converts a response from the REST API into this dataclass. + + Arguments: + wiki_header: The response from the REST API. + + Returns: + The WikiHeader object. + """ + self.id = wiki_header.get("id", None) + self.title = wiki_header.get("title", None) + self.parent_id = wiki_header.get("parentId", None) + return self + + @classmethod + @otel_trace_method( + method_to_trace_name=lambda self, **kwargs: f"Get_Wiki_Header_Tree for Owner ID {kwargs['owner_id']}" + ) + async def get_async( + cls, + owner_id: str = None, + *, + offset: int = 0, + limit: int = 20, + synapse_client: Optional["Synapse"] = None, + ) -> List["WikiHeader"]: + """ + Get the header tree (hierarchy) of wiki pages for an entity. + + Arguments: + owner_id: The Synapse ID of the owner entity. + offset: The index of the pagination offset. + limit: Limits the size of the page returned. + synapse_client: Optionally provide a Synapse client. + Returns: + A list of WikiHeader objects for the entity. + """ + if not owner_id: + raise ValueError("Must provide owner_id to get wiki header tree.") + headers = [] + async for item in get_wiki_header_tree( + owner_id=owner_id, + offset=offset, + limit=limit, + synapse_client=synapse_client, + ): + headers.append(cls().fill_from_dict(item)) + return headers + + +@dataclass +@async_to_sync +class WikiPage(WikiPageSynchronousProtocol): + """ + Represents a [Wiki Page](https://rest-docs.synapse.org/rest/org/sagebionetworks/repo/model/v2/wiki/V2WikiPage.html). + + Attributes: + id: The unique identifier for this wiki page. + etag: The etag of this object. Synapse employs an Optimistic Concurrency Control (OCC) scheme to handle + concurrent updates. Since the E-Tag changes every time an entity is updated it is + used to detect when a client's current representation of an entity is out-of-date. + title: The title of this page. + parent_id: When set, the WikiPage is a sub-page of the indicated parent WikiPage. + markdown: The markdown content of the wiki page. + attachments: A list of file attachments associated with the wiki page. + owner_id: The Synapse ID of the owning object (e.g., entity, evaluation, etc.). + created_on: The timestamp when this page was created. + created_by: The ID of the user that created this page. + modified_on: The timestamp when this page was last modified. + modified_by: The ID of the user that last modified this page. + wiki_version: The version number of this wiki page. + markdown_file_handle_id: The ID of the file handle containing the markdown content. + attachment_file_handle_ids: The list of attachment file handle ids of this page. + """ + + id: Optional[str] = None + """The unique identifier for this wiki page.""" + + etag: Optional[str] = field(default=None, compare=False) + """The etag of this object. Synapse employs an Optimistic Concurrency Control (OCC) scheme to handle concurrent + updates. Since the E-Tag changes every time an entity is updated it is used to detect + when a client's current representation of an entity is out-of-date.""" + + title: Optional[str] = None + """The title of this page.""" + + parent_id: Optional[str] = None + """When set, the WikiPage is a sub-page of the indicated parent WikiPage.""" + + markdown: Optional[str] = None + """The markdown content of this page.""" + + attachments: List[Dict[str, Any]] = field(default_factory=list) + """A list of file attachments associated with this page.""" + + owner_id: Optional[str] = None + """The Synapse ID of the owning object (e.g., entity, evaluation, etc.).""" + + created_on: Optional[str] = field(default=None, compare=False) + """The timestamp when this page was created.""" + + created_by: Optional[str] = field(default=None, compare=False) + """The ID of the user that created this page.""" + + modified_on: Optional[str] = field(default=None, compare=False) + """The timestamp when this page was last modified.""" + + modified_by: Optional[str] = field(default=None, compare=False) + """The ID of the user that last modified this page.""" + + wiki_version: Optional[str] = None + """The version number of this wiki page.""" + + markdown_file_handle_id: Optional[str] = None + """The ID of the file handle containing the markdown content.""" + + attachment_file_handle_ids: List[str] = field(default_factory=list) + """The list of attachment file handle ids of this page.""" + + def fill_from_dict( + self, + synapse_wiki: Dict[str, Union[str, List[str], List[Dict[str, Any]]]], + ) -> "WikiPage": + """ + Converts a response from the REST API into this dataclass. + + Arguments: + synapse_wiki: The response from the REST API. + + Returns: + The WikiPage object. + """ + self.id = synapse_wiki.get("id", None) + self.etag = synapse_wiki.get("etag", None) + self.title = synapse_wiki.get("title", None) + self.parent_id = synapse_wiki.get("parentWikiId", None) + self.markdown = synapse_wiki.get("markdown", None) + self.attachments = synapse_wiki.get("attachments", []) + self.owner_id = synapse_wiki.get("ownerId", None) + self.created_on = synapse_wiki.get("createdOn", None) + self.created_by = synapse_wiki.get("createdBy", None) + self.modified_on = synapse_wiki.get("modifiedOn", None) + self.modified_by = synapse_wiki.get("modifiedBy", None) + self.wiki_version = synapse_wiki.get("wikiVersion", None) + self.markdown_file_handle_id = synapse_wiki.get("markdownFileHandleId", None) + self.attachment_file_handle_ids = synapse_wiki.get( + "attachmentFileHandleIds", [] + ) + return self + + def to_synapse_request( + self, + ) -> Dict[str, Union[str, List[str], List[Dict[str, Any]]]]: + """Convert the wiki page object into a format suitable for the Synapse API.""" + result = { + "id": self.id, + "etag": self.etag, + "title": self.title, + "parentWikiId": self.parent_id, + "markdown": self.markdown, + "attachments": self.attachments, + "ownerId": self.owner_id, + "createdOn": self.created_on, + "createdBy": self.created_by, + "modifiedOn": self.modified_on, + "modifiedBy": self.modified_by, + "wikiVersion": self.wiki_version, + "markdownFileHandleId": self.markdown_file_handle_id, + "attachmentFileHandleIds": self.attachment_file_handle_ids, + } + delete_none_keys(result) + return result + + def _to_gzip_file( + self, + wiki_content: str, + synapse_client: Optional[Synapse] = None, + ) -> str: + """Convert markdown or attachment to a gzipped file and save it in the synapse cache to get a file handle id later. + + Arguments: + wiki_content: The markdown or attachment content as plain text, basic HTML, or Markdown, or a file path to such content. + synapse_client: The Synapse client to use for cache access. + + Returns: + The path to the gzipped file and the cache directory. + """ + # check if markdown is a string + if not isinstance(wiki_content, str): + raise SyntaxError(f"Expected a string, got {type(wiki_content).__name__}") + # Get the cache directory path to save the newly created gzipped file + cache_dir = os.path.join(synapse_client.cache.cache_root_dir, "wiki_content") + if not os.path.exists(cache_dir): + os.makedirs(cache_dir) + + # Check if markdown looks like a file path and exists + if os.path.isfile(wiki_content): + # If it's already a gzipped file, use the file path directly + if wiki_content.endswith(".gz"): + file_path = wiki_content + else: + # If it's a regular html or markdown file, compress it + with open(wiki_content, "rb") as f_in: + # Open the output gzip file + file_path = os.path.join( + cache_dir, os.path.basename(wiki_content) + ".gz" + ) + with gzip.open(file_path, "wb") as f_out: + f_out.writelines(f_in) + + else: + # If it's a plain text, write it to a gzipped file and save it in the synapse cache + file_path = os.path.join(cache_dir, f"wiki_markdown_{self.title}.md.gz") + with gzip.open(file_path, "wt", encoding="utf-8") as f_out: + f_out.write(wiki_content) + + return file_path + + @staticmethod + def _get_file_size(filehandle_dict: dict, file_name: str) -> str: + """Get the file name from the response headers. + Arguments: + response: The response from the REST API. + Returns: + The file name. + """ + filehandle_dict = filehandle_dict["list"] + available_files = [filehandle["fileName"] for filehandle in filehandle_dict] + # locate the contentSize for given file_name + for filehandle in filehandle_dict: + if filehandle["fileName"] == file_name: + return filehandle["contentSize"] + raise ValueError( + f"File {file_name} not found in filehandle_dict. Available files: {available_files}" + ) + + @otel_trace_method( + method_to_trace_name=lambda self, **kwargs: f"Get the markdown file handle: {self.owner_id}" + ) + async def _get_markdown_file_handle(self, synapse_client: Synapse) -> "WikiPage": + """Get the markdown file handle from the synapse client. + Arguments: + synapse_client: The Synapse client to use for cache access. + Returns: + A WikiPage with the updated markdown file handle id. + """ + if not self.markdown: + return self + else: + file_path = self._to_gzip_file( + wiki_content=self.markdown, synapse_client=synapse_client + ) + try: + # Upload the gzipped file to get a file handle + file_handle = await upload_file_handle( + syn=synapse_client, + parent_entity_id=self.owner_id, + path=file_path, + ) + synapse_client.logger.debug( + f"Uploaded file handle {file_handle.get('id')} for wiki page markdown." + ) + # Set the markdown file handle ID from the upload response + self.markdown_file_handle_id = file_handle.get("id") + finally: + # delete the temp directory saving the gzipped file + if os.path.exists(file_path): + os.remove(file_path) + synapse_client.logger.debug(f"Deleted temp directory {file_path}") + return self + + @otel_trace_method( + method_to_trace_name=lambda self, **kwargs: f"Get the attachment file handles for wiki page: {self.owner_id}" + ) + async def _get_attachment_file_handles(self, synapse_client: Synapse) -> "WikiPage": + """Get the attachment file handles from the synapse client. + Arguments: + synapse_client: The Synapse client to use for cache access. + Returns: + A WikiPage with the updated attachment file handle ids. + """ + if not self.attachments: + return self + else: + + async def task_of_uploading_attachment(attachment: str) -> tuple[str, str]: + """Process a single attachment and return its file handle ID and cache directory.""" + file_path = self._to_gzip_file( + wiki_content=attachment, synapse_client=synapse_client + ) + try: + file_handle = await upload_file_handle( + syn=synapse_client, + parent_entity_id=self.owner_id, + path=file_path, + ) + synapse_client.logger.debug( + f"Uploaded file handle {file_handle.get('id')} for wiki page attachment." + ) + return file_handle.get("id") + finally: + if os.path.exists(file_path): + os.remove(file_path) + synapse_client.logger.debug( + f"Deleted temp directory {file_path}" + ) + + # Process all attachments in parallel + tasks = [ + asyncio.create_task(task_of_uploading_attachment(attachment)) + for attachment in self.attachments + ] + results = await asyncio.gather(*tasks) + # Set the attachment file handle IDs from the upload response + self.attachment_file_handle_ids = results + return self + + async def _determine_wiki_action( + self, + ) -> Literal["create_root", "update_root", "create_sub"]: + """Determine the wiki action to perform. + Returns: + The wiki action to perform. + Raises: + ValueError: If required fields are missing. + """ + if not self.owner_id: + raise ValueError("Must provide owner_id to modify a wiki page.") + + if self.parent_id: + return "create_sub_wiki_page" + + try: + await WikiHeader.get_async(owner_id=self.owner_id) + except SynapseHTTPError as e: + if e.response.status_code == 404: + return "create_root_wiki_page" + else: + raise + else: + if not self.id: + raise ValueError("Must provide id to update existing wiki page.") + return "update_existing_wiki_page" + + @otel_trace_method( + method_to_trace_name=lambda self, **kwargs: f"Store the wiki page: {self.owner_id}" + ) + async def store_async( + self, + *, + synapse_client: Optional[Synapse] = None, + ) -> "WikiPage": + """Store the wiki page. If there is no wiki page, a new wiki page will be created. + If the wiki page already exists, it will be updated. + Subwiki pages are created by passing in a parent_id. + If a parent_id is provided, the wiki page will be created as a subwiki page. + + Arguments: + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The created/updated wiki page. + + Raises: + ValueError: If owner_id is not provided or if required fields are missing. + """ + client = Synapse.get_client(synapse_client=synapse_client) + + wiki_action = await self._determine_wiki_action() + # get the markdown file handle and attachment file handles if the wiki action is valid + if wiki_action: + # Update self with the returned WikiPage objects that have file handle IDs set + self = await self._get_markdown_file_handle(synapse_client=client) + self = await self._get_attachment_file_handles(synapse_client=client) + + # Handle root wiki page creation if parent_id is not given + if wiki_action == "create_root_wiki_page": + client.logger.info( + "No wiki page exists within the owner. Create a new wiki page." + ) + # Create the wiki page + wiki_data = await post_wiki_page( + owner_id=self.owner_id, + request=self.to_synapse_request(), + ) + client.logger.info( + f"Created wiki page: {wiki_data.get('title')} with ID: {wiki_data.get('id')}." + ) + if wiki_action == "update_existing_wiki_page": + client.logger.info( + "A wiki page already exists within the owner. Update the existing wiki page." + ) + # retrieve the wiki page + existing_wiki_dict = await get_wiki_page( + owner_id=self.owner_id, + wiki_id=self.id, + wiki_version=self.wiki_version, + ) + # convert to dataclass + existing_wiki = WikiPage() + existing_wiki = existing_wiki.fill_from_dict( + synapse_wiki=existing_wiki_dict + ) + # Update existing_wiki with current object's attributes if they are not None + updated_wiki = merge_dataclass_entities( + source=existing_wiki, + destination=self, + fields_to_ignore=[ + "created_on", + "created_by", + "modified_on", + "modified_by", + ], + ) + # update the wiki page + wiki_data = await put_wiki_page( + owner_id=self.owner_id, + wiki_id=self.id, + request=updated_wiki.to_synapse_request(), + ) + client.logger.info( + f"Updated wiki page: {wiki_data.get('title')} with ID: {wiki_data.get('id')}." + ) + + # Handle sub-wiki page creation if parent_id is given + if wiki_action == "create_sub_wiki_page": + client.logger.info( + f"Creating sub-wiki page under parent ID: {self.parent_id}" + ) + # Create the sub-wiki page directly + wiki_data = await post_wiki_page( + owner_id=self.owner_id, + request=self.to_synapse_request(), + synapse_client=client, + ) + client.logger.info( + f"Created sub-wiki page: {wiki_data.get('title')} with ID: {wiki_data.get('id')} under parent: {self.parent_id}" + ) + self.fill_from_dict(wiki_data) + return self + + @otel_trace_method( + method_to_trace_name=lambda self, **kwargs: f"Restore version: {self.wiki_version} for wiki page: {self.id}" + ) + async def restore_async( + self, + *, + synapse_client: Optional[Synapse] = None, + ) -> "WikiPage": + """Restore a specific version of a wiki page. + + Arguments: + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The restored wiki page. + """ + if not self.owner_id: + raise ValueError("Must provide owner_id to restore a wiki page.") + if not self.id: + raise ValueError("Must provide id to restore a wiki page.") + if not self.wiki_version: + raise ValueError("Must provide wiki_version to restore a wiki page.") + + # restore the wiki page + wiki_data = await put_wiki_version( + owner_id=self.owner_id, + wiki_id=self.id, + wiki_version=self.wiki_version, + request=self.to_synapse_request(), + synapse_client=synapse_client, + ) + self.fill_from_dict(wiki_data) + return self + + @otel_trace_method( + method_to_trace_name=lambda self, **kwargs: f"Get_Wiki_Page: {self.owner_id}" + ) + async def get_async( + self, + *, + synapse_client: Optional[Synapse] = None, + ) -> "WikiPage": + """Get a wiki page from Synapse. + + Arguments: + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The wiki page. + + Raises: + ValueError: If owner_id is not provided. + + """ + if not self.owner_id: + raise ValueError("Must provide owner_id to get a wiki page.") + if not self.id and not self.title: + raise ValueError("Must provide id or title to get a wiki page.") + + # If we only have a title, find the wiki page with matching title. + if self.id is None: + async for result in get_wiki_header_tree( + owner_id=self.owner_id, + synapse_client=synapse_client, + ): + if result.get("title") == self.title: + matching_header = result + break + else: + matching_header = None + + if not matching_header: + raise ValueError(f"No wiki page found with title: {self.title}") + self.id = matching_header["id"] + + wiki_data = await get_wiki_page( + owner_id=self.owner_id, + wiki_id=self.id, + wiki_version=self.wiki_version, + synapse_client=synapse_client, + ) + self.fill_from_dict(wiki_data) + return self + + @otel_trace_method( + method_to_trace_name=lambda self, **kwargs: f"Delete_Wiki_Page: Owner ID {self.owner_id}, Wiki ID {self.id}" + ) + async def delete_async( + self, + *, + synapse_client: Optional["Synapse"] = None, + ) -> None: + """ + Delete this wiki page. + + Arguments: + synapse_client: Optionally provide a Synapse client. + Raises: + ValueError: If required fields are missing. + """ + if not self.owner_id: + raise ValueError("Must provide owner_id to delete a wiki page.") + if not self.id: + raise ValueError("Must provide id to delete a wiki page.") + + await delete_wiki_page( + owner_id=self.owner_id, + wiki_id=self.id, + synapse_client=synapse_client, + ) + + @otel_trace_method( + method_to_trace_name=lambda self, **kwargs: f"Get_Attachment_Handles: Owner ID {self.owner_id}, Wiki ID {self.id}, Wiki Version {self.wiki_version}" + ) + async def get_attachment_handles_async( + self, + *, + synapse_client: Optional["Synapse"] = None, + ) -> List[Dict[str, Any]]: + """ + Get the file handles of all attachments on this wiki page. + + Arguments: + synapse_client: Optionally provide a Synapse client. + Returns: + The list of FileHandles for all file attachments of this WikiPage. + Raises: + ValueError: If owner_id or id is not provided. + """ + if not self.owner_id: + raise ValueError("Must provide owner_id to get attachment handles.") + if not self.id: + raise ValueError("Must provide id to get attachment handles.") + + return await get_attachment_handles( + owner_id=self.owner_id, + wiki_id=self.id, + wiki_version=self.wiki_version, + synapse_client=synapse_client, + ) + + @otel_trace_method( + method_to_trace_name=lambda self, **kwargs: f"Get_Attachment_URL: Owner ID {self.owner_id}, Wiki ID {self.id}, File Name {kwargs['file_name']}" + ) + async def get_attachment_async( + self, + *, + file_name: str, + download_file: bool = True, + download_location: Optional[str] = None, + synapse_client: Optional["Synapse"] = None, + ) -> Union[str, None]: + """ + Download the wiki page attachment to a local file or return the URL. + + Arguments: + file_name: The name of the file to get. + download_file: Whether associated files should be downloaded. Default is True. + download_location: The directory to download the file to. Required if download_file is True. + synapse_client: Optionally provide a Synapse client. + Returns: + If download_file is True, the attachment file will be downloaded to the download_location. Otherwise, the URL will be returned. + Raises: + ValueError: If owner_id or id is not provided. + """ + if not self.owner_id: + raise ValueError("Must provide owner_id to get attachment URL.") + if not self.id: + raise ValueError("Must provide id to get attachment URL.") + if not file_name: + raise ValueError("Must provide file_name to get attachment URL.") + + client = Synapse.get_client(synapse_client=synapse_client) + attachment_url = await get_attachment_url( + owner_id=self.owner_id, + wiki_id=self.id, + file_name=file_name, + wiki_version=self.wiki_version, + synapse_client=client, + ) + + if download_file: + if not download_location: + raise ValueError("Must provide download_location to download a file.") + + # construct PresignedUrlInfo for downloading + presigned_url_info = PresignedUrlInfo( + url=attachment_url, + file_name=file_name, + expiration_utc=_pre_signed_url_expiration_time(attachment_url), + ) + filehandle_dict = await get_attachment_handles( + owner_id=self.owner_id, + wiki_id=self.id, + wiki_version=self.wiki_version, + synapse_client=client, + ) + # check the file_size + file_size = int(WikiPage._get_file_size(filehandle_dict, file_name)) + # use single thread download if file size < 8 MiB + if file_size < SINGLE_THREAD_DOWNLOAD_SIZE_LIMIT: + download_from_url( + url=presigned_url_info.url, + destination=download_location, + url_is_presigned=True, + ) + else: + # download the file + download_from_url_multi_threaded( + presigned_url=presigned_url_info, destination=download_location + ) + client.logger.debug( + f"Downloaded file {presigned_url_info.file_name} to {download_location}" + ) + else: + return attachment_url + + @otel_trace_method( + method_to_trace_name=lambda self, **kwargs: f"Get_Attachment_Preview_URL: Owner ID {self.owner_id}, Wiki ID {self.id}, File Name {kwargs['file_name']}" + ) + async def get_attachment_preview_async( + self, + file_name: str, + *, + download_file: bool = True, + download_location: Optional[str] = None, + synapse_client: Optional["Synapse"] = None, + ) -> Union[str, None]: + """ + Download the wiki page attachment preview to a local file or return the URL. + + Arguments: + file_name: The name of the file to get. + download_file: Whether associated files should be downloaded. Default is True. + download_location: The directory to download the file to. Required if download_file is True. + synapse_client: Optionally provide a Synapse client. + Returns: + If download_file is True, the attachment preview file will be downloaded to the download_location. Otherwise, the URL will be returned. + Raises: + ValueError: If owner_id or id is not provided. + """ + if not self.owner_id: + raise ValueError("Must provide owner_id to get attachment preview URL.") + if not self.id: + raise ValueError("Must provide id to get attachment preview URL.") + if not file_name: + raise ValueError("Must provide file_name to get attachment preview URL.") + + client = Synapse.get_client(synapse_client=synapse_client) + attachment_preview_url = await get_attachment_preview_url( + owner_id=self.owner_id, + wiki_id=self.id, + file_name=file_name, + wiki_version=self.wiki_version, + synapse_client=client, + ) + # download the file if download_file is True + if download_file: + if not download_location: + raise ValueError("Must provide download_location to download a file.") + + # construct PresignedUrlInfo for downloading + presigned_url_info = PresignedUrlInfo( + url=attachment_preview_url, + file_name=file_name, + expiration_utc=_pre_signed_url_expiration_time(attachment_preview_url), + ) + + filehandle_dict = await get_attachment_handles( + owner_id=self.owner_id, + wiki_id=self.id, + wiki_version=self.wiki_version, + synapse_client=client, + ) + # check the file_size + file_size = int(WikiPage._get_file_size(filehandle_dict, file_name)) + # use single thread download if file size < 8 MiB + if file_size < SINGLE_THREAD_DOWNLOAD_SIZE_LIMIT: + download_from_url( + url=presigned_url_info.url, + destination=download_location, + url_is_presigned=True, + ) + else: + # download the file + download_from_url_multi_threaded( + presigned_url=presigned_url_info, destination=download_location + ) + client.logger.debug( + f"Downloaded the preview file {presigned_url_info.file_name} to {download_location}" + ) + else: + return attachment_preview_url + + @otel_trace_method( + method_to_trace_name=lambda self, **kwargs: f"Get_Markdown_URL: Owner ID {self.owner_id}, Wiki ID {self.id}, Wiki Version {self.wiki_version}" + ) + async def get_markdown_async( + self, + *, + download_file_name: Optional[str] = None, + download_file: bool = True, + download_location: Optional[str] = None, + synapse_client: Optional["Synapse"] = None, + ) -> Union[str, None]: + """ + Get the markdown URL of this wiki page. + + Arguments: + download_file_name: The name of the file to download. Required if download_file is True. + download_file: Whether associated files should be downloaded. Default is True. + download_location: The directory to download the file to. Required if download_file is True. + synapse_client: Optionally provide a Synapse client. + Returns: + If download_file is True, the markdown file will be downloaded to the download_location. Otherwise, the URL will be returned. + Raises: + ValueError: If owner_id or id is not provided. + """ + if not self.owner_id: + raise ValueError("Must provide owner_id to get markdown URL.") + if not self.id: + raise ValueError("Must provide id to get markdown URL.") + + client = Synapse.get_client(synapse_client=synapse_client) + markdown_url = await get_markdown_url( + owner_id=self.owner_id, + wiki_id=self.id, + wiki_version=self.wiki_version, + synapse_client=client, + ) + # download the file if download_file is True + if download_file: + if not download_location: + raise ValueError("Must provide download_location to download a file.") + if not download_file_name: + raise ValueError("Must provide download_file_name to download a file.") + + # construct PresignedUrlInfo for downloading + presigned_url_info = PresignedUrlInfo( + url=markdown_url, + file_name=download_file_name, + expiration_utc=_pre_signed_url_expiration_time(markdown_url), + ) + download_from_url( + url=presigned_url_info.url, + destination=download_location, + url_is_presigned=True, + ) + client.logger.debug( + f"Downloaded file {presigned_url_info.file_name} to {download_location}" + ) + else: + return markdown_url diff --git a/tests/unit/synapseclient/models/async/unit_test_wiki_async.py b/tests/unit/synapseclient/models/async/unit_test_wiki_async.py new file mode 100644 index 000000000..30ce165db --- /dev/null +++ b/tests/unit/synapseclient/models/async/unit_test_wiki_async.py @@ -0,0 +1,1937 @@ +"""Tests for the synapseclient.models.wiki classes.""" +import copy +from typing import Any, AsyncGenerator, Dict, List +from unittest.mock import AsyncMock, Mock, call, mock_open, patch + +import pytest + +from synapseclient import Synapse +from synapseclient.core.download.download_async import PresignedUrlInfo +from synapseclient.core.exceptions import SynapseHTTPError +from synapseclient.models.wiki import ( + WikiHeader, + WikiHistorySnapshot, + WikiOrderHint, + WikiPage, +) + + +class TestWikiOrderHint: + """Tests for the WikiOrderHint class.""" + + @pytest.fixture(autouse=True, scope="function") + def init_syn(self, syn: Synapse) -> None: + self.syn = syn + + # Construct a WikiOrderHint object + order_hint = WikiOrderHint( + owner_id="syn123", + owner_object_type="org.sagebionetworks.repo.model.Project", + id_list=["wiki1", "wiki2", "wiki3"], + etag="etag123", + ) + + api_response = { + "ownerId": "syn123", + "ownerObjectType": "org.sagebionetworks.repo.model.Project", + "idList": ["wiki1", "wiki2", "wiki3"], + "etag": "etag123", + } + + async def test_fill_from_dict(self) -> None: + # WHEN I call `fill_from_dict` with the API response + result = self.order_hint.fill_from_dict(self.api_response) + + # THEN the WikiOrderHint object should be filled with the example data + assert result == self.order_hint + + async def test_to_synapse_request(self): + # WHEN I call `to_synapse_request` on an initialized order hint + results = self.order_hint.to_synapse_request() + + # THEN the request should contain the correct data + assert results == self.api_response + + async def test_to_synapse_request_with_none_values(self) -> None: + # GIVEN a WikiOrderHint object with None values + order_hint = WikiOrderHint( + owner_id="syn123", + owner_object_type=None, + id_list=[], + etag=None, + ) + + # WHEN I call `to_synapse_request` + results = order_hint.to_synapse_request() + + # THEN the request should not contain None values + assert results == {"ownerId": "syn123", "idList": []} + + async def test_store_async_success(self) -> None: + # GIVEN a mock response + with patch( + "synapseclient.models.wiki.put_wiki_order_hint", + new_callable=AsyncMock, + return_value=self.api_response, + ) as mocked_put: + results = await self.order_hint.store_async(synapse_client=self.syn) + + # THEN the API should be called with correct parameters + mocked_put.assert_called_once_with( + owner_id=self.order_hint.owner_id, + request=self.order_hint.to_synapse_request(), + synapse_client=self.syn, + ) + + # AND the result should be updated with the response + assert results == self.order_hint + + async def test_store_async_missing_owner_id(self) -> None: + # GIVEN a WikiOrderHint object without owner_id + order_hint = WikiOrderHint( + owner_object_type="org.sagebionetworks.repo.model.Project", + id_list=["wiki1", "wiki2"], + ) + + # WHEN I call `store_async` + # THEN it should raise ValueError + with patch( + "synapseclient.models.wiki.put_wiki_order_hint", + new_callable=AsyncMock, + return_value=self.api_response, + ) as mocked_put, pytest.raises( + ValueError, match="Must provide owner_id to store wiki order hint." + ): + await order_hint.store_async(synapse_client=self.syn) + # THEN the API should not be called + mocked_put.assert_not_called() + + async def test_get_async_success(self) -> None: + # WHEN I call `get_async` + with patch( + "synapseclient.models.wiki.get_wiki_order_hint", + new_callable=AsyncMock, + return_value=self.api_response, + ) as mocked_get: + results = await self.order_hint.get_async(synapse_client=self.syn) + + # THEN the API should be called with correct parameters + mocked_get.assert_called_once_with( + owner_id="syn123", + synapse_client=self.syn, + ) + + # AND the result should be filled with the response + assert results == self.order_hint + + async def test_get_async_missing_owner_id(self) -> None: + # GIVEN a WikiOrderHint object without owner_id + self.order_hint.owner_id = None + # WHEN I call `get_async` + # THEN it should raise ValueError + with patch( + "synapseclient.models.wiki.get_wiki_order_hint" + ) as mocked_get, pytest.raises( + ValueError, match="Must provide owner_id to get wiki order hint." + ): + await self.order_hint.get_async(synapse_client=self.syn) + # THEN the API should not be called + mocked_get.assert_not_called() + + +class TestWikiHistorySnapshot: + """Tests for the WikiHistorySnapshot class.""" + + @pytest.fixture(autouse=True, scope="function") + def init_syn(self, syn: Synapse) -> None: + self.syn = syn + + # Construct a WikiHistorySnapshot object + history_snapshot = WikiHistorySnapshot( + version="1", + modified_on="2023-01-01T00:00:00.000Z", + modified_by="12345", + ) + + # Construct an API response + api_response = { + "version": "1", + "modifiedOn": "2023-01-01T00:00:00.000Z", + "modifiedBy": "12345", + } + + async def test_fill_from_dict(self) -> None: + # WHEN I call `fill_from_dict` with the API response + results = self.history_snapshot.fill_from_dict(self.api_response) + + # THEN the WikiHistorySnapshot object should be filled with the example data + assert results == self.history_snapshot + + async def test_get_async_success(self) -> None: + # GIVEN mock responses + mock_responses = [ + { + "version": 1, + "modifiedOn": "2023-01-01T00:00:00.000Z", + "modifiedBy": "12345", + }, + { + "version": 2, + "modifiedOn": "2023-01-02T00:00:00.000Z", + "modifiedBy": "12345", + }, + { + "version": 3, + "modifiedOn": "2023-01-03T00:00:00.000Z", + "modifiedBy": "12345", + }, + ] + + # Create an async generator mock + async def mock_async_generator( + items: List[Dict[str, Any]] + ) -> AsyncGenerator[Dict[str, Any], None]: + for item in items: + yield item + + # WHEN I call `get_async` + with patch( + "synapseclient.models.wiki.get_wiki_history", + return_value=mock_async_generator(mock_responses), + ) as mocked_get: + results = await WikiHistorySnapshot.get_async( + owner_id="syn123", + id="wiki1", + offset=0, + limit=20, + synapse_client=self.syn, + ) + # THEN the API should be called with correct parameters + mocked_get.assert_called_once_with( + owner_id="syn123", + wiki_id="wiki1", + offset=0, + limit=20, + synapse_client=self.syn, + ) + + history_snapshot_list = [ + WikiHistorySnapshot( + version=1, + modified_on="2023-01-01T00:00:00.000Z", + modified_by="12345", + ), + WikiHistorySnapshot( + version=2, + modified_on="2023-01-02T00:00:00.000Z", + modified_by="12345", + ), + WikiHistorySnapshot( + version=3, + modified_on="2023-01-03T00:00:00.000Z", + modified_by="12345", + ), + ] + # AND the results should contain the expected data + assert results == history_snapshot_list + + async def test_get_async_missing_owner_id(self) -> None: + # WHEN I call `get_async` + with patch( + "synapseclient.models.wiki.get_wiki_history" + ) as mocked_get, pytest.raises( + ValueError, match="Must provide owner_id to get wiki history." + ): + await WikiHistorySnapshot.get_async( + owner_id=None, + id="wiki1", + synapse_client=self.syn, + ) + # THEN the API should not be called + mocked_get.assert_not_called() + + async def test_get_async_missing_id(self) -> None: + # WHEN I call `get_async` + with patch( + "synapseclient.models.wiki.get_wiki_history" + ) as mocked_get, pytest.raises( + ValueError, match="Must provide id to get wiki history." + ): + await WikiHistorySnapshot.get_async( + owner_id="syn123", + id=None, + synapse_client=self.syn, + ) + # THEN the API should not be called + mocked_get.assert_not_called() + + +class TestWikiHeader: + """Tests for the WikiHeader class.""" + + @pytest.fixture(autouse=True, scope="function") + def init_syn(self, syn: Synapse) -> None: + self.syn = syn + + wiki_header = WikiHeader( + id="wiki1", + title="Test Wiki", + parent_id="1234", + ) + + api_response = { + "id": "wiki1", + "title": "Test Wiki", + "parentId": "1234", + } + + async def test_fill_from_dict(self) -> None: + # WHEN I call `fill_from_dict` with the example data + results = self.wiki_header.fill_from_dict(self.api_response) + + # THEN the WikiHeader object should be filled with the example data + assert results == self.wiki_header + + async def test_get_async_success(self) -> None: + # GIVEN mock responses + mock_responses = [ + { + "id": "wiki1", + "title": "Test Wiki", + "parentId": "1234", + }, + { + "id": "wiki2", + "title": "Test Wiki 2", + "parentId": "1234", + }, + ] + + # Create an async generator mock + async def mock_async_generator( + values: List[Dict[str, Any]] + ) -> AsyncGenerator[Dict[str, Any], None]: + for item in values: + yield item + + # WHEN I call `get_async` + with patch( + "synapseclient.models.wiki.get_wiki_header_tree", + return_value=mock_async_generator(mock_responses), + ) as mocked_get: + results = await WikiHeader.get_async( + owner_id="syn123", + synapse_client=self.syn, + offset=0, + limit=20, + ) + + # THEN the API should be called with correct parameters + mocked_get.assert_called_once_with( + owner_id="syn123", + offset=0, + limit=20, + synapse_client=self.syn, + ) + + # AND the results should contain the expected data + wiki_header_list = [ + WikiHeader(id="wiki1", title="Test Wiki", parent_id="1234"), + WikiHeader(id="wiki2", title="Test Wiki 2", parent_id="1234"), + ] + assert results == wiki_header_list + + async def test_get_async_missing_owner_id(self) -> None: + # WHEN I call `get_async` + # THEN it should raise ValueError + with patch( + "synapseclient.models.wiki.get_wiki_header_tree" + ) as mocked_get, pytest.raises( + ValueError, match="Must provide owner_id to get wiki header tree." + ): + await WikiHeader.get_async(owner_id=None, synapse_client=self.syn) + # THEN the API should not be called + mocked_get.assert_not_called() + + +class TestWikiPage: + """Tests for the WikiPage class.""" + + @pytest.fixture(autouse=True, scope="function") + def init_syn(self, syn: Synapse) -> None: + self.syn = syn + + wiki_page = WikiPage( + id="wiki1", + etag="etag123", + title="Test Wiki Page", + parent_id="parent_wiki", + markdown="# Test markdown text", + attachments=["test_1.txt", "test_2.txt"], + owner_id="syn123", + created_on="2023-01-01T00:00:00.000Z", + created_by="12345", + modified_on="2023-01-02T00:00:00.000Z", + modified_by="12345", + wiki_version="0", + markdown_file_handle_id=None, + attachment_file_handle_ids=[], + ) + + api_response = { + "id": "wiki1", + "etag": "etag123", + "title": "Test Wiki Page", + "parentWikiId": "parent_wiki", + "markdown": "# Test markdown text", + "attachments": ["test_1.txt", "test_2.txt"], + "ownerId": "syn123", + "createdOn": "2023-01-01T00:00:00.000Z", + "createdBy": "12345", + "modifiedOn": "2023-01-02T00:00:00.000Z", + "modifiedBy": "12345", + "wikiVersion": "0", + "markdownFileHandleId": None, + "attachmentFileHandleIds": [], + } + + def get_fresh_wiki_page(self) -> WikiPage: + """Helper method to get a fresh copy of the wiki_page for tests that need to modify it.""" + return copy.deepcopy(self.wiki_page) + + async def test_fill_from_dict(self) -> None: + # WHEN I call `fill_from_dict` with the example data + results = self.wiki_page.fill_from_dict(self.api_response) + + # THEN the WikiPage object should be filled with the example data + assert results == self.wiki_page + + async def test_to_synapse_request(self) -> None: + # WHEN I call `to_synapse_request` + results = self.wiki_page.to_synapse_request() + # delete none keys for expected response + expected_results = copy.deepcopy(self.api_response) + expected_results.pop("markdownFileHandleId", None) + # THEN the request should contain the correct data + assert results == expected_results + + def test_to_synapse_request_with_none_values(self) -> None: + # WHEN I call `to_synapse_request` + results = self.wiki_page.to_synapse_request() + # THEN the request should not contain None values + expected_results = copy.deepcopy(self.api_response) + expected_results.pop("markdownFileHandleId", None) + assert results == expected_results + + def test_to_gzip_file_with_string_content(self) -> None: + self.syn.cache.cache_root_dir = "/tmp/cache" + + # WHEN I call `_to_gzip_file` with a markdown string + with patch("os.path.isfile", return_value=False), patch( + "builtins.open", mock_open(read_data=b"test content") + ), patch("gzip.open", mock_open()), patch("os.path.exists", return_value=True): + file_path = self.wiki_page._to_gzip_file(self.wiki_page.markdown, self.syn) + + # THEN the content should be written to a gzipped file + assert file_path == "/tmp/cache/wiki_content/wiki_markdown_Test Wiki Page.md.gz" + + def test_to_gzip_file_with_gzipped_file(self) -> None: + with patch("os.path.isfile", return_value=True): + self.syn.cache.cache_root_dir = "/tmp/cache" + markdown_file_path = "wiki_markdown_Test Wiki Page.md.gz" + # WHEN I call `_to_gzip_file` with a gzipped file + file_path = self.wiki_page._to_gzip_file(markdown_file_path, self.syn) + + # THEN the filepath should be the same as the input + assert file_path == markdown_file_path + + def test_to_gzip_file_with_non_gzipped_file(self) -> None: + self.syn.cache.cache_root_dir = "/tmp/cache" + + # WHEN I call `_to_gzip_file` with a file path + with patch("os.path.isfile", return_value=True), patch( + "builtins.open", mock_open(read_data=b"test content") + ), patch("gzip.open", mock_open()), patch("os.path.exists", return_value=True): + file_path = self.wiki_page._to_gzip_file("/path/to/test.txt", self.syn) + + # THEN the file should be processed + assert file_path == "/tmp/cache/wiki_content/test.txt.gz" + + def test_to_gzip_file_with_invalid_content(self) -> None: + # WHEN I call `_to_gzip_file` with invalid content type + # THEN it should raise SyntaxError + with pytest.raises(SyntaxError, match="Expected a string, got int"): + self.wiki_page._to_gzip_file(123, self.syn) + + def test_get_file_size_success(self) -> None: + # GIVEN a filehandle dictionary + filehandle_dict = { + "list": [ + {"fileName": "test1.txt", "contentSize": "100"}, + {"fileName": "test2.txt", "contentSize": "200"}, + ] + } + + # WHEN I call `_get_file_size` + results = WikiPage._get_file_size(filehandle_dict, "test1.txt") + + # THEN the result should be the content size + assert results == "100" + + def test_get_file_size_file_not_found(self) -> None: + # GIVEN a filehandle dictionary + filehandle_dict = { + "list": [ + {"fileName": "test1.txt", "contentSize": "100"}, + {"fileName": "test2.txt", "contentSize": "200"}, + ] + } + + # WHEN I call `_get_file_size` with a non-existent file + # THEN it should raise ValueError + with pytest.raises( + ValueError, match="File nonexistent.txt not found in filehandle_dict" + ): + WikiPage._get_file_size(filehandle_dict, "nonexistent.txt") + + async def test_get_markdown_file_handle_success_with_markdown(self) -> WikiPage: + with patch( + "synapseclient.models.wiki.WikiPage._to_gzip_file", + return_value=("test.txt.gz"), + ) as mock_to_gzip_file, patch( + "synapseclient.models.wiki.upload_file_handle", + return_value={"id": "handle1"}, + ) as mock_upload, patch.object( + self.syn.logger, "debug" + ) as mock_logger, patch( + "os.path.exists", return_value=True + ), patch( + "os.remove" + ) as mock_remove: + # WHEN I call `_get_markdown_file_handle` + results = await self.wiki_page._get_markdown_file_handle( + synapse_client=self.syn + ) + + # THEN the markdown file handle should be uploaded + mock_to_gzip_file.assert_called_once_with( + wiki_content=self.wiki_page.markdown, synapse_client=self.syn + ) + mock_upload.assert_called_once_with( + syn=self.syn, + parent_entity_id=self.wiki_page.owner_id, + path="test.txt.gz", + ) + assert mock_logger.call_count == 2 + assert mock_logger.has_calls( + [ + call("Uploaded file handle handle1 for wiki page markdown."), + call("Deleted temp directory test.txt.gz"), + ] + ) + + # AND the temp gzipped file should be deleted + assert mock_remove.call_count == 1 + + # AND the result should be filled with the response + expected_results = self.get_fresh_wiki_page() + expected_results.markdown_file_handle_id = "handle1" + assert results == expected_results + + async def test_get_markdown_file_handle_no_markdown(self) -> WikiPage: + # GIVEN a WikiPage with no markdown + wiki_page = WikiPage( + id="wiki1", + title="Test Wiki Page", + attachments=["test_1.txt", "test_2.txt"], + ) + with patch.object(self.syn.logger, "info") as mock_logger: + # WHEN I call `_get_markdown_file_handle` + results = await wiki_page._get_markdown_file_handle(synapse_client=self.syn) + + # THEN the result should be the same WikiPage + assert mock_logger.call_count == 0 + assert results == wiki_page + + async def test_get_attachment_file_handles_success_multiple_attachments( + self, + ) -> WikiPage: + # GIVEN mock responses for file handles + mock_to_gzip_file_responses = [ + ("/tmp/cache1/test_1.txt.gz"), + ("/tmp/cache1/test_2.txt.gz"), + ] + mock_upload_responses = [ + {"id": "handle1"}, + {"id": "handle2"}, + ] + + with patch( + "synapseclient.models.wiki.WikiPage._to_gzip_file", + side_effect=mock_to_gzip_file_responses, + ) as mock_to_gzip_file, patch( + "synapseclient.models.wiki.upload_file_handle", + side_effect=mock_upload_responses, + ) as mock_upload, patch.object( + self.syn.logger, "debug" + ) as mock_logger, patch( + "os.path.exists", return_value=True + ), patch( + "os.remove" + ) as mock_remove: + # WHEN I call `_get_attachment_file_handles` + results = await self.wiki_page._get_attachment_file_handles( + synapse_client=self.syn + ) + # THEN _to_gzip_file should be called for each attachment + assert mock_to_gzip_file.call_count == len(self.wiki_page.attachments) + mock_to_gzip_file.assert_any_call( + wiki_content="test_1.txt", synapse_client=self.syn + ) + mock_to_gzip_file.assert_any_call( + wiki_content="test_2.txt", synapse_client=self.syn + ) + + # AND upload_file_handle should be called for each attachment + assert mock_upload.call_count == len(self.wiki_page.attachments) + mock_upload.assert_any_call( + syn=self.syn, + parent_entity_id=self.wiki_page.owner_id, + path="/tmp/cache1/test_1.txt.gz", + ) + mock_upload.assert_any_call( + syn=self.syn, + parent_entity_id=self.wiki_page.owner_id, + path="/tmp/cache1/test_2.txt.gz", + ) + assert mock_logger.call_count == 4 + assert mock_logger.has_calls( + [ + call("Uploaded file handle handle1 for wiki page attachment."), + call("Uploaded file handle handle2 for wiki page attachment."), + call("Deleted temp directory /tmp/cache1/test_1.txt.gz"), + call("Deleted temp directory /tmp/cache1/test_2.txt.gz"), + ] + ) + + # AND the temp directories should be cleaned up + mock_remove.assert_has_calls( + [ + call("/tmp/cache1/test_1.txt.gz"), + call("/tmp/cache1/test_2.txt.gz"), + ] + ) + + # AND the attachment file handle IDs should be set correctly + expected_attachment_handles = ["handle1", "handle2"] + assert results.attachment_file_handle_ids == expected_attachment_handles + + # AND the result should be the updated WikiPage + expected_results = self.get_fresh_wiki_page() + expected_results.attachment_file_handle_ids = expected_attachment_handles + assert results == expected_results + + async def test_get_attachment_file_handles_empty_attachments(self) -> WikiPage: + # GIVEN a WikiPage with no attachments + wiki_page = WikiPage( + id="wiki1", + title="Test Wiki Page", + markdown="# Test markdown text", + attachments=[], # Empty attachments + owner_id="syn123", + ) + + # WHEN I call `_get_attachment_file_handles` + results = await wiki_page._get_attachment_file_handles(synapse_client=self.syn) + + # THEN the result should be the same WikiPage + assert results == wiki_page + + async def test_get_attachment_file_handles_single_attachment(self) -> WikiPage: + # GIVEN a WikiPage with a single attachment + wiki_page = WikiPage( + id="wiki1", + title="Test Wiki Page", + markdown="# Test markdown text", + attachments=["test_1.txt"], + owner_id="syn123", + ) + + with patch( + "synapseclient.models.wiki.WikiPage._to_gzip_file", + return_value=("/tmp/cache/test_1.txt.gz"), + ) as mock_to_gzip_file, patch( + "synapseclient.models.wiki.upload_file_handle", + return_value={"id": "handle1"}, + ) as mock_upload, patch.object( + self.syn.logger, "debug" + ) as mock_logger, patch( + "os.path.exists", return_value=True + ), patch( + "os.remove" + ) as mock_remove: + # WHEN I call `_get_attachment_file_handles` + results = await wiki_page._get_attachment_file_handles( + synapse_client=self.syn + ) + + # THEN _to_gzip_file should be called once + mock_to_gzip_file.assert_called_once_with( + wiki_content="test_1.txt", synapse_client=self.syn + ) + + # AND upload_file_handle should be called once + mock_upload.assert_called_once_with( + syn=self.syn, + parent_entity_id=wiki_page.owner_id, + path="/tmp/cache/test_1.txt.gz", + ) + assert mock_logger.call_count == 2 + assert mock_logger.has_calls( + [ + call("Uploaded file handle handle1 for wiki page attachment."), + call("Deleted temp directory /tmp/cache/test_1.txt.gz"), + ] + ) + # AND the temp directory should be cleaned up + mock_remove.assert_called_once_with("/tmp/cache/test_1.txt.gz") + + # AND the attachment file handle ID should be set correctly + assert results.attachment_file_handle_ids == ["handle1"] + + # AND the result should be the updated WikiPage + wiki_page.attachment_file_handle_ids = ["handle1"] + assert results == wiki_page + + async def test_get_attachment_file_handles_cache_dir_not_exists(self) -> WikiPage: + # GIVEN a WikiPage with attachments + wiki_page = WikiPage( + id="wiki1", + title="Test Wiki Page", + markdown="# Test markdown text", + attachments=["test_1.txt"], + owner_id="syn123", + ) + + with patch( + "synapseclient.models.wiki.WikiPage._to_gzip_file", + return_value=("/tmp/cache/test_1.txt.gz"), + ), patch( + "synapseclient.models.wiki.upload_file_handle", + return_value={"id": "handle1"}, + ), patch( + "os.path.exists", return_value=False + ), patch.object( + self.syn.logger, "debug" + ) as mock_logger, patch( + "os.remove" + ) as mock_remove: + # WHEN I call `_get_attachment_file_handles` + results = await wiki_page._get_attachment_file_handles( + synapse_client=self.syn + ) + + # THEN the function should complete successfully + assert results.attachment_file_handle_ids == ["handle1"] + assert mock_logger.call_count == 1 + assert ( + mock_logger.call_args[0][0] + == "Uploaded file handle handle1 for wiki page attachment." + ) + # AND cleanup should not be attempted since directory doesn't exist + mock_remove.assert_not_called() + + async def test_get_attachment_file_handles_upload_failure(self) -> WikiPage: + # GIVEN a WikiPage with attachments + wiki_page = WikiPage( + id="wiki1", + title="Test Wiki Page", + markdown="# Test markdown text", + attachments=["test_1.txt"], + owner_id="syn123", + ) + + with patch( + "synapseclient.models.wiki.WikiPage._to_gzip_file", + return_value=("/tmp/cache/test_1.txt.gz"), + ), patch( + "synapseclient.models.wiki.upload_file_handle", + side_effect=Exception("Upload failed"), + ), patch( + "os.path.exists", return_value=True + ), patch.object( + self.syn.logger, "debug" + ) as mock_logger, patch( + "os.remove" + ) as mock_remove: + # WHEN I call `_get_attachment_file_handles` + # THEN it should raise the exception + with pytest.raises(Exception, match="Upload failed"): + await wiki_page._get_attachment_file_handles(synapse_client=self.syn) + + # AND cleanup should still be attempted + mock_remove.assert_called_once_with("/tmp/cache/test_1.txt.gz") + assert mock_logger.call_count == 1 + assert ( + mock_logger.call_args[0][0] + == "Deleted temp directory /tmp/cache/test_1.txt.gz" + ) + + async def test_determine_wiki_action_error_no_owner_id(self) -> None: + with patch( + "synapseclient.models.wiki.WikiHeader.get_async", + side_effect=SynapseHTTPError(response=Mock(status_code=404)), + ) as mock_get_header: + # GIVEN a WikiPage with no parent_id + wiki_page = WikiPage( + id="wiki1", + title="Test Wiki Page", + ) + + # WHEN I cal `determine_wiki_action` + # THEN it should raise ValueError + with pytest.raises( + ValueError, match="Must provide owner_id to modify a wiki page." + ): + await wiki_page._determine_wiki_action() + mock_get_header.assert_not_called() + + async def test_determine_wiki_action_create_root(self) -> None: + with patch( + "synapseclient.models.wiki.WikiHeader.get_async", + side_effect=SynapseHTTPError(response=Mock(status_code=404)), + ) as mock_get_header: + # GIVEN a WikiPage with no parent_id + wiki_page = WikiPage( + owner_id="syn123", + title="Test Wiki Page", + ) + # WHEN I call `determine_wiki_action` + # THEN it should return "create_root_wiki_page" + assert await wiki_page._determine_wiki_action() == "create_root_wiki_page" + mock_get_header.assert_called_once_with(owner_id="syn123") + + async def test_determine_wiki_action_create_sub(self) -> None: + with patch( + "synapseclient.models.wiki.WikiHeader.get_async", + side_effect=SynapseHTTPError(response=Mock(status_code=404)), + ) as mock_get_header: + # GIVEN a WikiPage with a parent_id + wiki_page = WikiPage( + owner_id="syn123", + title="Test Wiki Page", + parent_id="parent_wiki", + ) + # WHEN I call `determine_wiki_action` + # THEN it should return "create_sub_wiki_page" + assert await wiki_page._determine_wiki_action() == "create_sub_wiki_page" + mock_get_header.assert_not_called() + + async def test_determine_wiki_action_update_existing_root(self) -> None: + with patch( + "synapseclient.models.wiki.WikiHeader.get_async", + return_value=WikiHeader(id="wiki1", title="Test Wiki Page"), + ) as mock_get_header: + # GIVEN a WikiPage with an id + wiki_page = WikiPage( + id="wiki1", + owner_id="syn123", + title="Test Wiki Page", + ) + + # WHEN I call `determine_wiki_action` + # THEN it should return "update_existing_wiki_page" + assert ( + await wiki_page._determine_wiki_action() == "update_existing_wiki_page" + ) + mock_get_header.assert_called_once_with(owner_id="syn123") + + async def test_determine_wiki_action_update_existing_without_passing_id( + self, + ) -> None: + with patch( + "synapseclient.models.wiki.WikiHeader.get_async", + return_value=WikiHeader(id="wiki1", title="Test Wiki Page"), + ) as mock_get_header: + # GIVEN a WikiPage with an id and parent_id + wiki_page = WikiPage( + owner_id="syn123", + title="Test Wiki Page", + ) + # WHEN I call `determine_wiki_action` + # THEN it should raise ValueError + with pytest.raises( + ValueError, match="Must provide id to update existing wiki page." + ): + await wiki_page._determine_wiki_action() + mock_get_header.assert_called_once_with(owner_id="syn123") + + async def test_store_async_new_root_wiki_success(self) -> None: + # GIVEN a WikiPage + new_wiki_page = self.get_fresh_wiki_page() + new_wiki_page.parent_id = None + + # AND mock the post_wiki_page response + post_api_response = copy.deepcopy(self.api_response) + post_api_response["parentId"] = None + post_api_response["markdownFileHandleId"] = "markdown_file_handle_id" + post_api_response["attachmentFileHandleIds"] = [ + "attachment_file_handle_id_1", + "attachment_file_handle_id_2", + ] + + # Create mock WikiPage objects with the expected file handle IDs for markdown + mock_wiki_with_markdown = copy.deepcopy(new_wiki_page) + mock_wiki_with_markdown.markdown_file_handle_id = "markdown_file_handle_id" + + # Create mock WikiPage objects with the expected file handle IDs for attachments + mock_wiki_with_attachments = copy.deepcopy(mock_wiki_with_markdown) + mock_wiki_with_attachments.attachment_file_handle_ids = [ + "attachment_file_handle_id_1", + "attachment_file_handle_id_2", + ] + + # AND mock responses + with patch( + "synapseclient.models.wiki.WikiPage._determine_wiki_action", + return_value="create_root_wiki_page", + ), patch( + "synapseclient.models.wiki.WikiPage._get_markdown_file_handle", + return_value=mock_wiki_with_markdown, + ), patch( + "synapseclient.models.wiki.WikiPage._get_attachment_file_handles", + return_value=mock_wiki_with_attachments, + ), patch( + "synapseclient.models.wiki.post_wiki_page", return_value=post_api_response + ) as mock_post_wiki, patch.object( + self.syn.logger, "info" + ) as mock_logger: + # WHEN I call `store_async` + + results = await new_wiki_page.store_async(synapse_client=self.syn) + + # THEN log messages should be printed + assert mock_logger.call_count == 2 + assert mock_logger.has_calls( + [ + call( + "No wiki page exists within the owner. Create a new wiki page." + ), + call( + f"Created wiki page: {post_api_response['title']} with ID: {post_api_response['id']}." + ), + ] + ) + # Update the wiki_page with file handle ids for validation + new_wiki_page.markdown_file_handle_id = "markdown_file_handle_id" + new_wiki_page.attachment_file_handle_ids = [ + "attachment_file_handle_id_1", + "attachment_file_handle_id_2", + ] + + # AND the wiki should be created + mock_post_wiki.assert_called_once_with( + owner_id="syn123", + request=new_wiki_page.to_synapse_request(), + ) + # AND the result should be filled with the response + expected_results = new_wiki_page.fill_from_dict(post_api_response) + assert results == expected_results + + async def test_store_async_update_existing_wiki_success(self) -> None: + # GIVEN a WikiPage + new_wiki_page = self.get_fresh_wiki_page() + new_wiki_page.title = "Updated Wiki Page" + new_wiki_page.parent_id = None + new_wiki_page.etag = None + + # AND mock the get_wiki_page response + mock_get_wiki_response = copy.deepcopy(self.api_response) + mock_get_wiki_response["parentWikiId"] = None + mock_get_wiki_response["markdown"] = None + mock_get_wiki_response["attachments"] = [] + mock_get_wiki_response["markdownFileHandleId"] = None + mock_get_wiki_response["attachmentFileHandleIds"] = [] + + # Create mock WikiPage objects + mock_wiki_with_markdown = self.get_fresh_wiki_page() + mock_wiki_with_markdown.title = "Updated Wiki Page" + mock_wiki_with_markdown.parent_id = None + mock_wiki_with_markdown.markdown_file_handle_id = "markdown_file_handle_id" + + # Create mock WikiPage objects with the expected file handle IDs for attachments + mock_wiki_with_attachments = copy.deepcopy(mock_wiki_with_markdown) + mock_wiki_with_attachments.attachment_file_handle_ids = [ + "attachment_file_handle_id_1", + "attachment_file_handle_id_2", + ] + + # AND mock the put_wiki_page response + # Create mock WikiPage objects with the expected file handle IDs for markdown + mock_put_wiki_response = copy.deepcopy(self.api_response) + mock_put_wiki_response["title"] = "Updated Wiki Page" + mock_put_wiki_response["parentId"] = None + mock_put_wiki_response["markdownFileHandleId"] = "markdown_file_handle_id" + mock_put_wiki_response["attachmentFileHandleIds"] = [ + "attachment_file_handle_id_1", + "attachment_file_handle_id_2", + ] + + # AND mock responses + with patch( + "synapseclient.models.wiki.WikiPage._determine_wiki_action", + return_value="update_existing_wiki_page", + ), patch( + "synapseclient.models.wiki.WikiPage._get_markdown_file_handle", + return_value=mock_wiki_with_markdown, + ), patch( + "synapseclient.models.wiki.WikiPage._get_attachment_file_handles", + return_value=mock_wiki_with_attachments, + ), patch( + "synapseclient.models.wiki.get_wiki_page", + return_value=mock_get_wiki_response, + ) as mock_get_wiki, patch( + "synapseclient.models.wiki.put_wiki_page", + return_value=mock_put_wiki_response, + ) as mock_put_wiki, patch.object( + self.syn.logger, "info" + ) as mock_logger: + # WHEN I call `store_async` + results = await new_wiki_page.store_async(synapse_client=self.syn) + # THEN the existing wiki should be retrieved + mock_get_wiki.assert_called_once_with( + owner_id="syn123", + wiki_id="wiki1", + wiki_version="0", + ) + + # AND the wiki should be updated after merging dataclass objects + new_wiki_page.etag = "etag123" + new_wiki_page.created_on = "2023-01-01T00:00:00.000Z" + new_wiki_page.created_by = "12345" + new_wiki_page.modified_on = "2023-01-02T00:00:00.000Z" + new_wiki_page.modified_by = "12345" + new_wiki_page.markdown_file_handle_id = "markdown_file_handle_id" + new_wiki_page.attachment_file_handle_ids = [ + "attachment_file_handle_id_1", + "attachment_file_handle_id_2", + ] + mock_put_wiki.assert_called_once_with( + owner_id="syn123", + wiki_id="wiki1", + request=new_wiki_page.to_synapse_request(), + ) + + # AND log messages should be printed + assert mock_logger.call_count == 2 + assert mock_logger.has_calls( + [ + call( + "A wiki page already exists within the owner. Update the existing wiki page." + ), + call( + f"Updated wiki page: {self.api_response['title']} with ID: {self.api_response['id']}." + ), + ] + ) + # AND the result should be filled with the response + expected_results = new_wiki_page.fill_from_dict(mock_put_wiki_response) + assert results == expected_results + + async def test_store_async_create_sub_wiki_success(self) -> None: + # AND mock the post_wiki_page response + post_api_response = copy.deepcopy(self.api_response) + post_api_response["markdownFileHandleId"] = "markdown_file_handle_id" + post_api_response["attachmentFileHandleIds"] = [ + "attachment_file_handle_id_1", + "attachment_file_handle_id_2", + ] + + # Create mock WikiPage objects with the expected file handle IDs for markdown + mock_wiki_with_markdown = self.get_fresh_wiki_page() + mock_wiki_with_markdown.markdown_file_handle_id = "markdown_file_handle_id" + + # Create mock WikiPage objects with the expected file handle IDs for attachments + mock_wiki_with_attachments = copy.deepcopy(mock_wiki_with_markdown) + mock_wiki_with_attachments.attachment_file_handle_ids = [ + "attachment_file_handle_id_1", + "attachment_file_handle_id_2", + ] + + # AND mock responses + with patch( + "synapseclient.models.wiki.WikiPage._determine_wiki_action", + return_value="create_sub_wiki_page", + ), patch( + "synapseclient.models.wiki.WikiPage._get_markdown_file_handle", + return_value=mock_wiki_with_markdown, + ), patch( + "synapseclient.models.wiki.WikiPage._get_attachment_file_handles", + return_value=mock_wiki_with_attachments, + ), patch( + "synapseclient.models.wiki.post_wiki_page", return_value=post_api_response + ) as mock_post_wiki, patch.object( + self.syn.logger, "info" + ) as mock_logger: + # WHEN I call `store_async` + results = await self.wiki_page.store_async(synapse_client=self.syn) + + # THEN log messages should be printed + assert mock_logger.call_count == 2 + assert mock_logger.has_calls( + [ + call("Creating sub-wiki page under parent ID: parent_wiki"), + call( + f"Created sub-wiki page: {post_api_response['title']} with ID: {post_api_response['id']} under parent: parent_wiki" + ), + ] + ) + + # Update the wiki_page with file handle ids for validation + new_wiki_page = self.get_fresh_wiki_page() + new_wiki_page.markdown_file_handle_id = "markdown_file_handle_id" + new_wiki_page.attachment_file_handle_ids = [ + "attachment_file_handle_id_1", + "attachment_file_handle_id_2", + ] + + # AND the wiki should be created + mock_post_wiki.assert_called_once_with( + owner_id="syn123", + request=new_wiki_page.to_synapse_request(), + synapse_client=self.syn, + ) + + # AND the result should be filled with the response + expected_results = new_wiki_page.fill_from_dict(post_api_response) + assert results == expected_results + + @pytest.mark.parametrize( + "wiki_page, expected_error", + [ + ( + WikiPage(owner_id=None, title="Test Wiki", wiki_version="0"), + "Must provide owner_id to restore a wiki page.", + ), + ( + WikiPage(owner_id="syn123", id=None, wiki_version="0"), + "Must provide id to restore a wiki page.", + ), + ( + WikiPage(owner_id="syn123", id="wiki1", wiki_version=None), + "Must provide wiki_version to restore a wiki page.", + ), + ], + ) + async def test_restore_async_missing_required_parameters( + self, wiki_page, expected_error + ) -> None: + # WHEN I call `restore_async` + # THEN it should raise ValueError + with patch( + "synapseclient.models.wiki.put_wiki_version" + ) as mocked_put, pytest.raises(ValueError, match=expected_error): + await wiki_page.restore_async(synapse_client=self.syn) + # THEN the API should not be called + mocked_put.assert_not_called() + + async def test_restore_async_success(self) -> None: + # GIVEN a WikiPage + new_wiki_page = self.get_fresh_wiki_page() + with patch( + "synapseclient.models.wiki.put_wiki_version", return_value=self.api_response + ) as mock_put_wiki_version: + # WHEN I call `restore_async` + results = await self.wiki_page.restore_async(synapse_client=self.syn) + + # THEN the API should be called with correct parameters + mock_put_wiki_version.assert_called_once_with( + owner_id="syn123", + wiki_id="wiki1", + wiki_version="0", + request=new_wiki_page.to_synapse_request(), + synapse_client=self.syn, + ) + # AND the result should be filled with the response + expected_results = new_wiki_page.fill_from_dict(self.api_response) + assert results == expected_results + + async def test_get_async_by_id_success(self) -> None: + # GIVEN a WikiPage object with id + wiki = WikiPage( + id="wiki1", + owner_id="syn123", + ) + + # AND a mock response + with patch("synapseclient.models.wiki.get_wiki_page") as mock_get_wiki: + mock_get_wiki.return_value = self.api_response + + # WHEN I call `get_async` + results = await wiki.get_async(synapse_client=self.syn) + + # THEN the API should be called with correct parameters + mock_get_wiki.assert_called_once_with( + owner_id="syn123", + wiki_id="wiki1", + wiki_version=None, + synapse_client=self.syn, + ) + + # AND the result should be filled with the response + expected_wiki = self.wiki_page.fill_from_dict(self.api_response) + assert results == expected_wiki + + async def test_get_async_by_title_success(self) -> None: + # GIVEN a WikiPage object with title but no id + wiki = WikiPage( + title="Test Wiki", + owner_id="syn123", + ) + + # AND mock responses + mock_responses = [ + {"id": "wiki1", "title": "Test Wiki", "parentId": None}, + {"id": "wiki2", "title": "Test Wiki 2", "parentId": None}, + ] + + # Create an async generator mock + async def mock_async_generator(values): + for item in values: + yield item + + with patch( + "synapseclient.models.wiki.get_wiki_header_tree", + return_value=mock_async_generator(mock_responses), + ) as mock_get_header_tree, patch( + "synapseclient.models.wiki.get_wiki_page", return_value=self.api_response + ) as mock_get_wiki: + # WHEN I call `get_async` + results = await wiki.get_async(synapse_client=self.syn) + + # THEN the header tree should be retrieved + mock_get_header_tree.assert_called_once_with( + owner_id="syn123", + synapse_client=self.syn, + ) + + # AND the wiki should be retrieved by id + mock_get_wiki.assert_called_once_with( + owner_id="syn123", + wiki_id="wiki1", + wiki_version=None, + synapse_client=self.syn, + ) + + # AND the result should be filled with the response + expected_wiki = self.wiki_page.fill_from_dict(self.api_response) + assert results == expected_wiki + + async def test_get_async_by_title_not_found(self) -> None: + # GIVEN a WikiPage object with title but no id + wiki = WikiPage( + title="Non-existent Wiki", + owner_id="syn123", + ) + + # AND mock responses that don't contain the title + mock_responses = [{"id": "wiki1", "title": "Different Wiki", "parentId": None}] + + # Create an async generator mock + async def mock_async_generator(values): + for item in values: + yield item + + with patch( + "synapseclient.models.wiki.get_wiki_header_tree", + return_value=mock_async_generator(mock_responses), + ) as mock_get_header_tree: + # WHEN I call `get_async` + # THEN it should raise ValueError + with pytest.raises( + ValueError, match="No wiki page found with title: Non-existent Wiki" + ): + await wiki.get_async(synapse_client=self.syn) + mock_get_header_tree.assert_called_once_with( + owner_id="syn123", + synapse_client=self.syn, + ) + + @pytest.mark.parametrize( + "wiki_page, expected_error", + [ + ( + WikiPage(id="wiki1"), + "Must provide owner_id to delete a wiki page.", + ), + ( + WikiPage(owner_id="syn123"), + "Must provide id to delete a wiki page.", + ), + ], + ) + async def test_delete_async_missing_required_parameters( + self, wiki_page, expected_error + ) -> None: + # WHEN I call `delete_async` + # THEN it should raise ValueError + with patch( + "synapseclient.models.wiki.delete_wiki_page" + ) as mocked_delete, pytest.raises(ValueError, match=expected_error): + await wiki_page.delete_async(synapse_client=self.syn) + # THEN the API should not be called + mocked_delete.assert_not_called() + + async def test_delete_async_success(self) -> None: + # WHEN I call `delete_async` + # THEN it should call the API with the correct parameters + with patch("synapseclient.models.wiki.delete_wiki_page") as mocked_delete: + await self.wiki_page.delete_async(synapse_client=self.syn) + mocked_delete.assert_called_once_with( + owner_id="syn123", + wiki_id="wiki1", + synapse_client=self.syn, + ) + + @pytest.mark.parametrize( + "wiki_page, expected_error", + [ + ( + WikiPage(id="wiki1"), + "Must provide owner_id to get attachment handles.", + ), + ( + WikiPage(owner_id="syn123"), + "Must provide id to get attachment handles.", + ), + ], + ) + async def test_get_attachment_handles_async_missing_required_parameters( + self, wiki_page, expected_error + ) -> None: + # WHEN I call `get_attachment_handles_async` + # THEN it should raise ValueError + with patch( + "synapseclient.models.wiki.get_attachment_handles" + ) as mocked_get, pytest.raises(ValueError, match=expected_error): + await wiki_page.get_attachment_handles_async(synapse_client=self.syn) + # THEN the API should not be called + mocked_get.assert_not_called() + + async def test_get_attachment_handles_async_success(self) -> None: + # mock responses + mock_handles = [{"id": "handle1", "fileName": "test.txt"}] + with patch( + "synapseclient.models.wiki.get_attachment_handles", + return_value=mock_handles, + ) as mock_get_handles: + # WHEN I call `get_attachment_handles_async` + results = await self.wiki_page.get_attachment_handles_async( + synapse_client=self.syn + ) + + # THEN the API should be called with correct parameters + mock_get_handles.assert_called_once_with( + owner_id="syn123", + wiki_id="wiki1", + wiki_version="0", + synapse_client=self.syn, + ) + # AND the result should be the handles + assert results == mock_handles + + @pytest.mark.parametrize( + "wiki_page, file_name, expected_error", + [ + ( + WikiPage(id="wiki1"), + "test.txt", + "Must provide owner_id to get attachment URL.", + ), + ( + WikiPage(owner_id="syn123"), + "test.txt", + "Must provide id to get attachment URL.", + ), + ( + WikiPage(owner_id="syn123", id="wiki1"), + None, + "Must provide file_name to get attachment URL.", + ), + ], + ) + async def test_get_attachment_async_missing_required_parameters( + self, file_name, wiki_page, expected_error + ) -> None: + # WHEN I call `get_attachment_async` + # THEN it should raise ValueError + with patch( + "synapseclient.models.wiki.get_attachment_url" + ) as mocked_get, pytest.raises(ValueError, match=expected_error): + await wiki_page.get_attachment_async( + file_name=file_name, + synapse_client=self.syn, + ) + # THEN the API should not be called + mocked_get.assert_not_called() + + @pytest.mark.parametrize("file_size", [8 * 1024 * 1024 - 1, 8 * 1024 * 1024 + 1]) + async def test_get_attachment_async_download_file_success(self, file_size) -> None: + # AND mock responses + mock_attachment_url = "https://example.com/attachment.txt" + mock_filehandle_dict = { + "list": [ + { + "fileName": "test.txt", + "contentSize": str(file_size), + } + ] + } + + with patch( + "synapseclient.models.wiki.get_attachment_url", + return_value=mock_attachment_url, + ) as mock_get_url, patch( + "synapseclient.models.wiki.get_attachment_handles", + return_value=mock_filehandle_dict, + ) as mock_get_handles, patch( + "synapseclient.models.wiki.download_from_url" + ) as mock_download_from_url, patch( + "synapseclient.models.wiki.download_from_url_multi_threaded" + ) as mock_download_from_url_multi_threaded, patch( + "synapseclient.models.wiki._pre_signed_url_expiration_time", + return_value="2030-01-01T00:00:00.000Z", + ) as mock_expiration_time, patch.object( + self.syn.logger, "debug" + ) as mock_logger: + # WHEN I call `get_attachment_async` with download_file=True + result = await self.wiki_page.get_attachment_async( + file_name="test.txt", + download_file=True, + download_location="/tmp/download", + synapse_client=self.syn, + ) + + # THEN the attachment URL should be retrieved + mock_get_url.assert_called_once_with( + owner_id="syn123", + wiki_id="wiki1", + file_name="test.txt", + wiki_version="0", + synapse_client=self.syn, + ) + + # AND the attachment handles should be retrieved + mock_get_handles.assert_called_once_with( + owner_id="syn123", + wiki_id="wiki1", + wiki_version="0", + synapse_client=self.syn, + ) + + # AND the expiration time should be calculated + mock_expiration_time.assert_called_once_with(mock_attachment_url) + + # AND the appropriate download method should be called based on file size + if file_size < 8 * 1024 * 1024: + # Single-threaded download for files smaller than 8 MiB + mock_download_from_url.assert_called_once_with( + url=mock_attachment_url, + destination="/tmp/download", + url_is_presigned=True, + ) + mock_download_from_url_multi_threaded.assert_not_called() + + else: + # construct a mock presigned url info + mock_presigned_url_info = PresignedUrlInfo( + file_name="test.txt", + url=mock_attachment_url, + expiration_utc="2030-01-01T00:00:00.000Z", + ) + # Multi-threaded download for files larger than or equal to 8 MiB + mock_download_from_url_multi_threaded.assert_called_once_with( + presigned_url=mock_presigned_url_info, + destination="/tmp/download", + ) + mock_download_from_url.assert_not_called() + + # AND debug log should be called once (only the general one) + assert mock_logger.call_count == 1 + mock_logger.assert_called_once_with( + f"Downloaded file test.txt to /tmp/download" + ) + # AND the result should be None (since download_file=True) + assert result is None + + async def test_get_attachment_async_no_file_download(self) -> None: + with patch( + "synapseclient.models.wiki.get_attachment_url", + return_value="https://example.com/attachment.txt", + ) as mock_get_url: + # WHEN I call `get_attachment_async` with download_file=True but no download_location + # THEN it should return the attachment URL + results = await self.wiki_page.get_attachment_async( + file_name="test.txt", + download_file=False, + synapse_client=self.syn, + ) + # AND the result should be the attachment URL + assert results == "https://example.com/attachment.txt" + + async def test_get_attachment_async_download_file_missing_location(self) -> None: + # GIVEN a WikiPage object + wiki = WikiPage( + id="wiki1", + owner_id="syn123", + wiki_version="0", + ) + + # AND a mock attachment URL + mock_attachment_url = "https://example.com/attachment.txt" + + with patch( + "synapseclient.models.wiki.get_attachment_url", + return_value=mock_attachment_url, + ) as mock_get_url, patch( + "synapseclient.models.wiki.get_attachment_handles" + ) as mock_get_handles: + # WHEN I call `get_attachment_async` with download_file=True but no download_location + # THEN it should raise ValueError + with pytest.raises( + ValueError, match="Must provide download_location to download a file." + ): + await wiki.get_attachment_async( + file_name="test.txt", + download_file=True, + download_location=None, + synapse_client=self.syn, + ) + + # AND the attachment URL should still be retrieved + mock_get_url.assert_called_once_with( + owner_id="syn123", + wiki_id="wiki1", + file_name="test.txt", + wiki_version="0", + synapse_client=self.syn, + ) + + # AND the attachment handles should not be retrieved + mock_get_handles.assert_not_called() + + @pytest.mark.parametrize( + "wiki_page, file_name, expected_error", + [ + ( + WikiPage(id="wiki1"), + "test.txt", + "Must provide owner_id to get attachment preview URL.", + ), + ( + WikiPage(owner_id="syn123"), + "test.txt", + "Must provide id to get attachment preview URL.", + ), + ( + WikiPage(owner_id="syn123", id="wiki1"), + None, + "Must provide file_name to get attachment preview URL.", + ), + ], + ) + async def test_get_attachment_preview_async_missing_required_parameters( + self, file_name, wiki_page, expected_error + ) -> None: + # WHEN I call `get_attachment_preview_url_async` + # THEN it should raise ValueError + with patch( + "synapseclient.models.wiki.get_attachment_preview_url" + ) as mocked_get, pytest.raises(ValueError, match=expected_error): + await wiki_page.get_attachment_preview_async( + file_name=file_name, + synapse_client=self.syn, + ) + # THEN the API should not be called + mocked_get.assert_not_called() + + @pytest.mark.parametrize("file_size", [8 * 1024 * 1024 - 1, 8 * 1024 * 1024 + 1]) + async def test_get_attachment_preview_async_download_file_success( + self, file_size + ) -> None: + # Mock responses + mock_attachment_url = "https://example.com/attachment.txt" + mock_filehandle_dict = { + "list": [ + { + "fileName": "test.txt", + "contentSize": str(file_size), + } + ] + } + + with patch( + "synapseclient.models.wiki.get_attachment_preview_url", + return_value=mock_attachment_url, + ) as mock_get_url, patch( + "synapseclient.models.wiki.get_attachment_handles", + return_value=mock_filehandle_dict, + ) as mock_get_handles, patch( + "synapseclient.models.wiki.download_from_url" + ) as mock_download_from_url, patch( + "synapseclient.models.wiki.download_from_url_multi_threaded" + ) as mock_download_from_url_multi_threaded, patch( + "synapseclient.models.wiki._pre_signed_url_expiration_time", + return_value="2030-01-01T00:00:00.000Z", + ) as mock_expiration_time, patch.object( + self.syn.logger, "debug" + ) as mock_logger: + # WHEN I call `get_attachment_async` with download_file=True + result = await self.wiki_page.get_attachment_preview_async( + file_name="test.txt", + download_file=True, + download_location="/tmp/download", + synapse_client=self.syn, + ) + + # THEN the attachment URL should be retrieved + mock_get_url.assert_called_once_with( + owner_id="syn123", + wiki_id="wiki1", + file_name="test.txt", + wiki_version="0", + synapse_client=self.syn, + ) + + # AND the attachment handles should be retrieved + mock_get_handles.assert_called_once_with( + owner_id="syn123", + wiki_id="wiki1", + wiki_version="0", + synapse_client=self.syn, + ) + + # AND the expiration time should be calculated + mock_expiration_time.assert_called_once_with(mock_attachment_url) + + # AND the appropriate download method should be called based on file size + if file_size < 8 * 1024 * 1024: + # Single-threaded download for files smaller than 8 MiB + mock_download_from_url.assert_called_once_with( + url=mock_attachment_url, + destination="/tmp/download", + url_is_presigned=True, + ) + mock_download_from_url_multi_threaded.assert_not_called() + + else: + # construct a mock presigned url info + mock_presigned_url_info = PresignedUrlInfo( + file_name="test.txt", + url=mock_attachment_url, + expiration_utc="2030-01-01T00:00:00.000Z", + ) + # Multi-threaded download for files larger than or equal to 8 MiB + mock_download_from_url_multi_threaded.assert_called_once_with( + presigned_url=mock_presigned_url_info, + destination="/tmp/download", + ) + mock_download_from_url.assert_not_called() + + # AND debug log should be called once (only the general one) + assert mock_logger.call_count == 1 + mock_logger.assert_called_once_with( + f"Downloaded the preview file test.txt to /tmp/download" + ) + # AND the result should be None (since download_file=True) + assert result is None + + async def test_get_attachment_preview_async_no_file_download(self) -> None: + with patch( + "synapseclient.models.wiki.get_attachment_preview_url", + return_value="https://example.com/attachment.txt", + ) as mock_get_url: + # WHEN I call `get_attachment_preview_async` with download_file=False + # THEN it should return the attachment URL + results = await self.wiki_page.get_attachment_preview_async( + file_name="test.txt", + download_file=False, + synapse_client=self.syn, + ) + # AND the result should be the attachment URL + assert results == "https://example.com/attachment.txt" + + async def test_get_attachment_preview_async_download_file_missing_location( + self, + ) -> None: + # GIVEN a WikiPage object + wiki = WikiPage( + id="wiki1", + owner_id="syn123", + wiki_version="0", + ) + + # AND a mock attachment URL + mock_attachment_url = "https://example.com/attachment.txt" + + with patch( + "synapseclient.models.wiki.get_attachment_preview_url", + return_value=mock_attachment_url, + ) as mock_get_url, patch( + "synapseclient.models.wiki.get_attachment_handles" + ) as mock_get_handles: + # WHEN I call `get_attachment_async` with download_file=True but no download_location + # THEN it should raise ValueError + with pytest.raises( + ValueError, match="Must provide download_location to download a file." + ): + await wiki.get_attachment_preview_async( + file_name="test.txt", + download_file=True, + download_location=None, + synapse_client=self.syn, + ) + + # AND the attachment URL should still be retrieved + mock_get_url.assert_called_once_with( + owner_id="syn123", + wiki_id="wiki1", + file_name="test.txt", + wiki_version="0", + synapse_client=self.syn, + ) + # AND the attachment handles should not be retrieved + mock_get_handles.assert_not_called() + + @pytest.mark.parametrize( + "wiki_page, expected_error", + [ + ( + WikiPage(id="wiki1"), + "Must provide owner_id to get markdown URL.", + ), + ( + WikiPage(owner_id="syn123"), + "Must provide id to get markdown URL.", + ), + ], + ) + async def test_get_markdown_async_missing_required_parameters( + self, wiki_page, expected_error + ) -> None: + # WHEN I call `get_markdown_async` + # THEN it should raise ValueError + with patch( + "synapseclient.models.wiki.get_markdown_url" + ) as mocked_get, pytest.raises(ValueError, match=expected_error): + await wiki_page.get_markdown_async(synapse_client=self.syn) + # THEN the API should not be called + mocked_get.assert_not_called() + + async def test_get_markdown_async_download_file_success(self) -> None: + # Mock responses + mock_markdown_url = "https://example.com/markdown.md" + + with patch( + "synapseclient.models.wiki.get_markdown_url", + return_value=mock_markdown_url, + ) as mock_get_url, patch( + "synapseclient.models.wiki.download_from_url" + ) as mock_download_from_url, patch( + "synapseclient.models.wiki._pre_signed_url_expiration_time", + return_value="2030-01-01T00:00:00.000Z", + ) as mock_expiration_time, patch.object( + self.syn.logger, "debug" + ) as mock_logger: + # WHEN I call `get_markdown_async` with download_file=True + result = await self.wiki_page.get_markdown_async( + download_file_name="test.md", + download_file=True, + download_location="/tmp/download", + synapse_client=self.syn, + ) + + # THEN the markdown URL should be retrieved + mock_get_url.assert_called_once_with( + owner_id="syn123", + wiki_id="wiki1", + wiki_version="0", + synapse_client=self.syn, + ) + + # AND the expiration time should be calculated + mock_expiration_time.assert_called_once_with(mock_markdown_url) + + # AND the file should be downloaded using single-threaded download + mock_download_from_url.assert_called_once_with( + url=mock_markdown_url, + destination="/tmp/download", + url_is_presigned=True, + ) + + # AND debug log should be called + assert mock_logger.call_count == 1 + mock_logger.assert_called_once_with( + f"Downloaded file test.md to /tmp/download" + ) + + # AND the result should be None (since download_file=True) + assert result is None + + async def test_get_markdown_async_no_file_download(self) -> None: + with patch( + "synapseclient.models.wiki.get_markdown_url", + return_value="https://example.com/markdown.md", + ) as mock_get_url: + # WHEN I call `get_markdown_async` with download_file=False + results = await self.wiki_page.get_markdown_async( + download_file=False, + synapse_client=self.syn, + ) + + # THEN the markdown URL should be retrieved + mock_get_url.assert_called_once_with( + owner_id="syn123", + wiki_id="wiki1", + wiki_version="0", + synapse_client=self.syn, + ) + + # AND the result should be the markdown URL + assert results == "https://example.com/markdown.md" + + async def test_get_markdown_async_download_file_missing_location(self) -> None: + # GIVEN a WikiPage object + wiki = WikiPage( + id="wiki1", + owner_id="syn123", + wiki_version="0", + ) + + # AND a mock markdown URL + mock_markdown_url = "https://example.com/markdown.md" + + with patch( + "synapseclient.models.wiki.get_markdown_url", + return_value=mock_markdown_url, + ) as mock_get_url, patch( + "synapseclient.models.wiki.get_attachment_handles" + ) as mock_get_handles: + # WHEN I call `get_markdown_async` with download_file=True but no download_location + # THEN it should raise ValueError + with pytest.raises( + ValueError, match="Must provide download_location to download a file." + ): + await wiki.get_markdown_async( + download_file_name="test.md", + download_file=True, + download_location=None, + synapse_client=self.syn, + ) + + # AND the markdown URL should still be retrieved + mock_get_url.assert_called_once_with( + owner_id="syn123", + wiki_id="wiki1", + wiki_version="0", + synapse_client=self.syn, + ) + # AND the attachment handles should not be retrieved + mock_get_handles.assert_not_called() + + async def test_get_markdown_async_download_file_missing_filename(self) -> None: + # GIVEN a WikiPage object + wiki = WikiPage( + id="wiki1", + owner_id="syn123", + wiki_version="0", + ) + + # AND a mock markdown URL + mock_markdown_url = "https://example.com/markdown.md" + + with patch( + "synapseclient.models.wiki.get_markdown_url", + return_value=mock_markdown_url, + ) as mock_get_url, patch( + "synapseclient.models.wiki.get_attachment_handles" + ) as mock_get_handles: + # WHEN I call `get_markdown_async` with download_file=True but no download_file_name + # THEN it should raise ValueError + with pytest.raises( + ValueError, match="Must provide download_file_name to download a file." + ): + await wiki.get_markdown_async( + download_file_name=None, + download_file=True, + download_location="/tmp/download", + synapse_client=self.syn, + ) + + # AND the markdown URL should still be retrieved + mock_get_url.assert_called_once_with( + owner_id="syn123", + wiki_id="wiki1", + wiki_version="0", + synapse_client=self.syn, + ) + # AND the attachment handles should not be retrieved + mock_get_handles.assert_not_called() + + async def test_get_markdown_async_with_different_wiki_version(self) -> None: + # GIVEN a WikiPage object with a specific wiki version + wiki = WikiPage( + id="wiki1", + owner_id="syn123", + wiki_version="2", + ) + + with patch( + "synapseclient.models.wiki.get_markdown_url", + return_value="https://example.com/markdown_v2.md", + ) as mock_get_url: + # WHEN I call `get_markdown_async` + results = await wiki.get_markdown_async( + download_file=False, + synapse_client=self.syn, + ) + + # THEN the markdown URL should be retrieved with the correct wiki version + mock_get_url.assert_called_once_with( + owner_id="syn123", + wiki_id="wiki1", + wiki_version="2", + synapse_client=self.syn, + ) + + # AND the result should be the markdown URL + assert results == "https://example.com/markdown_v2.md" + + async def test_get_markdown_async_with_none_wiki_version(self) -> None: + # GIVEN a WikiPage object with None wiki version + wiki = WikiPage( + id="wiki1", + owner_id="syn123", + wiki_version=None, + ) + + with patch( + "synapseclient.models.wiki.get_markdown_url", + return_value="https://example.com/markdown_latest.md", + ) as mock_get_url: + # WHEN I call `get_markdown_async` + results = await wiki.get_markdown_async( + download_file=False, + synapse_client=self.syn, + ) + + # THEN the markdown URL should be retrieved with None wiki version + mock_get_url.assert_called_once_with( + owner_id="syn123", + wiki_id="wiki1", + wiki_version=None, + synapse_client=self.syn, + ) + + # AND the result should be the markdown URL + assert results == "https://example.com/markdown_latest.md" diff --git a/tests/unit/synapseclient/models/synchronous/unit_test_wiki.py b/tests/unit/synapseclient/models/synchronous/unit_test_wiki.py new file mode 100644 index 000000000..efb743c4d --- /dev/null +++ b/tests/unit/synapseclient/models/synchronous/unit_test_wiki.py @@ -0,0 +1,1558 @@ +"""Synchronous tests for the synapseclient.models.wiki classes.""" +import copy +from typing import Any, AsyncGenerator, Dict, List +from unittest.mock import Mock, call, mock_open, patch + +import pytest + +from synapseclient import Synapse +from synapseclient.core.exceptions import SynapseHTTPError +from synapseclient.models.wiki import ( + PresignedUrlInfo, + WikiHeader, + WikiHistorySnapshot, + WikiOrderHint, + WikiPage, +) + + +class TestWikiOrderHint: + """Tests for the WikiOrderHint class.""" + + @pytest.fixture(autouse=True, scope="function") + def init_syn(self, syn: Synapse) -> None: + self.syn = syn + + # Construct a WikiOrderHint object + order_hint = WikiOrderHint( + owner_id="syn123", + owner_object_type="org.sagebionetworks.repo.model.Project", + id_list=["wiki1", "wiki2", "wiki3"], + etag="etag123", + ) + + api_response = { + "ownerId": "syn123", + "ownerObjectType": "org.sagebionetworks.repo.model.Project", + "idList": ["wiki1", "wiki2", "wiki3"], + "etag": "etag123", + } + + def test_fill_from_dict(self) -> None: + # WHEN I call `fill_from_dict` with the API response + result = self.order_hint.fill_from_dict(self.api_response) + + # THEN the WikiOrderHint object should be filled with the example data + assert result == self.order_hint + + def test_to_synapse_request(self): + # WHEN I call `to_synapse_request` on an initialized order hint + results = self.order_hint.to_synapse_request() + + # THEN the request should contain the correct data + assert results == self.api_response + + def test_to_synapse_request_with_none_values(self) -> None: + # GIVEN a WikiOrderHint object with None values + order_hint = WikiOrderHint( + owner_id="syn123", + owner_object_type=None, + id_list=[], + etag=None, + ) + + # WHEN I call `to_synapse_request` + results = order_hint.to_synapse_request() + + # THEN the request should not contain None values + assert results == {"ownerId": "syn123", "idList": []} + + def test_store_success(self) -> None: + # GIVEN a mock response + with patch( + "synapseclient.models.wiki.put_wiki_order_hint", + return_value=self.api_response, + ) as mocked_put: + # WHEN I call `store` + results = self.order_hint.store(synapse_client=self.syn) + + # THEN the API should be called with correct parameters + mocked_put.assert_called_once_with( + owner_id=self.order_hint.owner_id, + request=self.order_hint.to_synapse_request(), + synapse_client=self.syn, + ) + + # AND the result should be updated with the response + assert results == self.order_hint + + def test_store_missing_owner_id(self) -> None: + # GIVEN a WikiOrderHint object without owner_id + order_hint = WikiOrderHint( + owner_object_type="org.sagebionetworks.repo.model.Project", + id_list=["wiki1", "wiki2"], + ) + + # WHEN I call `store` + # THEN it should raise ValueError + with patch( + "synapseclient.models.wiki.put_wiki_order_hint", + return_value=self.api_response, + ) as mocked_put, pytest.raises( + ValueError, match="Must provide owner_id to store wiki order hint." + ): + order_hint.store(synapse_client=self.syn) + # THEN the API should not be called + mocked_put.assert_not_called() + + def test_get_success(self) -> None: + # WHEN I call `get` + with patch( + "synapseclient.models.wiki.get_wiki_order_hint", + return_value=self.api_response, + ) as mocked_get: + results = self.order_hint.get(synapse_client=self.syn) + + # THEN the API should be called with correct parameters + mocked_get.assert_called_once_with( + owner_id="syn123", + synapse_client=self.syn, + ) + + # AND the result should be filled with the response + assert results == self.order_hint + + def test_get_missing_owner_id(self) -> None: + # GIVEN a WikiOrderHint object without owner_id + self.order_hint.owner_id = None + # WHEN I call `get` + # THEN it should raise ValueError + with patch( + "synapseclient.models.wiki.get_wiki_order_hint" + ) as mocked_get, pytest.raises( + ValueError, match="Must provide owner_id to get wiki order hint." + ): + self.order_hint.get(synapse_client=self.syn) + # THEN the API should not be called + mocked_get.assert_not_called() + + +class TestWikiHistorySnapshot: + """Tests for the WikiHistorySnapshot class.""" + + @pytest.fixture(autouse=True, scope="function") + def init_syn(self, syn: Synapse) -> None: + self.syn = syn + + # Construct a WikiHistorySnapshot object + history_snapshot = WikiHistorySnapshot( + version="1", + modified_on="2023-01-01T00:00:00.000Z", + modified_by="12345", + ) + + # Construct an API response + api_response = { + "version": "1", + "modifiedOn": "2023-01-01T00:00:00.000Z", + "modifiedBy": "12345", + } + + def test_fill_from_dict(self) -> None: + # WHEN I call `fill_from_dict` with the API response + results = self.history_snapshot.fill_from_dict(self.api_response) + + # THEN the WikiHistorySnapshot object should be filled with the example data + assert results == self.history_snapshot + + def test_get_success(self) -> None: + # GIVEN mock responses + mock_responses = [ + { + "version": 1, + "modifiedOn": "2023-01-01T00:00:00.000Z", + "modifiedBy": "12345", + }, + { + "version": 2, + "modifiedOn": "2023-01-02T00:00:00.000Z", + "modifiedBy": "12345", + }, + { + "version": 3, + "modifiedOn": "2023-01-03T00:00:00.000Z", + "modifiedBy": "12345", + }, + ] + + # Create a generator function + async def mock_async_generator( + items: List[Dict[str, Any]] + ) -> AsyncGenerator[Dict[str, Any], None]: + for item in items: + yield item + + # WHEN I call `get` + with patch( + "synapseclient.models.wiki.get_wiki_history", + return_value=mock_async_generator(mock_responses), + ) as mocked_get: + results = WikiHistorySnapshot.get( + owner_id="syn123", + id="wiki1", + offset=0, + limit=20, + synapse_client=self.syn, + ) + # THEN the API should be called with correct parameters + mocked_get.assert_called_once_with( + owner_id="syn123", + wiki_id="wiki1", + offset=0, + limit=20, + synapse_client=self.syn, + ) + + history_snapshot_list = [ + WikiHistorySnapshot( + version=1, + modified_on="2023-01-01T00:00:00.000Z", + modified_by="12345", + ), + WikiHistorySnapshot( + version=2, + modified_on="2023-01-02T00:00:00.000Z", + modified_by="12345", + ), + WikiHistorySnapshot( + version=3, + modified_on="2023-01-03T00:00:00.000Z", + modified_by="12345", + ), + ] + # AND the results should contain the expected data + assert results == history_snapshot_list + + def test_get_missing_owner_id(self) -> None: + # WHEN I call `get` + with patch( + "synapseclient.models.wiki.get_wiki_history" + ) as mocked_get, pytest.raises( + ValueError, match="Must provide owner_id to get wiki history." + ): + WikiHistorySnapshot.get( + owner_id=None, + id="wiki1", + synapse_client=self.syn, + ) + # THEN the API should not be called + mocked_get.assert_not_called() + + def test_get_missing_id(self) -> None: + # WHEN I call `get` + with patch( + "synapseclient.models.wiki.get_wiki_history" + ) as mocked_get, pytest.raises( + ValueError, match="Must provide id to get wiki history." + ): + WikiHistorySnapshot.get( + owner_id="syn123", + id=None, + synapse_client=self.syn, + ) + # THEN the API should not be called + mocked_get.assert_not_called() + + +class TestWikiHeader: + """Tests for the WikiHeader class.""" + + @pytest.fixture(autouse=True, scope="function") + def init_syn(self, syn: Synapse) -> None: + self.syn = syn + + wiki_header = WikiHeader( + id="wiki1", + title="Test Wiki", + parent_id="1234", + ) + + api_response = { + "id": "wiki1", + "title": "Test Wiki", + "parentId": "1234", + } + + def test_fill_from_dict(self) -> None: + # WHEN I call `fill_from_dict` with the example data + results = self.wiki_header.fill_from_dict(self.api_response) + + # THEN the WikiHeader object should be filled with the example data + assert results == self.wiki_header + + def test_get_success(self) -> None: + # GIVEN mock responses + mock_responses = [ + { + "id": "wiki1", + "title": "Test Wiki", + "parentId": "1234", + }, + { + "id": "wiki2", + "title": "Test Wiki 2", + "parentId": "1234", + }, + ] + + # Create a generator function + async def mock_async_generator( + items: List[Dict[str, Any]] + ) -> AsyncGenerator[Dict[str, Any], None]: + for item in items: + yield item + + with patch( + "synapseclient.models.wiki.get_wiki_header_tree", + return_value=mock_async_generator(mock_responses), + ) as mocked_get: + results = WikiHeader.get( + owner_id="syn123", + synapse_client=self.syn, + offset=0, + limit=20, + ) + + # THEN the API should be called with correct parameters + mocked_get.assert_called_once_with( + owner_id="syn123", + offset=0, + limit=20, + synapse_client=self.syn, + ) + + # AND the results should contain the expected data + wiki_header_list = [ + WikiHeader(id="wiki1", title="Test Wiki", parent_id="1234"), + WikiHeader(id="wiki2", title="Test Wiki 2", parent_id="1234"), + ] + assert results == wiki_header_list + + def test_get_missing_owner_id(self) -> None: + # WHEN I call `get` + # THEN it should raise ValueError + with patch( + "synapseclient.models.wiki.get_wiki_header_tree" + ) as mocked_get, pytest.raises( + ValueError, match="Must provide owner_id to get wiki header tree." + ): + WikiHeader.get(owner_id=None, synapse_client=self.syn) + # THEN the API should not be called + mocked_get.assert_not_called() + + +class TestWikiPage: + """Tests for the WikiPage class.""" + + @pytest.fixture(autouse=True, scope="function") + def init_syn(self, syn: Synapse) -> None: + self.syn = syn + + wiki_page = WikiPage( + id="wiki1", + etag="etag123", + title="Test Wiki Page", + parent_id="parent_wiki", + markdown="# Test markdown text", + attachments=["test_1.txt", "test_2.txt"], + owner_id="syn123", + created_on="2023-01-01T00:00:00.000Z", + created_by="12345", + modified_on="2023-01-02T00:00:00.000Z", + modified_by="12345", + wiki_version="0", + markdown_file_handle_id=None, + attachment_file_handle_ids=[], + ) + + api_response = { + "id": "wiki1", + "etag": "etag123", + "title": "Test Wiki Page", + "parentWikiId": "parent_wiki", + "markdown": "# Test markdown text", + "attachments": ["test_1.txt", "test_2.txt"], + "ownerId": "syn123", + "createdOn": "2023-01-01T00:00:00.000Z", + "createdBy": "12345", + "modifiedOn": "2023-01-02T00:00:00.000Z", + "modifiedBy": "12345", + "wikiVersion": "0", + "markdownFileHandleId": None, + "attachmentFileHandleIds": [], + } + + def get_fresh_wiki_page(self) -> WikiPage: + """Helper method to get a fresh copy of the wiki_page for tests that need to modify it.""" + return copy.deepcopy(self.wiki_page) + + def test_fill_from_dict(self) -> None: + # WHEN I call `fill_from_dict` with the example data + results = self.wiki_page.fill_from_dict(self.api_response) + + # THEN the WikiPage object should be filled with the example data + assert results == self.wiki_page + + def test_to_synapse_request(self) -> None: + # WHEN I call `to_synapse_request` + results = self.wiki_page.to_synapse_request() + # delete none keys for expected response + expected_results = copy.deepcopy(self.api_response) + expected_results.pop("markdownFileHandleId", None) + # THEN the request should contain the correct data + assert results == expected_results + + def test_to_synapse_request_with_none_values(self) -> None: + # WHEN I call `to_synapse_request` + results = self.wiki_page.to_synapse_request() + # THEN the request should not contain None values + expected_results = copy.deepcopy(self.api_response) + expected_results.pop("markdownFileHandleId", None) + assert results == expected_results + + def test_to_gzip_file_with_string_content(self) -> None: + self.syn.cache.cache_root_dir = "/tmp/cache" + + # WHEN I call `_to_gzip_file` with a markdown string + with patch("os.path.isfile", return_value=False), patch( + "builtins.open", mock_open(read_data=b"test content") + ), patch("gzip.open", mock_open()), patch("os.path.exists", return_value=True): + file_path = self.wiki_page._to_gzip_file(self.wiki_page.markdown, self.syn) + + # THEN the content should be written to a gzipped file + assert file_path == "/tmp/cache/wiki_content/wiki_markdown_Test Wiki Page.md.gz" + + def test_to_gzip_file_with_gzipped_file(self) -> None: + with patch("os.path.isfile", return_value=True): + self.syn.cache.cache_root_dir = "/tmp/cache" + markdown_file_path = "wiki_markdown_Test Wiki Page.md.gz" + # WHEN I call `_to_gzip_file` with a gzipped file + file_path = self.wiki_page._to_gzip_file(markdown_file_path, self.syn) + + # THEN the filepath should be the same as the input + assert file_path == markdown_file_path + + def test_to_gzip_file_with_non_gzipped_file(self) -> None: + self.syn.cache.cache_root_dir = "/tmp/cache" + + # WHEN I call `_to_gzip_file` with a file path + with patch("os.path.isfile", return_value=True), patch( + "builtins.open", mock_open(read_data=b"test content") + ), patch("gzip.open", mock_open()), patch("os.path.exists", return_value=True): + file_path = self.wiki_page._to_gzip_file("/path/to/test.txt", self.syn) + + # THEN the file should be processed + assert file_path == "/tmp/cache/wiki_content/test.txt.gz" + + def test_to_gzip_file_with_invalid_content(self) -> None: + # WHEN I call `_to_gzip_file` with invalid content type + # THEN it should raise SyntaxError + with pytest.raises(SyntaxError, match="Expected a string, got int"): + self.wiki_page._to_gzip_file(123, self.syn) + + def test_get_file_size_success(self) -> None: + # GIVEN a filehandle dictionary + filehandle_dict = { + "list": [ + {"fileName": "test1.txt", "contentSize": "100"}, + {"fileName": "test2.txt", "contentSize": "200"}, + ] + } + + # WHEN I call `_get_file_size` + results = WikiPage._get_file_size(filehandle_dict, "test1.txt") + + # THEN the result should be the content size + assert results == "100" + + def test_get_file_size_file_not_found(self) -> None: + # GIVEN a filehandle dictionary + filehandle_dict = { + "list": [ + {"fileName": "test1.txt", "contentSize": "100"}, + {"fileName": "test2.txt", "contentSize": "200"}, + ] + } + + # WHEN I call `_get_file_size` with a non-existent file + # THEN it should raise ValueError + with pytest.raises( + ValueError, match="File nonexistent.txt not found in filehandle_dict" + ): + WikiPage._get_file_size(filehandle_dict, "nonexistent.txt") + + def test_store_new_root_wiki_success(self) -> None: + # Update the wiki_page with file handle ids + new_wiki_page = self.get_fresh_wiki_page() + new_wiki_page.parent_id = None + + # AND mock the post_wiki_page response + post_api_response = copy.deepcopy(self.api_response) + post_api_response["parentId"] = None + post_api_response["markdownFileHandleId"] = "markdown_file_handle_id" + post_api_response["attachmentFileHandleIds"] = [ + "attachment_file_handle_id_1", + "attachment_file_handle_id_2", + ] + + # Create mock WikiPage objects with the expected file handle IDs for markdown + mock_wiki_with_markdown = copy.deepcopy(new_wiki_page) + mock_wiki_with_markdown.markdown_file_handle_id = "markdown_file_handle_id" + + # Create mock WikiPage objects with the expected file handle IDs for attachments + mock_wiki_with_attachments = copy.deepcopy(mock_wiki_with_markdown) + mock_wiki_with_attachments.attachment_file_handle_ids = [ + "attachment_file_handle_id_1", + "attachment_file_handle_id_2", + ] + + # AND mock responses + with patch( + "synapseclient.models.wiki.WikiPage._determine_wiki_action", + return_value="create_root_wiki_page", + ), patch( + "synapseclient.models.wiki.WikiPage._get_markdown_file_handle", + return_value=mock_wiki_with_markdown, + ), patch( + "synapseclient.models.wiki.WikiPage._get_attachment_file_handles", + return_value=mock_wiki_with_attachments, + ), patch( + "synapseclient.models.wiki.post_wiki_page", return_value=post_api_response + ) as mock_post_wiki, patch.object( + self.syn.logger, "info" + ) as mock_logger: + # WHEN I call `store` + results = new_wiki_page.store(synapse_client=self.syn) + + # THEN log messages should be printed + assert mock_logger.call_count == 2 + assert mock_logger.has_calls( + [ + call( + "No wiki page exists within the owner. Create a new wiki page." + ), + call( + f"Created wiki page: {post_api_response['title']} with ID: {post_api_response['id']}." + ), + ] + ) + # Update the wiki_page with file handle ids for validation + new_wiki_page.markdown_file_handle_id = "markdown_file_handle_id" + new_wiki_page.attachment_file_handle_ids = [ + "attachment_file_handle_id_1", + "attachment_file_handle_id_2", + ] + + # AND the wiki should be created + mock_post_wiki.assert_called_once_with( + owner_id="syn123", + request=new_wiki_page.to_synapse_request(), + ) + # AND the result should be filled with the response + expected_results = new_wiki_page.fill_from_dict(post_api_response) + assert results == expected_results + + def test_store_update_existing_wiki_success(self) -> None: + # Update the wiki_page + new_wiki_page = self.get_fresh_wiki_page() + new_wiki_page.title = "Updated Wiki Page" + new_wiki_page.parent_id = None + new_wiki_page.etag = None + + # AND mock the get_wiki_page response + mock_get_wiki_response = copy.deepcopy(self.api_response) + mock_get_wiki_response["parentWikiId"] = None + mock_get_wiki_response["markdown"] = None + mock_get_wiki_response["attachments"] = [] + mock_get_wiki_response["markdownFileHandleId"] = None + mock_get_wiki_response["attachmentFileHandleIds"] = [] + + # Create mock WikiPage objects + mock_wiki_with_markdown = self.get_fresh_wiki_page() + mock_wiki_with_markdown.title = "Updated Wiki Page" + mock_wiki_with_markdown.parent_id = None + mock_wiki_with_markdown.markdown_file_handle_id = "markdown_file_handle_id" + + # Create mock WikiPage objects with the expected file handle IDs for attachments + mock_wiki_with_attachments = copy.deepcopy(mock_wiki_with_markdown) + mock_wiki_with_attachments.attachment_file_handle_ids = [ + "attachment_file_handle_id_1", + "attachment_file_handle_id_2", + ] + + # AND mock the put_wiki_page response + # Create mock WikiPage objects with the expected file handle IDs for markdown + mock_put_wiki_response = copy.deepcopy(self.api_response) + mock_put_wiki_response["title"] = "Updated Wiki Page" + mock_put_wiki_response["parentId"] = None + mock_put_wiki_response["markdownFileHandleId"] = "markdown_file_handle_id" + mock_put_wiki_response["attachmentFileHandleIds"] = [ + "attachment_file_handle_id_1", + "attachment_file_handle_id_2", + ] + + # AND mock responses + with patch( + "synapseclient.models.wiki.WikiPage._determine_wiki_action", + return_value="update_existing_wiki_page", + ), patch( + "synapseclient.models.wiki.WikiPage._get_markdown_file_handle", + return_value=mock_wiki_with_markdown, + ), patch( + "synapseclient.models.wiki.WikiPage._get_attachment_file_handles", + return_value=mock_wiki_with_attachments, + ), patch( + "synapseclient.models.wiki.get_wiki_page", + return_value=mock_get_wiki_response, + ) as mock_get_wiki, patch( + "synapseclient.models.wiki.put_wiki_page", + return_value=mock_put_wiki_response, + ) as mock_put_wiki, patch.object( + self.syn.logger, "info" + ) as mock_logger: + # WHEN I call `store` + results = new_wiki_page.store(synapse_client=self.syn) + # THEN the existing wiki should be retrieved + mock_get_wiki.assert_called_once_with( + owner_id="syn123", + wiki_id="wiki1", + wiki_version="0", + ) + + # AND the wiki should be updated after merging dataclass objects + new_wiki_page.etag = "etag123" + new_wiki_page.created_on = "2023-01-01T00:00:00.000Z" + new_wiki_page.created_by = "12345" + new_wiki_page.modified_on = "2023-01-02T00:00:00.000Z" + new_wiki_page.modified_by = "12345" + new_wiki_page.markdown_file_handle_id = "markdown_file_handle_id" + new_wiki_page.attachment_file_handle_ids = [ + "attachment_file_handle_id_1", + "attachment_file_handle_id_2", + ] + mock_put_wiki.assert_called_once_with( + owner_id="syn123", + wiki_id="wiki1", + request=new_wiki_page.to_synapse_request(), + ) + + # AND log messages should be printed + assert mock_logger.call_count == 2 + assert mock_logger.has_calls( + [ + call( + "A wiki page already exists within the owner. Update the existing wiki page." + ), + call( + f"Updated wiki page: {self.api_response['title']} with ID: {self.api_response['id']}." + ), + ] + ) + # AND the result should be filled with the response + expected_results = new_wiki_page.fill_from_dict(mock_put_wiki_response) + assert results == expected_results + + def test_store_create_sub_wiki_success(self) -> None: + # AND mock the post_wiki_page response + post_api_response = copy.deepcopy(self.api_response) + post_api_response["markdownFileHandleId"] = "markdown_file_handle_id" + post_api_response["attachmentFileHandleIds"] = [ + "attachment_file_handle_id_1", + "attachment_file_handle_id_2", + ] + + # Create mock WikiPage objects with the expected file handle IDs for markdown + mock_wiki_with_markdown = self.get_fresh_wiki_page() + mock_wiki_with_markdown.markdown_file_handle_id = "markdown_file_handle_id" + + # Create mock WikiPage objects with the expected file handle IDs for attachments + mock_wiki_with_attachments = copy.deepcopy(mock_wiki_with_markdown) + mock_wiki_with_attachments.attachment_file_handle_ids = [ + "attachment_file_handle_id_1", + "attachment_file_handle_id_2", + ] + + # AND mock responses + with patch( + "synapseclient.models.wiki.WikiPage._determine_wiki_action", + return_value="create_sub_wiki_page", + ), patch( + "synapseclient.models.wiki.WikiPage._get_markdown_file_handle", + return_value=mock_wiki_with_markdown, + ), patch( + "synapseclient.models.wiki.WikiPage._get_attachment_file_handles", + return_value=mock_wiki_with_attachments, + ), patch( + "synapseclient.models.wiki.post_wiki_page", return_value=post_api_response + ) as mock_post_wiki, patch.object( + self.syn.logger, "info" + ) as mock_logger: + # WHEN I call `store` + results = self.wiki_page.store(synapse_client=self.syn) + + # THEN log messages should be printed + assert mock_logger.call_count == 2 + assert mock_logger.has_calls( + [ + call("Creating sub-wiki page under parent ID: parent_wiki"), + call( + f"Created sub-wiki page: {post_api_response['title']} with ID: {post_api_response['id']} under parent: parent_wiki" + ), + ] + ) + + # Update the wiki_page with file handle ids for validation + new_wiki_page = self.get_fresh_wiki_page() + new_wiki_page.markdown_file_handle_id = "markdown_file_handle_id" + new_wiki_page.attachment_file_handle_ids = [ + "attachment_file_handle_id_1", + "attachment_file_handle_id_2", + ] + + # AND the wiki should be created + mock_post_wiki.assert_called_once_with( + owner_id="syn123", + request=new_wiki_page.to_synapse_request(), + synapse_client=self.syn, + ) + + # AND the result should be filled with the response + expected_results = new_wiki_page.fill_from_dict(post_api_response) + assert results == expected_results + + @pytest.mark.parametrize( + "wiki_page, expected_error", + [ + ( + WikiPage(owner_id=None, title="Test Wiki", wiki_version="0"), + "Must provide owner_id to restore a wiki page.", + ), + ( + WikiPage(owner_id="syn123", id=None, wiki_version="0"), + "Must provide id to restore a wiki page.", + ), + ( + WikiPage(owner_id="syn123", id="wiki1", wiki_version=None), + "Must provide wiki_version to restore a wiki page.", + ), + ], + ) + def test_restore_missing_required_parameters( + self, wiki_page, expected_error + ) -> None: + # WHEN I call `restore` + # THEN it should raise ValueError + with patch( + "synapseclient.models.wiki.put_wiki_version" + ) as mocked_put, pytest.raises(ValueError, match=expected_error): + wiki_page.restore(synapse_client=self.syn) + # THEN the API should not be called + mocked_put.assert_not_called() + + def test_restore_success(self) -> None: + new_wiki_page = self.get_fresh_wiki_page() + with patch( + "synapseclient.models.wiki.put_wiki_version", return_value=self.api_response + ) as mock_put_wiki_version: + # WHEN I call `restore` + results = self.wiki_page.restore(synapse_client=self.syn) + + # THEN the API should be called with correct parameters + mock_put_wiki_version.assert_called_once_with( + owner_id="syn123", + wiki_id="wiki1", + wiki_version="0", + request=new_wiki_page.to_synapse_request(), + synapse_client=self.syn, + ) + # AND the result should be filled with the response + expected_results = new_wiki_page.fill_from_dict(self.api_response) + assert results == expected_results + + def test_get_by_id_success(self) -> None: + # GIVEN a WikiPage object with id + wiki = WikiPage( + id="wiki1", + owner_id="syn123", + ) + + # AND a mock response + with patch("synapseclient.models.wiki.get_wiki_page") as mock_get_wiki: + mock_get_wiki.return_value = self.api_response + + # WHEN I call `get` + results = wiki.get(synapse_client=self.syn) + + # THEN the API should be called with correct parameters + mock_get_wiki.assert_called_once_with( + owner_id="syn123", + wiki_id="wiki1", + wiki_version=None, + synapse_client=self.syn, + ) + + # AND the result should be filled with the response + expected_wiki = self.wiki_page.fill_from_dict(self.api_response) + assert results == expected_wiki + + def test_get_by_title_success(self) -> None: + # GIVEN a WikiPage object with title but no id + wiki = WikiPage( + title="Test Wiki", + owner_id="syn123", + ) + + # AND mock responses + mock_responses = [ + {"id": "wiki1", "title": "Test Wiki", "parentId": None}, + {"id": "wiki2", "title": "Test Wiki 2", "parentId": None}, + ] + + # Create a generator function + async def mock_async_generator( + items: List[Dict[str, Any]] + ) -> AsyncGenerator[Dict[str, Any], None]: + for item in items: + yield item + + with patch( + "synapseclient.models.wiki.get_wiki_header_tree", + return_value=mock_async_generator(mock_responses), + ) as mock_get_header_tree, patch( + "synapseclient.models.wiki.get_wiki_page", return_value=self.api_response + ) as mock_get_wiki: + # WHEN I call `get` + results = wiki.get(synapse_client=self.syn) + + # THEN the header tree should be retrieved + mock_get_header_tree.assert_called_once_with( + owner_id="syn123", + synapse_client=self.syn, + ) + + # AND the wiki should be retrieved by id + mock_get_wiki.assert_called_once_with( + owner_id="syn123", + wiki_id="wiki1", + wiki_version=None, + synapse_client=self.syn, + ) + + # AND the result should be filled with the response + expected_wiki = self.wiki_page.fill_from_dict(self.api_response) + assert results == expected_wiki + + def test_get_by_title_not_found(self) -> None: + # GIVEN a WikiPage object with title but no id + wiki = WikiPage( + title="Non-existent Wiki", + owner_id="syn123", + ) + + # AND mock responses that don't contain the title + mock_responses = [{"id": "wiki1", "title": "Different Wiki", "parentId": None}] + + # Create a generator function + async def mock_async_generator( + items: List[Dict[str, Any]] + ) -> AsyncGenerator[Dict[str, Any], None]: + for item in items: + yield item + + with patch( + "synapseclient.models.wiki.get_wiki_header_tree", + return_value=mock_async_generator(mock_responses), + ) as mock_get_header_tree: + # WHEN I call `get` + # THEN it should raise ValueError + with pytest.raises( + ValueError, match="No wiki page found with title: Non-existent Wiki" + ): + wiki.get(synapse_client=self.syn) + mock_get_header_tree.assert_called_once_with( + owner_id="syn123", + synapse_client=self.syn, + ) + + @pytest.mark.parametrize( + "wiki_page, expected_error", + [ + ( + WikiPage(id="wiki1"), + "Must provide owner_id to delete a wiki page.", + ), + ( + WikiPage(owner_id="syn123"), + "Must provide id to delete a wiki page.", + ), + ], + ) + def test_delete_missing_required_parameters( + self, wiki_page, expected_error + ) -> None: + # WHEN I call `delete` + # THEN it should raise ValueError + with patch( + "synapseclient.models.wiki.delete_wiki_page" + ) as mocked_delete, pytest.raises(ValueError, match=expected_error): + wiki_page.delete(synapse_client=self.syn) + # THEN the API should not be called + mocked_delete.assert_not_called() + + def test_delete_success(self) -> None: + # WHEN I call `delete` + with patch("synapseclient.models.wiki.delete_wiki_page") as mock_delete_wiki: + self.wiki_page.delete(synapse_client=self.syn) + + # THEN the API should be called with correct parameters + mock_delete_wiki.assert_called_once_with( + owner_id="syn123", + wiki_id="wiki1", + synapse_client=self.syn, + ) + + @pytest.mark.parametrize( + "wiki_page, expected_error", + [ + ( + WikiPage(id="wiki1"), + "Must provide owner_id to get attachment handles.", + ), + ( + WikiPage(owner_id="syn123"), + "Must provide id to get attachment handles.", + ), + ], + ) + def test_get_attachment_handles_missing_required_parameters( + self, wiki_page, expected_error + ) -> None: + # WHEN I call `get_attachment_handles` + # THEN it should raise ValueError + with patch( + "synapseclient.models.wiki.get_attachment_handles" + ) as mocked_get, pytest.raises(ValueError, match=expected_error): + wiki_page.get_attachment_handles(synapse_client=self.syn) + # THEN the API should not be called + mocked_get.assert_not_called() + + def test_get_attachment_handles_success(self) -> None: + # mock responses + mock_handles = [{"id": "handle1", "fileName": "test.txt"}] + with patch( + "synapseclient.models.wiki.get_attachment_handles", + return_value=mock_handles, + ) as mock_get_handles: + # WHEN I call `get_attachment_handles` + results = self.wiki_page.get_attachment_handles(synapse_client=self.syn) + + # THEN the API should be called with correct parameters + mock_get_handles.assert_called_once_with( + owner_id="syn123", + wiki_id="wiki1", + wiki_version="0", + synapse_client=self.syn, + ) + # AND the result should be the handles + assert results == mock_handles + + @pytest.mark.parametrize( + "wiki_page, file_name, expected_error", + [ + ( + WikiPage(id="wiki1"), + "test.txt", + "Must provide owner_id to get attachment URL.", + ), + ( + WikiPage(owner_id="syn123"), + "test.txt", + "Must provide id to get attachment URL.", + ), + ( + WikiPage(owner_id="syn123", id="wiki1"), + None, + "Must provide file_name to get attachment URL.", + ), + ], + ) + def test_get_attachment_missing_required_parameters( + self, file_name, wiki_page, expected_error + ) -> None: + # WHEN I call `get_attachment` + # THEN it should raise ValueError + with patch( + "synapseclient.models.wiki.get_attachment_url" + ) as mocked_get, pytest.raises(ValueError, match=expected_error): + wiki_page.get_attachment( + file_name=file_name, + synapse_client=self.syn, + ) + # THEN the API should not be called + mocked_get.assert_not_called() + + @pytest.mark.parametrize("file_size", [8 * 1024 * 1024 - 1, 8 * 1024 * 1024 + 1]) + def test_get_attachment_download_file_success(self, file_size) -> None: + # AND mock responses + mock_attachment_url = "https://example.com/attachment.txt" + mock_filehandle_dict = { + "list": [ + { + "fileName": "test.txt", + "contentSize": str(file_size), + } + ] + } + + with patch( + "synapseclient.models.wiki.get_attachment_url", + return_value=mock_attachment_url, + ) as mock_get_url, patch( + "synapseclient.models.wiki.get_attachment_handles", + return_value=mock_filehandle_dict, + ) as mock_get_handles, patch( + "synapseclient.models.wiki.download_from_url" + ) as mock_download_from_url, patch( + "synapseclient.models.wiki.download_from_url_multi_threaded" + ) as mock_download_from_url_multi_threaded, patch( + "synapseclient.models.wiki._pre_signed_url_expiration_time", + return_value="2030-01-01T00:00:00.000Z", + ) as mock_expiration_time, patch.object( + self.syn.logger, "debug" + ) as mock_logger: + # WHEN I call `get_attachment` with download_file=True + result = self.wiki_page.get_attachment( + file_name="test.txt", + download_file=True, + download_location="/tmp/download", + synapse_client=self.syn, + ) + + # THEN the attachment URL should be retrieved + mock_get_url.assert_called_once_with( + owner_id="syn123", + wiki_id="wiki1", + file_name="test.txt", + wiki_version="0", + synapse_client=self.syn, + ) + + # AND the attachment handles should be retrieved + mock_get_handles.assert_called_once_with( + owner_id="syn123", + wiki_id="wiki1", + wiki_version="0", + synapse_client=self.syn, + ) + + # AND the expiration time should be calculated + mock_expiration_time.assert_called_once_with(mock_attachment_url) + + # AND the appropriate download method should be called based on file size + if file_size < 8 * 1024 * 1024: + # Single-threaded download for files smaller than 8 MiB + mock_download_from_url.assert_called_once_with( + url=mock_attachment_url, + destination="/tmp/download", + url_is_presigned=True, + ) + mock_download_from_url_multi_threaded.assert_not_called() + + else: + # construct a mock presigned url info + mock_presigned_url_info = PresignedUrlInfo( + file_name="test.txt", + url=mock_attachment_url, + expiration_utc="2030-01-01T00:00:00.000Z", + ) + # Multi-threaded download for files larger than or equal to 8 MiB + mock_download_from_url_multi_threaded.assert_called_once_with( + presigned_url=mock_presigned_url_info, + destination="/tmp/download", + ) + mock_download_from_url.assert_not_called() + + # AND debug log should be called once (only the general one) + assert mock_logger.call_count == 1 + mock_logger.assert_called_once_with( + f"Downloaded file test.txt to /tmp/download" + ) + # AND the result should be None (since download_file=True) + assert result is None + + def test_get_attachment_no_file_download(self) -> None: + with patch( + "synapseclient.models.wiki.get_attachment_url", + return_value="https://example.com/attachment.txt", + ) as mock_get_url: + # WHEN I call `get_attachment` with download_file=False + # THEN it should return the attachment URL + results = self.wiki_page.get_attachment( + file_name="test.txt", + download_file=False, + synapse_client=self.syn, + ) + # AND the result should be the attachment URL + assert results == "https://example.com/attachment.txt" + + def test_get_attachment_download_file_missing_location(self) -> None: + # GIVEN a WikiPage object + wiki = WikiPage( + id="wiki1", + owner_id="syn123", + wiki_version="0", + ) + + # AND a mock attachment URL + mock_attachment_url = "https://example.com/attachment.txt" + + with patch( + "synapseclient.models.wiki.get_attachment_url", + return_value=mock_attachment_url, + ) as mock_get_url, patch( + "synapseclient.models.wiki.get_attachment_handles", + ) as mock_get_handles: + # WHEN I call `get_attachment` with download_file=True but no download_location + # THEN it should raise ValueError + with pytest.raises( + ValueError, match="Must provide download_location to download a file." + ): + wiki.get_attachment( + file_name="test.txt", + download_file=True, + download_location=None, + synapse_client=self.syn, + ) + + # AND the attachment URL should still be retrieved + mock_get_url.assert_called_once_with( + owner_id="syn123", + wiki_id="wiki1", + file_name="test.txt", + wiki_version="0", + synapse_client=self.syn, + ) + # AND the attachment handles should not be retrieved + mock_get_handles.assert_not_called() + + @pytest.mark.parametrize( + "wiki_page, file_name, expected_error", + [ + ( + WikiPage(id="wiki1"), + "test.txt", + "Must provide owner_id to get attachment preview URL.", + ), + ( + WikiPage(owner_id="syn123"), + "test.txt", + "Must provide id to get attachment preview URL.", + ), + ( + WikiPage(owner_id="syn123", id="wiki1"), + None, + "Must provide file_name to get attachment preview URL.", + ), + ], + ) + def test_get_attachment_preview_missing_required_parameters( + self, file_name, wiki_page, expected_error + ) -> None: + # WHEN I call `get_attachment_preview` + # THEN it should raise ValueError + with patch( + "synapseclient.models.wiki.get_attachment_preview_url" + ) as mocked_get, pytest.raises(ValueError, match=expected_error): + wiki_page.get_attachment_preview( + file_name=file_name, + synapse_client=self.syn, + ) + # THEN the API should not be called + mocked_get.assert_not_called() + + @pytest.mark.parametrize("file_size", [8 * 1024 * 1024 - 1, 8 * 1024 * 1024 + 1]) + def test_get_attachment_preview_download_file_success(self, file_size) -> None: + # Mock responses + mock_attachment_url = "https://example.com/attachment.txt" + mock_filehandle_dict = { + "list": [ + { + "fileName": "test.txt", + "contentSize": str(file_size), + } + ] + } + + with patch( + "synapseclient.models.wiki.get_attachment_preview_url", + return_value=mock_attachment_url, + ) as mock_get_url, patch( + "synapseclient.models.wiki.get_attachment_handles", + return_value=mock_filehandle_dict, + ) as mock_get_handles, patch( + "synapseclient.models.wiki.download_from_url" + ) as mock_download_from_url, patch( + "synapseclient.models.wiki.download_from_url_multi_threaded" + ) as mock_download_from_url_multi_threaded, patch( + "synapseclient.models.wiki._pre_signed_url_expiration_time", + return_value="2030-01-01T00:00:00.000Z", + ) as mock_expiration_time, patch.object( + self.syn.logger, "debug" + ) as mock_logger: + # WHEN I call `get_attachment_preview` with download_file=True + result = self.wiki_page.get_attachment_preview( + file_name="test.txt", + download_file=True, + download_location="/tmp/download", + synapse_client=self.syn, + ) + + # THEN the attachment URL should be retrieved + mock_get_url.assert_called_once_with( + owner_id="syn123", + wiki_id="wiki1", + file_name="test.txt", + wiki_version="0", + synapse_client=self.syn, + ) + + # AND the attachment handles should be retrieved + mock_get_handles.assert_called_once_with( + owner_id="syn123", + wiki_id="wiki1", + wiki_version="0", + synapse_client=self.syn, + ) + + # AND the expiration time should be calculated + mock_expiration_time.assert_called_once_with(mock_attachment_url) + + # AND the appropriate download method should be called based on file size + if file_size < 8 * 1024 * 1024: + # Single-threaded download for files smaller than 8 MiB + mock_download_from_url.assert_called_once_with( + url=mock_attachment_url, + destination="/tmp/download", + url_is_presigned=True, + ) + mock_download_from_url_multi_threaded.assert_not_called() + + else: + # construct a mock presigned url info + mock_presigned_url_info = PresignedUrlInfo( + file_name="test.txt", + url=mock_attachment_url, + expiration_utc="2030-01-01T00:00:00.000Z", + ) + # Multi-threaded download for files larger than or equal to 8 MiB + mock_download_from_url_multi_threaded.assert_called_once_with( + presigned_url=mock_presigned_url_info, + destination="/tmp/download", + ) + mock_download_from_url.assert_not_called() + + # AND debug log should be called once (only the general one) + assert mock_logger.call_count == 1 + mock_logger.assert_called_once_with( + f"Downloaded the preview file test.txt to /tmp/download" + ) + # AND the result should be None (since download_file=True) + assert result is None + + def test_get_attachment_preview_no_file_download(self) -> None: + with patch( + "synapseclient.models.wiki.get_attachment_preview_url", + return_value="https://example.com/attachment.txt", + ) as mock_get_url: + # WHEN I call `get_attachment_preview` with download_file=False + # THEN it should return the attachment URL + results = self.wiki_page.get_attachment_preview( + file_name="test.txt", + download_file=False, + synapse_client=self.syn, + ) + # AND the result should be the attachment URL + assert results == "https://example.com/attachment.txt" + + def test_get_attachment_preview_download_file_missing_location(self) -> None: + # GIVEN a WikiPage object + wiki = WikiPage( + id="wiki1", + owner_id="syn123", + wiki_version="0", + ) + + # AND a mock attachment URL + mock_attachment_url = "https://example.com/attachment.txt" + + with patch( + "synapseclient.models.wiki.get_attachment_preview_url", + return_value=mock_attachment_url, + ) as mock_get_url, patch( + "synapseclient.models.wiki.get_attachment_handles" + ) as mock_get_handles: + # WHEN I call `get_attachment_preview` with download_file=True but no download_location + # THEN it should raise ValueError + with pytest.raises( + ValueError, match="Must provide download_location to download a file." + ): + wiki.get_attachment_preview( + file_name="test.txt", + download_file=True, + download_location=None, + synapse_client=self.syn, + ) + + # AND the attachment URL should still be retrieved + mock_get_url.assert_called_once_with( + owner_id="syn123", + wiki_id="wiki1", + file_name="test.txt", + wiki_version="0", + synapse_client=self.syn, + ) + # AND the attachment handles should not be retrieved + mock_get_handles.assert_not_called() + + @pytest.mark.parametrize( + "wiki_page, expected_error", + [ + ( + WikiPage(id="wiki1"), + "Must provide owner_id to get markdown URL.", + ), + ( + WikiPage(owner_id="syn123"), + "Must provide id to get markdown URL.", + ), + ], + ) + def test_get_markdown_missing_required_parameters( + self, wiki_page, expected_error + ) -> None: + # WHEN I call `get_markdown` + # THEN it should raise ValueError + with patch( + "synapseclient.models.wiki.get_markdown_url" + ) as mocked_get, pytest.raises(ValueError, match=expected_error): + wiki_page.get_markdown(synapse_client=self.syn) + # THEN the API should not be called + mocked_get.assert_not_called() + + def test_get_markdown_download_file_success(self) -> None: + # Mock responses + mock_markdown_url = "https://example.com/markdown.md" + + with patch( + "synapseclient.models.wiki.get_markdown_url", + return_value=mock_markdown_url, + ) as mock_get_url, patch( + "synapseclient.models.wiki.download_from_url" + ) as mock_download_from_url, patch( + "synapseclient.models.wiki._pre_signed_url_expiration_time", + return_value="2030-01-01T00:00:00.000Z", + ) as mock_expiration_time, patch.object( + self.syn.logger, "debug" + ) as mock_logger: + # WHEN I call `get_markdown` with download_file=True + result = self.wiki_page.get_markdown( + download_file_name="test.md", + download_file=True, + download_location="/tmp/download", + synapse_client=self.syn, + ) + + # THEN the markdown URL should be retrieved + mock_get_url.assert_called_once_with( + owner_id="syn123", + wiki_id="wiki1", + wiki_version="0", + synapse_client=self.syn, + ) + + # AND the expiration time should be calculated + mock_expiration_time.assert_called_once_with(mock_markdown_url) + + # AND the file should be downloaded using single-threaded download + mock_download_from_url.assert_called_once_with( + url=mock_markdown_url, + destination="/tmp/download", + url_is_presigned=True, + ) + + # AND debug log should be called + assert mock_logger.call_count == 1 + mock_logger.assert_called_once_with( + f"Downloaded file test.md to /tmp/download" + ) + + # AND the result should be None (since download_file=True) + assert result is None + + def test_get_markdown_no_file_download(self) -> None: + with patch( + "synapseclient.models.wiki.get_markdown_url", + return_value="https://example.com/markdown.md", + ) as mock_get_url: + # WHEN I call `get_markdown` with download_file=False + results = self.wiki_page.get_markdown( + download_file=False, + synapse_client=self.syn, + ) + + # THEN the markdown URL should be retrieved + mock_get_url.assert_called_once_with( + owner_id="syn123", + wiki_id="wiki1", + wiki_version="0", + synapse_client=self.syn, + ) + + # AND the result should be the markdown URL + assert results == "https://example.com/markdown.md" + + def test_get_markdown_download_file_missing_location(self) -> None: + # GIVEN a WikiPage object + wiki = WikiPage( + id="wiki1", + owner_id="syn123", + wiki_version="0", + ) + + # AND a mock markdown URL + mock_markdown_url = "https://example.com/markdown.md" + + with patch( + "synapseclient.models.wiki.get_markdown_url", + return_value=mock_markdown_url, + ) as mock_get_url, patch( + "synapseclient.models.wiki.get_attachment_handles" + ) as mock_get_handles: + # WHEN I call `get_markdown` with download_file=True but no download_location + # THEN it should raise ValueError + with pytest.raises( + ValueError, match="Must provide download_location to download a file." + ): + wiki.get_markdown( + download_file_name="test.md", + download_file=True, + download_location=None, + synapse_client=self.syn, + ) + + # AND the markdown URL should still be retrieved + mock_get_url.assert_called_once_with( + owner_id="syn123", + wiki_id="wiki1", + wiki_version="0", + synapse_client=self.syn, + ) + # AND the attachment handles should not be retrieved + mock_get_handles.assert_not_called() + + def test_get_markdown_download_file_missing_filename(self) -> None: + # GIVEN a WikiPage object + wiki = WikiPage( + id="wiki1", + owner_id="syn123", + wiki_version="0", + ) + + # AND a mock markdown URL + mock_markdown_url = "https://example.com/markdown.md" + + with patch( + "synapseclient.models.wiki.get_markdown_url", + return_value=mock_markdown_url, + ) as mock_get_url, patch( + "synapseclient.models.wiki.get_attachment_handles" + ) as mock_get_handles: + # WHEN I call `get_markdown` with download_file=True but no download_file_name + # THEN it should raise ValueError + with pytest.raises( + ValueError, match="Must provide download_file_name to download a file." + ): + wiki.get_markdown( + download_file_name=None, + download_file=True, + download_location="/tmp/download", + synapse_client=self.syn, + ) + + # AND the markdown URL should still be retrieved + mock_get_url.assert_called_once_with( + owner_id="syn123", + wiki_id="wiki1", + wiki_version="0", + synapse_client=self.syn, + ) + # AND the attachment handles should not be retrieved + mock_get_handles.assert_not_called() + + def test_get_markdown_with_different_wiki_version(self) -> None: + # GIVEN a WikiPage object with a specific wiki version + wiki = WikiPage( + id="wiki1", + owner_id="syn123", + wiki_version="2", + ) + + with patch( + "synapseclient.models.wiki.get_markdown_url", + return_value="https://example.com/markdown_v2.md", + ) as mock_get_url: + # WHEN I call `get_markdown` + results = wiki.get_markdown( + download_file=False, + synapse_client=self.syn, + ) + + # THEN the markdown URL should be retrieved with the correct wiki version + mock_get_url.assert_called_once_with( + owner_id="syn123", + wiki_id="wiki1", + wiki_version="2", + synapse_client=self.syn, + ) + + # AND the result should be the markdown URL + assert results == "https://example.com/markdown_v2.md" + + def test_get_markdown_with_none_wiki_version(self) -> None: + # GIVEN a WikiPage object with None wiki version + wiki = WikiPage( + id="wiki1", + owner_id="syn123", + wiki_version=None, + ) + + with patch( + "synapseclient.models.wiki.get_markdown_url", + return_value="https://example.com/markdown_latest.md", + ) as mock_get_url: + # WHEN I call `get_markdown` + results = wiki.get_markdown( + download_file=False, + synapse_client=self.syn, + ) + + # THEN the markdown URL should be retrieved with None wiki version + mock_get_url.assert_called_once_with( + owner_id="syn123", + wiki_id="wiki1", + wiki_version=None, + synapse_client=self.syn, + ) + + # AND the result should be the markdown URL + assert results == "https://example.com/markdown_latest.md" diff --git a/tests/unit/synapseclient/unit_test_client.py b/tests/unit/synapseclient/unit_test_client.py index f40f6daa1..b71a8f917 100644 --- a/tests/unit/synapseclient/unit_test_client.py +++ b/tests/unit/synapseclient/unit_test_client.py @@ -4272,3 +4272,117 @@ async def test_for_httpx_modified_user_agent_multiple_strings(self) -> None: assert wrapped_rest_call.call_args[1]["headers"][ "User-Agent" ] == self.user_agent_httpx["User-Agent"] + " " + " ".join(user_agent) + + class TestRestGetPaginatedAsync: + @pytest.fixture(autouse=True, scope="function") + def init_syn(self, syn: Synapse) -> None: + self.syn = syn + + async def test_rest_get_paginated_async_with_results(self) -> None: + # Mock the rest_get_async method to return paginated results + mock_responses = [ + {"results": [{"id": 1}, {"id": 2}, {"id": 3}]}, + {"results": [{"id": 4}, {"id": 5}]}, + {"results": []}, + ] + + with patch.object( + self.syn, "rest_get_async", side_effect=mock_responses + ) as mock_rest_get: + # Test the paginated get + results = [] + async for result in self.syn.rest_get_paginated_async( + "/test/uri", limit=3 + ): + results.append(result) + + # Verify results + assert len(results) == 5 + assert [r["id"] for r in results] == [1, 2, 3, 4, 5] + + # Verify rest_get_async was called with correct parameters + assert mock_rest_get.call_count == 3 + call_list = [ + call(uri="/test/uri", params={"offset": 0, "limit": 3}), + call(uri="/test/uri", params={"offset": 3, "limit": 3}), + call(uri="/test/uri", params={"offset": 5, "limit": 3}), + ] + mock_rest_get.assert_has_calls(call_list) + + async def test_rest_get_paginated_async_with_children(self) -> None: + # Mock the rest_get_async method to return paginated results with "children" key + mock_responses = [ + {"children": [{"id": 1}, {"id": 2}]}, + {"children": [{"id": 3}]}, + {"children": []}, + ] + + with patch.object( + self.syn, "rest_get_async", side_effect=mock_responses + ) as mock_rest_get: + # Test the paginated get + results = [] + async for result in self.syn.rest_get_paginated_async( + "/test/uri", limit=2 + ): + results.append(result) + + # Verify results + assert len(results) == 3 + assert [r["id"] for r in results] == [1, 2, 3] + + # Verify rest_get_async was called with correct parameters + assert mock_rest_get.call_count == 3 + call_list = [ + call(uri="/test/uri", params={"offset": 0, "limit": 2}), + call(uri="/test/uri", params={"offset": 2, "limit": 2}), + call(uri="/test/uri", params={"offset": 3, "limit": 2}), + ] + mock_rest_get.assert_has_calls(call_list) + + async def test_rest_get_paginated_async_empty_response(self) -> None: + # Mock the rest_get_async method to return empty results immediately + with patch.object( + self.syn, "rest_get_async", return_value={"results": []} + ) as mock_rest_get: + # Test the paginated get + results = [] + async for result in self.syn.rest_get_paginated_async("/test/uri"): + results.append(result) + + # Verify no results were returned + assert len(results) == 0 + + # Verify rest_get_async was called once with default parameters + mock_rest_get.assert_called_once_with( + uri="/test/uri", params={"offset": 0, "limit": 20} + ) + + async def test_rest_get_paginated_async_custom_limit(self) -> None: + # Mock the rest_get_async method to return paginated results + mock_responses = [ + {"results": [{"id": 1}, {"id": 2}]}, + {"results": []}, + ] + + with patch.object( + self.syn, "rest_get_async", side_effect=mock_responses + ) as mock_rest_get: + # Test the paginated get with custom limit + results = [] + async for result in self.syn.rest_get_paginated_async( + "/test/uri", limit=2, offset=5 + ): + results.append(result) + + # Verify results + assert len(results) == 2 + assert [r["id"] for r in results] == [1, 2] + + # Verify rest_get_async was called with correct parameters + assert mock_rest_get.call_count == 2 + call_list = [ + call(uri="/test/uri", params={"offset": 5, "limit": 2}), + call(uri="/test/uri", params={"offset": 7, "limit": 2}), + ] + mock_rest_get.assert_has_calls(call_list)