From fef882b58116422f2d4202fb41afac20c39b6249 Mon Sep 17 00:00:00 2001 From: danlu1 Date: Wed, 11 Jun 2025 13:44:04 -0700 Subject: [PATCH 01/42] add Low-level Functionality to interact with wikipage2 endpoints --- synapseclient/api/wiki_service.py | 464 ++++++++++++++++++++++++++++++ 1 file changed, 464 insertions(+) create mode 100644 synapseclient/api/wiki_service.py diff --git a/synapseclient/api/wiki_service.py b/synapseclient/api/wiki_service.py new file mode 100644 index 000000000..95092beb9 --- /dev/null +++ b/synapseclient/api/wiki_service.py @@ -0,0 +1,464 @@ +"""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 + +import sys + + +async def post_wiki( + 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) + + prev_num_results = sys.maxsize + while prev_num_results > 0: + params = {"offset": offset, "limit": limit} + page = await client.rest_get_async( + uri=f"/entity/{owner_id}/wikiheadertree2", + 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 get_wiki_history( + owner_id: str, + wiki_id: str, + 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) + + params = {"offset": offset, "limit": limit} + return await client.rest_get_async( + uri=f"/entity/{owner_id}/wiki2/{wiki_id}/wikihistory", + params=params, + ) + + +async def get_attachment_handles( + owner_id: str, + wiki_id: str, + *, + wiki_version: Optional[int] = None, + synapse_client: Optional["Synapse"] = None, +) -> List[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, + *, + redirect: Optional[bool] = False, + 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. + redirect: When set to false, the URL will be returned as text/plain instead of redirecting. Default is False. + 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 and redirect as a query parameter if provided + params = {} + params["fileName"] = file_name + if wiki_version is not None: + params["wikiVersion"] = wiki_version + if redirect is not None: + params["redirect"] = redirect + + 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, + redirect: Optional[bool] = False, + 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. + redirect: When set to false, the URL will be returned as text/plain instead of redirecting. Default is False. + 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 and redirect as a query parameter if provided + params = {} + params["fileName"] = file_name + if redirect is not None: + params["redirect"] = redirect + 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, + redirect: Optional[bool] = False, + 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. + redirect: When set to false, the URL will be returned as text/plain instead of redirecting. Default is False. + 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 and redirect as a query parameter if provided + params = {} + if redirect is not None: + params["redirect"] = redirect + 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), + ) From 977413b56a640b414ba13b9dca33d4b0bb9512c3 Mon Sep 17 00:00:00 2001 From: danlu1 Date: Wed, 11 Jun 2025 13:49:32 -0700 Subject: [PATCH 02/42] remove unused module --- synapseclient/models/wiki.py | 805 ++++++++++++++++++++++++++++++++++- 1 file changed, 804 insertions(+), 1 deletion(-) diff --git a/synapseclient/models/wiki.py b/synapseclient/models/wiki.py index 464090415..bf7f54f09 100644 --- a/synapseclient/models/wiki.py +++ b/synapseclient/models/wiki.py @@ -1 +1,804 @@ -# TODO +"""Script to work with Synapse wiki pages.""" + +import gzip +import os +import shutil +import uuid +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Union + +from synapseclient import Synapse +from synapseclient.api.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, + 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.upload.upload_functions_async import upload_file_handle +from synapseclient.core.utils import delete_none_keys + + +@dataclass +@async_to_sync +class WikiOrderHint: + """ + A WikiOrderHint contains the order hint for the root wiki that corresponds to the given owner ID and type. + + Attributes: + owner_id: The 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 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 + + @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 asynchronously. + + 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) + + @otel_trace_method( + method_to_trace_name=lambda self, **kwargs: f"Update_Wiki_Order_Hint: {self.owner_id}" + ) + async def update_async( + self, + *, + synapse_client: Optional["Synapse"] = None, + ) -> "WikiOrderHint": + """ + Update the order hint of a wiki page tree asynchronously. + + 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 update 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 + + +@dataclass +@async_to_sync +class WikiHistorySnapshot: + """ + 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['wiki_id']}" + ) + async def get_async( + cls, + owner_id: str, + wiki_id: str, + *, + offset: int = 0, + limit: int = 20, + synapse_client: Optional["Synapse"] = None, + ) -> list: + """ + Get the history of a wiki page asynchronously 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. + """ + if not owner_id: + raise ValueError("Must provide owner_id to get wiki history.") + if not wiki_id: + raise ValueError("Must provide wiki_id to get wiki history.") + snapshots = [] + async for item in get_wiki_history( + owner_id=owner_id, + wiki_id=wiki_id, + offset=offset, + limit=limit, + synapse_client=synapse_client, + ): + snapshots.append(cls().fill_from_dict(item)) + return snapshots + + +@dataclass +@async_to_sync +class WikiHeader: + """ + 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, + *, + offset: int = 0, + limit: int = 20, + synapse_client: Optional["Synapse"] = None, + ) -> list: + """ + Get the header tree (hierarchy) of wiki pages for an entity asynchronously. + + 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. + """ + 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: + """ + 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 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. + version_number: 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 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[int] = 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.""" + entity = { + "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(entity) + result = { + "entity": entity, + } + delete_none_keys(result) + return result + + def _markdown_to_gzip_file( + self, + markdown: str, + synapse_client: Optional[Synapse] = None, + ) -> str: + """Convert markdown to a gzipped file and save it in the synapse cache to get a file handle id later. + + Arguments: + markdown: The markdown 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. + """ + if not isinstance(markdown, str): + raise TypeError( + f"Expected markdownto be a str, got {type(markdown).__name__}" + ) + + client = Synapse.get_client(synapse_client=synapse_client) + + # Get the cache directory path to save the newly created gzipped file + cache_dir = os.path.join(client.cache.cache_root_dir, "wiki_markdown") + 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(markdown): + # If it's already a gzipped file, save a copy to the cache + if markdown.endswith(".gz"): + file_path = os.path.join(cache_dir, os.path.basename(markdown)) + shutil.copyfile(markdown, file_path) + else: + # If it's a regular html or markdown file, compress it + with open(markdown, "rb") as f_in: + # Open the output gzip file + file_path = os.path.join( + cache_dir, os.path.basename(markdown) + ".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_{uuid.uuid4()}.md.gz") + with gzip.open(file_path, "wt", encoding="utf-8") as f_out: + f_out.write(markdown) + + return file_path + + @otel_trace_method( + method_to_trace_name=lambda self, **kwargs: f"Create_Wiki_Page: {self.owner_id}" + ) + async def create_async( + self, + *, + synapse_client: Optional[Synapse] = None, + force_version: bool = False, + ) -> "WikiPage": + """Create a new 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. + force_version: If True, the wiki page will be created with a new version number. + + Returns: + The created wiki page. + + Raises: + ValueError: If owner_id is not provided or if required fields are missing. + """ + if not self.owner_id: + raise ValueError("Must provide owner_id to create a wiki page.") + # check if the attachments exists + if not self.markdown: + raise ValueError("Must provide markdown content to create a wiki page.") + + # Convert markdown to gzipped file if needed + file_path = self._markdown_to_gzip_file(self.markdown, synapse_client) + + # 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, + ) + + # delete the temp gzip file + os.remove(file_path) + + # Set the markdown file handle ID from the upload response + self.markdown_file_handle_id = file_handle.get("id") + + # Create the wiki page + wiki_data = await post_wiki( + owner_id=self.owner_id, + request=self.to_synapse_request(), + synapse_client=synapse_client, + ) + + if force_version and self.wiki_version is not None: + wiki_data = await put_wiki_version( + owner_id=self.owner_id, + wiki_id=self.id, + wiki_version=self.wiki_version, + request=wiki_data, + synapse_client=synapse_client, + ) + + else: + raise ValueError("Must provide wiki_version to force a new version.") + + 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 we have an ID, use it directly (TO SIMPLIFY) + elif self.id: + wiki_data = await get_wiki_page( + owner_id=self.owner_id, + wiki_id=self.id, + wiki_version=self.version_number, + synapse_client=synapse_client, + ) + # If we only have a title, find the wiki page with matching title + else: + results = await get_wiki_header_tree( + owner_id=self.owner_id, + synapse_client=synapse_client, + ) + async for result in results: + 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}") + + wiki_data = await get_wiki_page( + owner_id=self.owner_id, + wiki_id=matching_header["id"], + wiki_version=self.version_number, + synapse_client=synapse_client, + ) + + self.fill_from_dict(wiki_data) + return self + + @otel_trace_method( + method_to_trace_name=lambda self, **kwargs: f"Update_Wiki_Page: {self.owner_id}, Wiki ID {self.id}, Wiki Version {self.wiki_version}" + ) + async def update_async( + self, + *, + force_version: bool = False, + synapse_client: Optional["Synapse"] = None, + ) -> "WikiPage": + """ + Update a wiki page. If force_version is True, restore a specific version of the content. + + Arguments: + force_version: If True, update a specific version of the wiki page (restore). + synapse_client: Optionally provide a Synapse client. + + Returns: + The updated WikiPage object. + + Raises: + ValueError: If required fields are missing. + """ + + if not self.owner_id: + raise ValueError("Must provide both owner_id to update a wiki page.") + if not self.id: + raise ValueError("Must provide id to update a wiki page.") + + if force_version: + if self.wiki_version is None: + raise ValueError("Must provide wiki_version to force a new version.") + 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, + ) + else: + wiki_data = await put_wiki_page( + owner_id=self.owner_id, + wiki_id=self.id, + 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"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 asynchronously. + + 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: + """ + Get the file handles of all attachments on this wiki page asynchronously. + + 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_url_async( + self, + file_name: str, + *, + redirect: Optional[bool] = False, + synapse_client: Optional["Synapse"] = None, + ) -> dict: + """ + Get the URL of a wiki page attachment asynchronously. + + Arguments: + file_name: The name of the file to get. + 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: + The URL that can be used to download a file for a given WikiPage file attachment. + 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.") + + return await get_attachment_url( + owner_id=self.owner_id, + wiki_id=self.id, + file_name=file_name, + wiki_version=self.wiki_version, + redirect=redirect, + synapse_client=synapse_client, + ) + + @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_url_async( + self, + file_name: str, + *, + wiki_version: Optional[int] = None, + redirect: Optional[bool] = False, + synapse_client: Optional["Synapse"] = None, + ) -> dict: + """ + Get the preview URL of a wiki page attachment asynchronously. + + 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: + The URL that can be used to download a preview file for a given WikiPage file attachment. + 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.") + + return await get_attachment_preview_url( + owner_id=self.owner_id, + wiki_id=self.id, + file_name=file_name, + wiki_version=self.wiki_version, + redirect=redirect, + synapse_client=synapse_client, + ) + + @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_url_async( + self, + *, + redirect: Optional[bool] = False, + synapse_client: Optional["Synapse"] = None, + ) -> dict: + """ + Get the markdown URL of this wiki page asynchronously. + + 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: + The URL that can be used to download the markdown file for 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 markdown URL.") + if not self.id: + raise ValueError("Must provide id to get markdown URL.") + + return await get_markdown_url( + owner_id=self.owner_id, + wiki_id=self.id, + wiki_version=self.wiki_version, + redirect=redirect, + synapse_client=synapse_client, + ) From b2a5840edddd72a8cd98c43a04fa6aab7bbddcf5 Mon Sep 17 00:00:00 2001 From: danlu1 Date: Wed, 11 Jun 2025 13:50:36 -0700 Subject: [PATCH 03/42] resort --- synapseclient/models/__init__.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/synapseclient/models/__init__.py b/synapseclient/models/__init__.py index 3ecefa185..1cee0028e 100644 --- a/synapseclient/models/__init__.py +++ b/synapseclient/models/__init__.py @@ -37,6 +37,13 @@ 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, + WikiPageHistory, +) __all__ = [ "Activity", @@ -88,6 +95,12 @@ "DatasetCollection", # Submission models "SubmissionView", + # Wiki models + "WikiPage", + "WikiOrderHint", + "WikiHistorySnapshot", + "WikiHeader", + "WikiPageHistory", ] # Static methods to expose as functions From 1ce73fdb1fdb8c90d24ee8b87037151a868276b1 Mon Sep 17 00:00:00 2001 From: danlu1 Date: Wed, 11 Jun 2025 16:36:39 -0700 Subject: [PATCH 04/42] add synchronous protocol parent class --- synapseclient/models/wiki.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/synapseclient/models/wiki.py b/synapseclient/models/wiki.py index bf7f54f09..26c944e58 100644 --- a/synapseclient/models/wiki.py +++ b/synapseclient/models/wiki.py @@ -26,11 +26,17 @@ from synapseclient.core.async_utils import async_to_sync, otel_trace_method from synapseclient.core.upload.upload_functions_async import upload_file_handle from synapseclient.core.utils import delete_none_keys +from synapseclient.models.protocols.wikipage_protocol import ( + WikiHeaderSynchronousProtocol, + WikiHistorySnapshotSynchronousProtocol, + WikiOrderHintSynchronousProtocol, + WikiPageSynchronousProtocol, +) @dataclass @async_to_sync -class WikiOrderHint: +class WikiOrderHint(WikiOrderHintSynchronousProtocol): """ A WikiOrderHint contains the order hint for the root wiki that corresponds to the given owner ID and type. @@ -130,14 +136,14 @@ async def update_async( @dataclass @async_to_sync -class WikiHistorySnapshot: +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. + modified_by: The ID of the user that created this version. """ version: Optional[str] = None @@ -210,7 +216,7 @@ async def get_async( @dataclass @async_to_sync -class WikiHeader: +class WikiHeader(WikiHeaderSynchronousProtocol): """ A WikiHeader contains basic metadata about a WikiPage. @@ -285,7 +291,7 @@ async def get_async( @dataclass @async_to_sync -class WikiPage: +class WikiPage(WikiPageSynchronousProtocol): """ Represents a [Wiki Page](https://rest-docs.synapse.org/rest/org/sagebionetworks/repo/model/v2/wiki/V2WikiPage.html). From 0685de4acd01e1af5aa645170e66829507c8320c Mon Sep 17 00:00:00 2001 From: danlu1 Date: Wed, 11 Jun 2025 16:51:43 -0700 Subject: [PATCH 05/42] add synchronous protocol for wiki2 --- .../models/protocols/wikipage_protocol.py | 218 ++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 synapseclient/models/protocols/wikipage_protocol.py diff --git a/synapseclient/models/protocols/wikipage_protocol.py b/synapseclient/models/protocols/wikipage_protocol.py new file mode 100644 index 000000000..d622265bc --- /dev/null +++ b/synapseclient/models/protocols/wikipage_protocol.py @@ -0,0 +1,218 @@ +"""Protocol for the specific methods of this class that have synchronous counterparts +generated at runtime.""" + +from typing import TYPE_CHECKING, List, Optional, Protocol + +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 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 + + def update( + 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 + + +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 create( + self, *, synapse_client: Optional["Synapse"] = None, force_version: bool = False + ) -> WikiPage: + """ + Create a new wiki page. + Arguments: + synapse_client: Optionally provide a Synapse client. + force_version: If True, the wiki page will be created with a new version number. + Returns: + The created WikiPage object. + """ + return self + + def get(self, *, synapse_client: Optional["Synapse"] = None) -> WikiPage: + """ + Get a wiki page from Synapse asynchronously. + Arguments: + synapse_client: Optionally provide a Synapse client. + Returns: + The WikiPage object. + """ + return self + + def update( + self, *, force_version: bool = False, synapse_client: Optional["Synapse"] = None + ) -> WikiPage: + """ + Update a wiki page asynchronously. If force_version is True, restore a specific version of the content. + Arguments: + force_version: If True, update a specific version of the wiki page (restore). + synapse_client: Optionally provide a Synapse client. + Returns: + The updated 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: + """ + 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_url( + self, + file_name: str, + *, + redirect: Optional[bool] = False, + synapse_client: Optional["Synapse"] = None, + ) -> dict: + """ + Get the URL of a wiki page attachment. + Arguments: + file_name: The name of the file to get. + 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: + The URL that can be used to download a file for a given WikiPage file attachment. + """ + return "" + + def get_attachment_preview_url( + self, + file_name: str, + *, + wiki_version: Optional[int] = None, + redirect: Optional[bool] = False, + synapse_client: Optional["Synapse"] = None, + ) -> dict: + """ + Get the preview URL of a wiki page attachment asynchronously. + 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: + The URL that can be used to download a preview file for a given WikiPage file attachment. + """ + return "" + + def get_markdown_url_async( + self, + *, + redirect: Optional[bool] = False, + synapse_client: Optional["Synapse"] = None, + ) -> dict: + """ + Get the markdown URL of this wiki page asynchronously. + 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: + The URL that can be used to download the markdown file for this WikiPage. + """ + return "" From 0fc3f75065778a9e5ee30cb1615cc666966a7bd7 Mon Sep 17 00:00:00 2001 From: danlu1 Date: Wed, 11 Jun 2025 16:54:25 -0700 Subject: [PATCH 06/42] remove redundant docstring --- synapseclient/models/wiki.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/synapseclient/models/wiki.py b/synapseclient/models/wiki.py index 26c944e58..243b5b75f 100644 --- a/synapseclient/models/wiki.py +++ b/synapseclient/models/wiki.py @@ -87,7 +87,7 @@ async def get_async( synapse_client: Optional["Synapse"] = None, ) -> "WikiOrderHint": """ - Get the order hint of a wiki page tree asynchronously. + Get the order hint of a wiki page tree. Arguments: synapse_client: Optionally provide a Synapse client. @@ -113,7 +113,7 @@ async def update_async( synapse_client: Optional["Synapse"] = None, ) -> "WikiOrderHint": """ - Update the order hint of a wiki page tree asynchronously. + Update the order hint of a wiki page tree. Arguments: synapse_client: Optionally provide a Synapse client. @@ -187,7 +187,7 @@ async def get_async( synapse_client: Optional["Synapse"] = None, ) -> list: """ - Get the history of a wiki page asynchronously as a list of WikiHistorySnapshot objects. + Get the history of a wiki page as a list of WikiHistorySnapshot objects. Arguments: owner_id: The ID of the owner entity. @@ -266,7 +266,7 @@ async def get_async( synapse_client: Optional["Synapse"] = None, ) -> list: """ - Get the header tree (hierarchy) of wiki pages for an entity asynchronously. + Get the header tree (hierarchy) of wiki pages for an entity. Arguments: owner_id: The ID of the owner entity. @@ -650,7 +650,7 @@ async def delete_async( synapse_client: Optional["Synapse"] = None, ) -> None: """ - Delete this wiki page asynchronously. + Delete this wiki page. Arguments: synapse_client: Optionally provide a Synapse client. @@ -677,7 +677,7 @@ async def get_attachment_handles_async( synapse_client: Optional["Synapse"] = None, ) -> list: """ - Get the file handles of all attachments on this wiki page asynchronously. + Get the file handles of all attachments on this wiki page. Arguments: synapse_client: Optionally provide a Synapse client. @@ -709,7 +709,7 @@ async def get_attachment_url_async( synapse_client: Optional["Synapse"] = None, ) -> dict: """ - Get the URL of a wiki page attachment asynchronously. + Get the URL of a wiki page attachment. Arguments: file_name: The name of the file to get. @@ -748,7 +748,7 @@ async def get_attachment_preview_url_async( synapse_client: Optional["Synapse"] = None, ) -> dict: """ - Get the preview URL of a wiki page attachment asynchronously. + Get the preview URL of a wiki page attachment. Arguments: file_name: The name of the file to get. @@ -786,7 +786,7 @@ async def get_markdown_url_async( synapse_client: Optional["Synapse"] = None, ) -> dict: """ - Get the markdown URL of this wiki page asynchronously. + Get the markdown URL of this wiki page. Arguments: redirect: When set to false, the URL will be returned as text/plain instead of redirecting. Default is False. From d4a98e854d46e328ddd7a5ee0f5bd251c8f83703 Mon Sep 17 00:00:00 2001 From: danlu1 Date: Fri, 13 Jun 2025 10:55:10 -0700 Subject: [PATCH 07/42] all pre-signed url to be downloaded directly --- synapseclient/core/download/download_async.py | 15 ++- .../core/download/download_functions.py | 100 +++++++++++------- 2 files changed, 76 insertions(+), 39 deletions(-) diff --git a/synapseclient/core/download/download_async.py b/synapseclient/core/download/download_async.py index 7ecd183d4..859bf9a4d 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,12 @@ 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 = 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 +288,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..8cfb6c285 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,13 +570,14 @@ def download_fn( async def download_from_url_multi_threaded( - file_handle_id: str, - object_id: str, + file_handle_id: Optional[str], + object_id: Optional[str], object_type: str, destination: str, *, 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. @@ -603,17 +605,25 @@ 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, + ) + # generate a name tuple for presigned url + else: + request = DownloadRequest( + path=temp_destination, + debug=client.debug, + presigned_url=presigned_url, + ) await download_file(client=client, download_request=request) @@ -643,6 +653,7 @@ def download_from_url( 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]: @@ -661,6 +672,8 @@ def download_from_url( 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. @@ -768,13 +781,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, @@ -801,24 +822,29 @@ 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, - ) - refreshed_url = response["preSignedURL"] - response = with_retry( - lambda url=refreshed_url, range_header=range_header, auth=auth: client._requests_session.get( - url=url, - headers=client._generate_headers(range_header), - stream=True, - allow_redirects=False, - auth=auth, - ), - verbose=client.debug, - **STANDARD_RETRY_PARAMS, - ) + if url_is_presigned: + raise SynapseError( + "The provided pre-signed URL has expired. Please provide a new pre-signed URL." + ) + else: + 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, + ) + refreshed_url = response["preSignedURL"] + response = with_retry( + lambda url=refreshed_url, range_header=range_header, auth=auth: client._requests_session.get( + url=url, + headers=client._generate_headers(range_header), + stream=True, + allow_redirects=False, + auth=auth, + ), + verbose=client.debug, + **STANDARD_RETRY_PARAMS, + ) else: raise elif err.response.status_code == 404: From 86fa041d1a19181c23976636fd87396c20b63577 Mon Sep 17 00:00:00 2001 From: danlu1 Date: Fri, 13 Jun 2025 12:57:26 -0700 Subject: [PATCH 08/42] add rest_get_paginated_async to get paginated results from api call --- synapseclient/client.py | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) 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 From 6601fdcbec010a68b6e6013326a8223911e98029 Mon Sep 17 00:00:00 2001 From: danlu1 Date: Mon, 16 Jun 2025 11:02:43 -0700 Subject: [PATCH 09/42] add unit test for rest_get_paginated_async --- tests/unit/synapseclient/unit_test_client.py | 110 +++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/tests/unit/synapseclient/unit_test_client.py b/tests/unit/synapseclient/unit_test_client.py index f40f6daa1..ba2804dd7 100644 --- a/tests/unit/synapseclient/unit_test_client.py +++ b/tests/unit/synapseclient/unit_test_client.py @@ -4272,3 +4272,113 @@ 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", return_value=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": 6, "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": []}, # Empty results to end pagination + ] + + with patch.object( + self.syn, "rest_get_async", return_value=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": 4, "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": []}, # Empty results to end pagination + ] + + with patch.object( + self.syn, "rest_get_async", return_value=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) From b7289f2fa76c6b98833d9e11073aa4aa43c993ff Mon Sep 17 00:00:00 2001 From: danlu1 Date: Mon, 16 Jun 2025 11:07:35 -0700 Subject: [PATCH 10/42] add unit test for rest_get_paginated_async --- tests/unit/synapseclient/unit_test_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/synapseclient/unit_test_client.py b/tests/unit/synapseclient/unit_test_client.py index ba2804dd7..f2a2953de 100644 --- a/tests/unit/synapseclient/unit_test_client.py +++ b/tests/unit/synapseclient/unit_test_client.py @@ -4312,7 +4312,7 @@ async def test_rest_get_paginated_async_with_children(self) -> None: mock_responses = [ {"children": [{"id": 1}, {"id": 2}]}, {"children": [{"id": 3}]}, - {"children": []}, # Empty results to end pagination + {"children": []}, ] with patch.object( @@ -4358,7 +4358,7 @@ 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": []}, # Empty results to end pagination + {"results": []}, ] with patch.object( From 21a4d8d0ce2eae8419fe76a259f558f36bff3a9e Mon Sep 17 00:00:00 2001 From: danlu1 Date: Mon, 16 Jun 2025 11:45:22 -0700 Subject: [PATCH 11/42] tweak test cases --- tests/unit/synapseclient/unit_test_client.py | 210 ++++++++++--------- 1 file changed, 107 insertions(+), 103 deletions(-) diff --git a/tests/unit/synapseclient/unit_test_client.py b/tests/unit/synapseclient/unit_test_client.py index f2a2953de..b71a8f917 100644 --- a/tests/unit/synapseclient/unit_test_client.py +++ b/tests/unit/synapseclient/unit_test_client.py @@ -4273,112 +4273,116 @@ async def test_for_httpx_modified_user_agent_multiple_strings(self) -> None: "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", return_value=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": 6, "limit": 3}), + 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": []}, ] - 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", return_value=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": 4, "limit": 2}), + 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": []}, ] - 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 + 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} + ) - with patch.object( - self.syn, "rest_get_async", return_value=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}), + 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": []}, ] - mock_rest_get.assert_has_calls(call_list) + + 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) From e06a35ab17e3bc2b4a0d00279b326f7af42a60c0 Mon Sep 17 00:00:00 2001 From: danlu1 Date: Mon, 16 Jun 2025 11:57:36 -0700 Subject: [PATCH 12/42] use rest_get_paginated_async to get paginated results --- synapseclient/api/wiki_service.py | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/synapseclient/api/wiki_service.py b/synapseclient/api/wiki_service.py index 95092beb9..da780ca9a 100644 --- a/synapseclient/api/wiki_service.py +++ b/synapseclient/api/wiki_service.py @@ -8,8 +8,6 @@ if TYPE_CHECKING: from synapseclient import Synapse -import sys - async def post_wiki( owner_id: str, @@ -195,19 +193,13 @@ async def get_wiki_header_tree( client = Synapse.get_client(synapse_client=synapse_client) - prev_num_results = sys.maxsize - while prev_num_results > 0: - params = {"offset": offset, "limit": limit} - page = await client.rest_get_async( - uri=f"/entity/{owner_id}/wikiheadertree2", - params=params, - ) - results = page["results"] if "results" in page else page["children"] - prev_num_results = len(results) + response = client.rest_get_paginated_async( + uri=f"/entity/{owner_id}/wikiheadertree2", + limit=limit, + offset=offset, + ) - for result in results: - offset += 1 - yield result + return response async def get_wiki_history( @@ -240,11 +232,12 @@ async def get_wiki_history( client = Synapse.get_client(synapse_client=synapse_client) - params = {"offset": offset, "limit": limit} - return await client.rest_get_async( + response = client.rest_get_paginated_async( uri=f"/entity/{owner_id}/wiki2/{wiki_id}/wikihistory", - params=params, + limit=limit, + offset=offset, ) + return response async def get_attachment_handles( From 2fdfc717bf9f7fd9d5d1fa89a6c7dbdace7fd6d6 Mon Sep 17 00:00:00 2001 From: danlu1 Date: Mon, 16 Jun 2025 11:58:59 -0700 Subject: [PATCH 13/42] reformat download_functions.py --- synapseclient/core/download/download_functions.py | 1 - 1 file changed, 1 deletion(-) diff --git a/synapseclient/core/download/download_functions.py b/synapseclient/core/download/download_functions.py index 8cfb6c285..6e82f3b29 100644 --- a/synapseclient/core/download/download_functions.py +++ b/synapseclient/core/download/download_functions.py @@ -617,7 +617,6 @@ async def download_from_url_multi_threaded( path=temp_destination, debug=client.debug, ) - # generate a name tuple for presigned url else: request = DownloadRequest( path=temp_destination, From 176cdf8ffe939f9f8307b487ffa3f0aec00cff4d Mon Sep 17 00:00:00 2001 From: danlu1 Date: Tue, 17 Jun 2025 13:29:29 -0700 Subject: [PATCH 14/42] use specified destination for pre-signed url downloads --- synapseclient/core/download/download_functions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/synapseclient/core/download/download_functions.py b/synapseclient/core/download/download_functions.py index 6e82f3b29..f6410cb43 100644 --- a/synapseclient/core/download/download_functions.py +++ b/synapseclient/core/download/download_functions.py @@ -572,7 +572,7 @@ def download_fn( async def download_from_url_multi_threaded( file_handle_id: Optional[str], object_id: Optional[str], - object_type: str, + object_type: Optional[str], destination: str, *, expected_md5: str = None, @@ -619,7 +619,7 @@ async def download_from_url_multi_threaded( ) else: request = DownloadRequest( - path=temp_destination, + path=destination, debug=client.debug, presigned_url=presigned_url, ) From c52996fb7be4c6bb8b2365c8dbb7ed538369b59d Mon Sep 17 00:00:00 2001 From: danlu1 Date: Mon, 23 Jun 2025 12:48:28 -0700 Subject: [PATCH 15/42] remove unused modules --- synapseclient/models/wiki.py | 493 +++++++++++++++++++++++++---------- 1 file changed, 349 insertions(+), 144 deletions(-) diff --git a/synapseclient/models/wiki.py b/synapseclient/models/wiki.py index 243b5b75f..2a2db5ee9 100644 --- a/synapseclient/models/wiki.py +++ b/synapseclient/models/wiki.py @@ -24,6 +24,13 @@ 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 from synapseclient.models.protocols.wikipage_protocol import ( @@ -78,6 +85,19 @@ def fill_from_dict( 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"Get_Wiki_Order_Hint: {self.owner_id}" ) @@ -107,13 +127,13 @@ async def get_async( @otel_trace_method( method_to_trace_name=lambda self, **kwargs: f"Update_Wiki_Order_Hint: {self.owner_id}" ) - async def update_async( + async def store_async( self, *, synapse_client: Optional["Synapse"] = None, ) -> "WikiOrderHint": """ - Update the order hint of a wiki page tree. + Store the order hint of a wiki page tree. Arguments: synapse_client: Optionally provide a Synapse client. @@ -123,7 +143,7 @@ async def update_async( ValueError: If owner_id or request is not provided. """ if not self.owner_id: - raise ValueError("Must provide owner_id to update wiki order hint.") + raise ValueError("Must provide owner_id to store wiki order hint.") order_hint_dict = await put_wiki_order_hint( owner_id=self.owner_id, @@ -175,23 +195,23 @@ def fill_from_dict( @classmethod @otel_trace_method( - method_to_trace_name=lambda self, **kwargs: f"Get_Wiki_History for Owner ID {kwargs['owner_id']}, Wiki ID {kwargs['wiki_id']}" + 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, - wiki_id: str, + id: str, *, offset: int = 0, limit: int = 20, synapse_client: Optional["Synapse"] = None, - ) -> list: + ) -> 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. + 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. @@ -200,12 +220,12 @@ async def get_async( """ if not owner_id: raise ValueError("Must provide owner_id to get wiki history.") - if not wiki_id: - raise ValueError("Must provide wiki_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=wiki_id, + wiki_id=id, # use id instead of wiki_id to match other classes offset=offset, limit=limit, synapse_client=synapse_client, @@ -264,7 +284,7 @@ async def get_async( offset: int = 0, limit: int = 20, synapse_client: Optional["Synapse"] = None, - ) -> list: + ) -> List["WikiHeader"]: """ Get the header tree (hierarchy) of wiki pages for an entity. @@ -349,7 +369,7 @@ class WikiPage(WikiPageSynchronousProtocol): modified_by: Optional[str] = field(default=None, compare=False) """The ID of the user that last modified this page.""" - wiki_version: Optional[int] = None + wiki_version: Optional[str] = None """The version number of this wiki page.""" markdown_file_handle_id: Optional[str] = None @@ -393,7 +413,7 @@ 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.""" - entity = { + result = { "id": self.id, "etag": self.etag, "title": self.title, @@ -409,51 +429,43 @@ def to_synapse_request( "markdownFileHandleId": self.markdown_file_handle_id, "attachmentFileHandleIds": self.attachment_file_handle_ids, } - delete_none_keys(entity) - result = { - "entity": entity, - } delete_none_keys(result) return result - def _markdown_to_gzip_file( + def _to_gzip_file( self, - markdown: str, + wiki_content: str, synapse_client: Optional[Synapse] = None, ) -> str: - """Convert markdown to a gzipped file and save it in the synapse cache to get a file handle id later. + """Convert markdown or attachment to a gzipped file and save it in the synapse cache to get a file handle id later. Arguments: - markdown: The markdown content as plain text, basic HTML, or Markdown, or a file path to such content. + 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. """ - if not isinstance(markdown, str): - raise TypeError( - f"Expected markdownto be a str, got {type(markdown).__name__}" - ) - - client = Synapse.get_client(synapse_client=synapse_client) - + # 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(client.cache.cache_root_dir, "wiki_markdown") + 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(markdown): + if os.path.isfile(wiki_content): # If it's already a gzipped file, save a copy to the cache - if markdown.endswith(".gz"): - file_path = os.path.join(cache_dir, os.path.basename(markdown)) - shutil.copyfile(markdown, file_path) + if wiki_content.endswith(".gz"): + file_path = os.path.join(cache_dir, os.path.basename(wiki_content)) + shutil.copyfile(wiki_content, file_path) else: # If it's a regular html or markdown file, compress it - with open(markdown, "rb") as f_in: + with open(wiki_content, "rb") as f_in: # Open the output gzip file file_path = os.path.join( - cache_dir, os.path.basename(markdown) + ".gz" + cache_dir, os.path.basename(wiki_content) + ".gz" ) with gzip.open(file_path, "wb") as f_out: f_out.writelines(f_in) @@ -462,26 +474,43 @@ def _markdown_to_gzip_file( # 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_{uuid.uuid4()}.md.gz") with gzip.open(file_path, "wt", encoding="utf-8") as f_out: - f_out.write(markdown) + 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"Create_Wiki_Page: {self.owner_id}" + method_to_trace_name=lambda self, **kwargs: f"Store the wiki page: {self.owner_id}" ) - async def create_async( + async def store_async( self, *, synapse_client: Optional[Synapse] = None, - force_version: bool = False, ) -> "WikiPage": - """Create a new wiki page. + """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: 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. - force_version: If True, the wiki page will be created with a new version number. Returns: The created wiki page. @@ -489,47 +518,163 @@ async def create_async( Raises: ValueError: If owner_id is not provided or if required fields are missing. """ + client = Synapse.get_client(synapse_client=synapse_client) if not self.owner_id: - raise ValueError("Must provide owner_id to create a wiki page.") - # check if the attachments exists - if not self.markdown: - raise ValueError("Must provide markdown content to create a wiki page.") - + raise ValueError("Must provide owner_id to modify a wiki page.") # Convert markdown to gzipped file if needed - file_path = self._markdown_to_gzip_file(self.markdown, synapse_client) + if self.markdown: + file_path = self._to_gzip_file( + wiki_content=self.markdown, synapse_client=client + ) + # Upload the gzipped file to get a file handle + file_handle = await upload_file_handle( + syn=client, + parent_entity_id=self.owner_id, + path=file_path, + ) - # 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, - ) + client.logger.info( + f"Uploaded file handle {file_handle.get('id')} for wiki page markdown." + ) + # delete the temp gzip file + os.remove(file_path) + client.logger.debug(f"Deleted temp gzip file {file_path}") + + # Set the markdown file handle ID from the upload response + self.markdown_file_handle_id = file_handle.get("id") + + # Convert attachments to gzipped file if needed + if self.attachments: + file_handles = [] + for attachment in self.attachments: + file_path = self._to_gzip_file( + wiki_content=attachment, synapse_client=client + ) + file_handle = await upload_file_handle( + syn=client, + parent_entity_id=self.owner_id, + path=file_path, + ) + file_handles.append(file_handle.get("id")) + client.logger.info( + f"Uploaded file handle {file_handle.get('id')} for wiki page attachment." + ) + # delete the temp gzip file + os.remove(file_path) + client.logger.debug(f"Deleted temp gzip file {file_path}") + # Set the attachment file handle IDs from the upload response + self.attachment_file_handle_ids = file_handles + + # Handle root wiki page creation if parent_id is not given + if not self.parent_id: + try: + WikiHeader.get( + owner_id=self.owner_id, + ) + except SynapseHTTPError as e: + if e.response.status_code == 404: + client.logger.debug( + "No wiki page exists within the owner. Create a new wiki page." + ) + # Create the wiki page + wiki_data = await post_wiki( + 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')}." + ) + else: + raise e + else: + client.logger.info( + "A wiki page already exists within the owner. Update the existing wiki page." + ) + # Update the existing wiki page + if not (self.id): + raise ValueError("Must provide id to update a wiki page.") + + # retrieve the wiki page + existing_wiki = await get_wiki_page( + owner_id=self.owner_id, + wiki_id=self.id, + wiki_version=self.wiki_version, + ) + # Update existing_wiki with current object's attributes if they are not None + updates = { + k: v + for k, v in { + "id": self.id, + "title": self.title, + "markdown": self.markdown, + "parentWikiId": self.parent_id, + "attachments": self.attachments, + "markdownFileHandleId": self.markdown_file_handle_id, + "attachmentFileHandleIds": self.attachment_file_handle_ids, + }.items() + if v is not None + } + existing_wiki.update(updates) + # update the wiki page + wiki_data = await put_wiki_page( + owner_id=self.owner_id, + wiki_id=self.id, + request=existing_wiki, + ) + 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 + else: + 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( + 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 - # delete the temp gzip file - os.remove(file_path) + @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. - # Set the markdown file handle ID from the upload response - self.markdown_file_handle_id = file_handle.get("id") + 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.") - # Create the wiki page - wiki_data = await post_wiki( + # 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, ) - - if force_version and self.wiki_version is not None: - wiki_data = await put_wiki_version( - owner_id=self.owner_id, - wiki_id=self.id, - wiki_version=self.wiki_version, - request=wiki_data, - synapse_client=synapse_client, - ) - - else: - raise ValueError("Must provide wiki_version to force a new version.") - self.fill_from_dict(wiki_data) return self @@ -563,16 +708,15 @@ async def get_async( wiki_data = await get_wiki_page( owner_id=self.owner_id, wiki_id=self.id, - wiki_version=self.version_number, + wiki_version=self.wiki_version, synapse_client=synapse_client, ) # If we only have a title, find the wiki page with matching title else: - results = await get_wiki_header_tree( + async for result in get_wiki_header_tree( owner_id=self.owner_id, synapse_client=synapse_client, - ) - async for result in results: + ): if result.get("title") == self.title: matching_header = result break @@ -585,56 +729,7 @@ async def get_async( wiki_data = await get_wiki_page( owner_id=self.owner_id, wiki_id=matching_header["id"], - wiki_version=self.version_number, - synapse_client=synapse_client, - ) - - self.fill_from_dict(wiki_data) - return self - - @otel_trace_method( - method_to_trace_name=lambda self, **kwargs: f"Update_Wiki_Page: {self.owner_id}, Wiki ID {self.id}, Wiki Version {self.wiki_version}" - ) - async def update_async( - self, - *, - force_version: bool = False, - synapse_client: Optional["Synapse"] = None, - ) -> "WikiPage": - """ - Update a wiki page. If force_version is True, restore a specific version of the content. - - Arguments: - force_version: If True, update a specific version of the wiki page (restore). - synapse_client: Optionally provide a Synapse client. - - Returns: - The updated WikiPage object. - - Raises: - ValueError: If required fields are missing. - """ - - if not self.owner_id: - raise ValueError("Must provide both owner_id to update a wiki page.") - if not self.id: - raise ValueError("Must provide id to update a wiki page.") - - if force_version: - if self.wiki_version is None: - raise ValueError("Must provide wiki_version to force a new version.") - 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, - ) - else: - wiki_data = await put_wiki_page( - owner_id=self.owner_id, - wiki_id=self.id, - request=self.to_synapse_request(), synapse_client=synapse_client, ) @@ -701,22 +796,26 @@ async def get_attachment_handles_async( @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_url_async( + async def get_attachment_async( self, file_name: str, *, + download_file: bool = True, + download_location: Optional[str] = None, redirect: Optional[bool] = False, synapse_client: Optional["Synapse"] = None, - ) -> dict: + ) -> Union[str, None]: """ - Get the URL of a wiki page attachment. + 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. 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: - The URL that can be used to download a file for a given WikiPage file attachment. + 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. """ @@ -727,36 +826,75 @@ async def get_attachment_url_async( if not file_name: raise ValueError("Must provide file_name to get attachment URL.") - return await 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, redirect=redirect, - synapse_client=synapse_client, + 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 < 8388608: + 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.url, 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_url_async( + async def get_attachment_preview_async( self, file_name: str, *, - wiki_version: Optional[int] = None, + download_file: bool = True, + download_location: Optional[str] = None, redirect: Optional[bool] = False, synapse_client: Optional["Synapse"] = None, - ) -> dict: + ) -> Union[str, None]: """ - Get the preview URL of a wiki page attachment. + 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. + 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. 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: - The URL that can be used to download a preview file for a given WikiPage file attachment. + 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. """ @@ -767,32 +905,75 @@ async def get_attachment_preview_url_async( if not file_name: raise ValueError("Must provide file_name to get attachment preview URL.") - return await 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, redirect=redirect, - synapse_client=synapse_client, + 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 < 8388608: + 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.url, 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_url_async( + async def get_markdown_async( self, *, + download_file_name: Optional[str] = None, + download_file: bool = True, + download_location: Optional[str] = None, redirect: Optional[bool] = False, synapse_client: Optional["Synapse"] = None, - ) -> dict: + ) -> Union[str, None]: """ Get the markdown URL of this wiki page. Arguments: + 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. 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: - The URL that can be used to download the markdown file for this WikiPage. + 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. """ @@ -801,10 +982,34 @@ async def get_markdown_url_async( if not self.id: raise ValueError("Must provide id to get markdown URL.") - return await 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, redirect=redirect, - synapse_client=synapse_client, + 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 From 99cbb8155d3125b5fc21a2bbfbba8fc002f7b87b Mon Sep 17 00:00:00 2001 From: danlu1 Date: Mon, 23 Jun 2025 12:48:56 -0700 Subject: [PATCH 16/42] update function names --- synapseclient/api/wiki_service.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/synapseclient/api/wiki_service.py b/synapseclient/api/wiki_service.py index da780ca9a..7de4e55b2 100644 --- a/synapseclient/api/wiki_service.py +++ b/synapseclient/api/wiki_service.py @@ -193,13 +193,12 @@ async def get_wiki_header_tree( client = Synapse.get_client(synapse_client=synapse_client) - response = client.rest_get_paginated_async( + async for item in client.rest_get_paginated_async( uri=f"/entity/{owner_id}/wikiheadertree2", limit=limit, offset=offset, - ) - - return response + ): + yield item async def get_wiki_history( @@ -232,12 +231,12 @@ async def get_wiki_history( client = Synapse.get_client(synapse_client=synapse_client) - response = client.rest_get_paginated_async( + async for item in client.rest_get_paginated_async( uri=f"/entity/{owner_id}/wiki2/{wiki_id}/wikihistory", limit=limit, offset=offset, - ) - return response + ): + yield item async def get_attachment_handles( @@ -246,7 +245,7 @@ async def get_attachment_handles( *, wiki_version: Optional[int] = None, synapse_client: Optional["Synapse"] = None, -) -> List[str, Any]: +) -> List[Dict[str, Any]]: """Get the file handles of all attachments on a wiki page. From 47f38e07d46a3b16a51e2f01b37f295278d70846 Mon Sep 17 00:00:00 2001 From: danlu1 Date: Mon, 23 Jun 2025 12:50:04 -0700 Subject: [PATCH 17/42] define optional params --- .../core/download/download_functions.py | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/synapseclient/core/download/download_functions.py b/synapseclient/core/download/download_functions.py index f6410cb43..9f3a17d4e 100644 --- a/synapseclient/core/download/download_functions.py +++ b/synapseclient/core/download/download_functions.py @@ -571,9 +571,9 @@ def download_fn( async def download_from_url_multi_threaded( file_handle_id: Optional[str], - object_id: Optional[str], - object_type: Optional[str], destination: str, + object_id: Optional[str] = None, + object_type: Optional[str] = None, *, expected_md5: str = None, synapse_client: Optional["Synapse"] = None, @@ -584,12 +584,12 @@ async def download_from_url_multi_threaded( 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 @@ -647,8 +647,8 @@ 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, @@ -662,9 +662,9 @@ 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 @@ -694,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 From a620db578b8d0198f816dbf9b67cae98342e93c7 Mon Sep 17 00:00:00 2001 From: danlu1 Date: Mon, 23 Jun 2025 12:50:32 -0700 Subject: [PATCH 18/42] update function names --- .../models/protocols/wikipage_protocol.py | 72 ++++++++++--------- 1 file changed, 38 insertions(+), 34 deletions(-) diff --git a/synapseclient/models/protocols/wikipage_protocol.py b/synapseclient/models/protocols/wikipage_protocol.py index d622265bc..0785ddaef 100644 --- a/synapseclient/models/protocols/wikipage_protocol.py +++ b/synapseclient/models/protocols/wikipage_protocol.py @@ -1,9 +1,10 @@ """Protocol for the specific methods of this class that have synchronous counterparts generated at runtime.""" -from typing import TYPE_CHECKING, List, Optional, Protocol +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Protocol, Union from synapseclient import Synapse +from synapseclient.core.async_utils import async_to_sync if TYPE_CHECKING: from synapseclient.models import ( @@ -14,6 +15,7 @@ ) +@async_to_sync class WikiOrderHintSynchronousProtocol(Protocol): """Protocol for the methods of the WikiOrderHint class that have synchronous counterparts generated at runtime.""" @@ -22,7 +24,7 @@ def get( self, *, synapse_client: Optional[Synapse] = None, - ) -> WikiOrderHint: + ) -> "WikiOrderHint": """ Get the order hint of a wiki page tree. Arguments: @@ -32,11 +34,11 @@ def get( """ return self - def update( + def store( self, *, synapse_client: Optional["Synapse"] = None, - ) -> WikiOrderHint: + ) -> "WikiOrderHint": """ Update the order hint of a wiki page tree. Arguments: @@ -47,6 +49,7 @@ def update( return self +@async_to_sync class WikiHistorySnapshotSynchronousProtocol(Protocol): """Protocol for the methods of the WikiHistorySnapshot class that have synchronous counterparts generated at runtime.""" @@ -60,7 +63,7 @@ def get( offset: int = 0, limit: int = 20, synapse_client: Optional["Synapse"] = None, - ) -> List[WikiHistorySnapshot]: + ) -> List["WikiHistorySnapshot"]: """ Get the history of a wiki page as a list of WikiHistorySnapshot objects. Arguments: @@ -75,6 +78,7 @@ def get( return list({}) +@async_to_sync class WikiHeaderSynchronousProtocol(Protocol): """Protocol for the methods of the WikiHeader class that have synchronous counterparts generated at runtime.""" @@ -87,7 +91,7 @@ def get( offset: int = 0, limit: int = 20, synapse_client: Optional["Synapse"] = None, - ) -> List[WikiHeader]: + ) -> List["WikiHeader"]: """ Get the header tree (hierarchy) of wiki pages for an entity. Arguments: @@ -101,43 +105,39 @@ def get( return list({}) +@async_to_sync class WikiPageSynchronousProtocol(Protocol): """Protocol for the methods of the WikiPage class that have synchronous counterparts generated at runtime.""" - def create( - self, *, synapse_client: Optional["Synapse"] = None, force_version: bool = False - ) -> WikiPage: + def store(self, *, synapse_client: Optional["Synapse"] = None) -> "WikiPage": """ - Create a new wiki page. + 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. - force_version: If True, the wiki page will be created with a new version number. Returns: The created WikiPage object. """ return self - def get(self, *, synapse_client: Optional["Synapse"] = None) -> WikiPage: + def restore(self, *, synapse_client: Optional["Synapse"] = None) -> "WikiPage": """ - Get a wiki page from Synapse asynchronously. + Restore a specific version of the wiki page. Arguments: synapse_client: Optionally provide a Synapse client. Returns: - The WikiPage object. + The restored WikiPage object. """ return self - def update( - self, *, force_version: bool = False, synapse_client: Optional["Synapse"] = None - ) -> WikiPage: + def get(self, *, synapse_client: Optional["Synapse"] = None) -> "WikiPage": """ - Update a wiki page asynchronously. If force_version is True, restore a specific version of the content. + Get a wiki page from Synapse. Arguments: - force_version: If True, update a specific version of the wiki page (restore). synapse_client: Optionally provide a Synapse client. Returns: - The updated WikiPage object. + The WikiPage object. """ return self @@ -153,7 +153,7 @@ def delete(self, *, synapse_client: Optional["Synapse"] = None) -> None: def get_attachment_handles( self, *, synapse_client: Optional["Synapse"] = None - ) -> list: + ) -> List[Dict[str, Any]]: """ Get the file handles of all attachments on this wiki page. Arguments: @@ -161,58 +161,62 @@ def get_attachment_handles( Returns: The list of FileHandles for all file attachments of this WikiPage. """ - return list([]) + return list({}) - def get_attachment_url( + 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, - ) -> dict: + ) -> Union[str, None]: """ - Get the URL of a wiki page attachment. + 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: - The URL that can be used to download a file for a given WikiPage file attachment. + 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_url( + def get_attachment_preview( self, file_name: str, *, wiki_version: Optional[int] = None, redirect: Optional[bool] = False, synapse_client: Optional["Synapse"] = None, - ) -> dict: + ) -> Union[str, None]: """ - Get the preview URL of a wiki page attachment asynchronously. + 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: - The URL that can be used to download a preview file for a given WikiPage file attachment. + 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_url_async( + def get_markdown( self, *, redirect: Optional[bool] = False, synapse_client: Optional["Synapse"] = None, - ) -> dict: + ) -> Union[str, None]: """ - Get the markdown URL of this wiki page asynchronously. + 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: - The URL that can be used to download the markdown file for this WikiPage. + If download_file is True, the markdown file will be downloaded to the download_location. Otherwise, the URL will be returned. """ return "" From 4e89258801ecbe72b4ae5c7ae3d002174e467f92 Mon Sep 17 00:00:00 2001 From: danlu1 Date: Mon, 23 Jun 2025 12:51:25 -0700 Subject: [PATCH 19/42] add tutorials --- .../tutorials/python/tutorial_scripts/wiki.py | 243 ++++++++++++++++++ docs/tutorials/python/wiki.md | 142 +++++++++- 2 files changed, 384 insertions(+), 1 deletion(-) create mode 100644 docs/tutorials/python/tutorial_scripts/wiki.py diff --git a/docs/tutorials/python/tutorial_scripts/wiki.py b/docs/tutorials/python/tutorial_scripts/wiki.py new file mode 100644 index 000000000..1e68af37b --- /dev/null +++ b/docs/tutorials/python/tutorial_scripts/wiki.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +""" +Tutorial script demonstrating the Synapse Wiki models functionality. + +This script shows how to: +1. +2. Work with wiki headers and hierarchy +3. Access wiki history +4. Manage wiki order hints +5. Handle attachments and markdown content +6. Download wiki content and attachments +""" +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 +wiki_page_1 = 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 +markdown_file_path = "path/to/your_markdown_file.md" +wiki_page_1 = 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 +wiki_page_2 = WikiPage( + owner_id=my_test_project.id, + title="My First Root Wiki Page Version 1", + markdown="# Welcome to My Root Wiki Version 1\n\nThis is a sample root wiki page created with the Synapse client.", + id=wiki_page_1.id, +).store() + +# Restore the wiki page to the original version +wiki_page_restored = WikiPage( + owner_id=my_test_project.id, id=wiki_page_1.id, wiki_version="0" +).restore() + +# check if the content is restored +comparisons = [ + wiki_page_1.markdown_file_handle_id == wiki_page_restored.markdown_file_handle_id, + wiki_page_1.id == wiki_page_restored.id, + wiki_page_1.title == wiki_page_restored.title, +] +print(f"All fields match: {all(comparisons)}") + +# Create a sub-wiki page +sub_wiki = WikiPage( + owner_id=my_test_project.id, + title="Sub Wiki Page 1", + parent_id=wiki_page_1.id, # Use the ID of the parent wiki page we created '633033' + 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.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=wiki_page_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 = [ + wiki_page_1.markdown_file_handle_id == retrieved_wiki.markdown_file_handle_id, + wiki_page_1.id == retrieved_wiki.id, + wiki_page_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 +markdown_wiki = WikiPage( + owner_id=my_test_project.id, + parent_id=wiki_page_1.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 +markdown_wiki = WikiPage( + owner_id=my_test_project.id, + parent_id=wiki_page_1.id, + title="Sub Page 3 created from markdown file", + markdown=markdown_file_path, +).store() + +# Download the markdown file +# delete the markdown file after downloading --> check if the file is downloaded +os.remove(markdown_file_path) +markdown_file = WikiPage(owner_id=my_test_project.id, id=markdown_wiki.id).get_markdown( + download_file=True, download_location=".", download_file_name="markdown_file.md" +) + +print(f"Markdown file downloaded to: {markdown_file}") + +# 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 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") + +wiki_with_attachments = WikiPage( + owner_id=my_test_project.id, + parent_id=wiki_page_1.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=wiki_with_attachments.id +).get_attachment_handles() +print(f"Found {len(attachment_handles['list'])} attachments") + +# Delete the attachment file after uploading --> check if the file is deleted +os.remove(attachment_file_name) +# Download an attachment +wiki_page = WikiPage( + owner_id=my_test_project.id, id=wiki_with_attachments.id +).get_attachment( + file_name=attachment_file_name, + download_file=True, + download_location=".", +) +print(f"Attachment downloaded: {os.path.exists(attachment_file_name)}") + +# Get attachment URL without downloading +wiki_page_url = WikiPage( + owner_id=my_test_project.id, id=wiki_with_attachments.id +).get_attachment( + file_name="temp_attachment.txt.gz", + download_file=False, +) +print(f"Attachment URL: {wiki_page_url}") + +# Download an attachment preview--? Failed to download the attachment preview, synapseclient.core.exceptions.SynapseHTTPError: 404 Client Error: Cannot find a wiki attachment for OwnerID: syn68493645, ObjectType: ENTITY, WikiPageId: 633100, fileName: preview.txt +attachment_handles = WikiPage( + owner_id=my_test_project.id, id=wiki_with_attachments.id +).get_attachment_handles() +print(f"Attachment handles: {attachment_handles}") +wiki_page = WikiPage( + owner_id=my_test_project.id, id=wiki_with_attachments.id +).get_attachment_preview( + file_name="preview.txt", + download_file=True, + download_location=".", +) + +# Section 4: WikiHeader - Working with Wiki Hierarchy + +# Get wiki header tree (hierarchy) +# Note: Uncomment to actually get the header tree +headers = WikiHeader.get(owner_id=my_test_project.id) +print(f"Found {len(headers)} wiki pages in hierarchy") + +# Section 5. WikiHistorySnapshot - Version History +# Get wiki history +history = WikiHistorySnapshot.get(owner_id=my_test_project.id, id=wiki_page_1.id) + +print(f"Found {len(history)} versions in history for {wiki_page_1.title}") + +# Section 6. WikiOrderHint - Ordering Wiki Pages +# Get wiki order hint --> failed to get the order hint +order_hint = WikiOrderHint(owner_id=my_test_project.id).get() +print(f"Order hint for {my_test_project.id}: {order_hint}") + +# Update wiki order hint +order_hint.id_list = [wiki_page_1.id] + +print(f"Created order hint for {len(order_hint.id_list)} wiki pages") + +# Update order hint +order_hint.id_list = ["633084", "633085", "633086", "633087", "633088"] # Reorder +order_hint.store() + +# Delete a wiki page +wiki_page_to_delete = WikiPage( + owner_id=my_test_project.id, id=wiki_with_attachments.id +).delete() + +# clean up +my_test_project.delete() diff --git a/docs/tutorials/python/wiki.md b/docs/tutorials/python/wiki.md index 7969eaa0c..bc6568e72 100644 --- a/docs/tutorials/python/wiki.md +++ b/docs/tutorials/python/wiki.md @@ -1,2 +1,142 @@ # 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 + +## Basic Setup +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=13-28} +``` + +## 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=32-36} +``` + +### OR you can create a wiki page with an existing markdown file +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=39-44} +``` + +### Create a new wiki page with updated content +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=47-52} +``` + +### Restore the wiki page to the original version +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=55-63} +``` + +### Create a sub-wiki page +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=66-71} +``` + +### 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=74-75} +``` + +### 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=78-79} +``` + +#### Retrieve a Wiki Page with wiki page title +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=82-83} +``` + +#### Check if the retrieved wiki page is the same as the original wiki page +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=86-91} +``` + +## 2. WikiPage Markdown Operations +### Create wiki page from markdown text +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=95-118} +``` + +### Create wiki page from a markdown file +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=122-132} +``` + +### Download the markdown file +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=136-143} +``` + +## 3. WikiPage Attachments Operations +### Create a wiki page with attachments +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=147-161} +``` +### Get the file handles of all attachments on this wiki page. +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=164-165} +``` +### Download an attachment +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=168-175} +``` + +### Get attachment URL without downloading +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=178-182} +``` + +### Download an attachment preview (WIP) +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=185-191} +``` +#### Get attachment preview URL without downloading (WIP) + + +## 4. WikiHeader - Working with Wiki Hierarchy +### Getting Wiki Header Tree +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=197-200} +``` + +## 5. WikiHistorySnapshot - Version History + +### Accessing Wiki History +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=203-207} +``` + +## 6. WikiOrderHint - Managing Wiki Order +### Get wiki order hint (No id_list returned, same result getting from direct endpoint calls) +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=212-213} +``` +### Update wiki order hint +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=216-222} +``` + +### 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=225} +``` + +## clean up +```python +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=228} +``` From da4dfa4a5415ff6198665f22afa78839690e635e Mon Sep 17 00:00:00 2001 From: danlu1 Date: Mon, 23 Jun 2025 14:15:55 -0700 Subject: [PATCH 20/42] remove unwanted module name --- synapseclient/models/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/synapseclient/models/__init__.py b/synapseclient/models/__init__.py index 1cee0028e..b8efc2d33 100644 --- a/synapseclient/models/__init__.py +++ b/synapseclient/models/__init__.py @@ -42,7 +42,6 @@ WikiHistorySnapshot, WikiOrderHint, WikiPage, - WikiPageHistory, ) __all__ = [ @@ -100,7 +99,6 @@ "WikiOrderHint", "WikiHistorySnapshot", "WikiHeader", - "WikiPageHistory", ] # Static methods to expose as functions From be5aae481e2fcd7fb9af605b133b30db43d8dd79 Mon Sep 17 00:00:00 2001 From: danlu1 Date: Mon, 23 Jun 2025 14:20:10 -0700 Subject: [PATCH 21/42] update typing hint --- synapseclient/models/wiki.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/synapseclient/models/wiki.py b/synapseclient/models/wiki.py index 2a2db5ee9..b82f5bda3 100644 --- a/synapseclient/models/wiki.py +++ b/synapseclient/models/wiki.py @@ -770,7 +770,7 @@ async def get_attachment_handles_async( self, *, synapse_client: Optional["Synapse"] = None, - ) -> list: + ) -> List[Dict[str, Any]]: """ Get the file handles of all attachments on this wiki page. From 1ab988833f64bf7176267a2c3351e8a1bfc030b0 Mon Sep 17 00:00:00 2001 From: danlu1 Date: Mon, 23 Jun 2025 14:31:14 -0700 Subject: [PATCH 22/42] make sure gzip file always be removed after getting the file handle id --- synapseclient/models/wiki.py | 59 +++++++++++++++++++----------------- 1 file changed, 31 insertions(+), 28 deletions(-) diff --git a/synapseclient/models/wiki.py b/synapseclient/models/wiki.py index b82f5bda3..73020e30a 100644 --- a/synapseclient/models/wiki.py +++ b/synapseclient/models/wiki.py @@ -526,44 +526,47 @@ async def store_async( file_path = self._to_gzip_file( wiki_content=self.markdown, synapse_client=client ) - # Upload the gzipped file to get a file handle - file_handle = await upload_file_handle( - syn=client, - parent_entity_id=self.owner_id, - path=file_path, - ) - - client.logger.info( - f"Uploaded file handle {file_handle.get('id')} for wiki page markdown." - ) - # delete the temp gzip file - os.remove(file_path) - client.logger.debug(f"Deleted temp gzip file {file_path}") - - # Set the markdown file handle ID from the upload response - self.markdown_file_handle_id = file_handle.get("id") - - # Convert attachments to gzipped file if needed - if self.attachments: - file_handles = [] - for attachment in self.attachments: - file_path = self._to_gzip_file( - wiki_content=attachment, synapse_client=client - ) + try: + # Upload the gzipped file to get a file handle file_handle = await upload_file_handle( syn=client, parent_entity_id=self.owner_id, path=file_path, ) - file_handles.append(file_handle.get("id")) + client.logger.info( - f"Uploaded file handle {file_handle.get('id')} for wiki page attachment." + 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 gzip file + os.remove(file_path) + client.logger.debug(f"Deleted temp gzip file {file_path}") + + # Convert attachments to gzipped file if needed + if self.attachments: + try: + file_handles = [] + for attachment in self.attachments: + file_path = self._to_gzip_file( + wiki_content=attachment, synapse_client=client + ) + file_handle = await upload_file_handle( + syn=client, + parent_entity_id=self.owner_id, + path=file_path, + ) + file_handles.append(file_handle.get("id")) + client.logger.info( + f"Uploaded file handle {file_handle.get('id')} for wiki page attachment." + ) + # Set the attachment file handle IDs from the upload response + self.attachment_file_handle_ids = file_handles + finally: # delete the temp gzip file os.remove(file_path) client.logger.debug(f"Deleted temp gzip file {file_path}") - # Set the attachment file handle IDs from the upload response - self.attachment_file_handle_ids = file_handles # Handle root wiki page creation if parent_id is not given if not self.parent_id: From 082df854dd62d16b08671036db1a096e6c70f89d Mon Sep 17 00:00:00 2001 From: danlu1 Date: Tue, 24 Jun 2025 09:41:21 -0700 Subject: [PATCH 23/42] update filename for markdown gzip file --- synapseclient/models/wiki.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/synapseclient/models/wiki.py b/synapseclient/models/wiki.py index 73020e30a..83dbb3f93 100644 --- a/synapseclient/models/wiki.py +++ b/synapseclient/models/wiki.py @@ -3,7 +3,6 @@ import gzip import os import shutil -import uuid from dataclasses import dataclass, field from typing import Any, Dict, List, Optional, Union @@ -472,7 +471,7 @@ def _to_gzip_file( 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_{uuid.uuid4()}.md.gz") + file_path = os.path.join(cache_dir, f"wiki_markdown_{self.id}.md.gz") with gzip.open(file_path, "wt", encoding="utf-8") as f_out: f_out.write(wiki_content) From 43bb9a068b0cb5a4bc72831f0b247cd79bb62a1a Mon Sep 17 00:00:00 2001 From: danlu1 Date: Tue, 24 Jun 2025 09:46:33 -0700 Subject: [PATCH 24/42] update tutorials --- .../tutorials/python/tutorial_scripts/wiki.py | 27 ++++++------ docs/tutorials/python/wiki.md | 42 ++++++++++++------- 2 files changed, 40 insertions(+), 29 deletions(-) diff --git a/docs/tutorials/python/tutorial_scripts/wiki.py b/docs/tutorials/python/tutorial_scripts/wiki.py index 1e68af37b..7f1eb1894 100644 --- a/docs/tutorials/python/tutorial_scripts/wiki.py +++ b/docs/tutorials/python/tutorial_scripts/wiki.py @@ -3,12 +3,13 @@ Tutorial script demonstrating the Synapse Wiki models functionality. This script shows how to: -1. -2. Work with wiki headers and hierarchy -3. Access wiki history -4. Manage wiki order hints -5. Handle attachments and markdown content -6. Download wiki content and attachments +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 @@ -119,7 +120,7 @@ def hello_world(): """ # Create wiki page from markdown text -markdown_wiki = WikiPage( +markdown_wiki_1 = WikiPage( owner_id=my_test_project.id, parent_id=wiki_page_1.id, title="Sub Page 2 created from markdown text", @@ -133,7 +134,7 @@ def hello_world(): gz.write("This is a markdown file") # Create wiki page from markdown file -markdown_wiki = WikiPage( +markdown_wiki_2 = WikiPage( owner_id=my_test_project.id, parent_id=wiki_page_1.id, title="Sub Page 3 created from markdown file", @@ -141,13 +142,13 @@ def hello_world(): ).store() # Download the markdown file -# delete the markdown file after downloading --> check if the file is downloaded +# delete the markdown file after downloading os.remove(markdown_file_path) -markdown_file = WikiPage(owner_id=my_test_project.id, id=markdown_wiki.id).get_markdown( - download_file=True, download_location=".", download_file_name="markdown_file.md" -) +markdown_file_2 = WikiPage( + owner_id=my_test_project.id, id=markdown_wiki_2.id +).get_markdown(download_file=True, download_location=".") -print(f"Markdown file downloaded to: {markdown_file}") +print(f"Markdown file downloaded to: {markdown_file_2}") # Section 3: WikiPage with Attachments # Create a temporary file for the attachment diff --git a/docs/tutorials/python/wiki.md b/docs/tutorials/python/wiki.md index bc6568e72..f88572d09 100644 --- a/docs/tutorials/python/wiki.md +++ b/docs/tutorials/python/wiki.md @@ -12,73 +12,83 @@ The Synapse Wiki models include: - **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=13-28} +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=14-29} ``` ## 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=32-36} +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=33-37} ``` ### OR you can create a wiki page with an existing markdown file ```python -{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=39-44} +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=40-45} ``` ### Create a new wiki page with updated content ```python -{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=47-52} +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=48-53} ``` ### Restore the wiki page to the original version ```python -{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=55-63} +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=56-64} ``` ### Create a sub-wiki page ```python -{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=66-71} +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=67-72} ``` ### 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=74-75} +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=75-76} ``` ### 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=78-79} +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=79-80} ``` #### Retrieve a Wiki Page with wiki page title ```python -{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=82-83} +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=83-84} ``` #### Check if the retrieved wiki page is the same as the original wiki page ```python -{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=86-91} +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=87-92} ``` ## 2. WikiPage Markdown Operations ### Create wiki page from markdown text ```python -{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=95-118} +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=96-119} ``` -### Create wiki page from a markdown file +### Create wiki page from a markdown file ```python -{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=122-132} +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=123-133} ``` ### 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=136-143} +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=137-143} ``` ## 3. WikiPage Attachments Operations @@ -92,7 +102,7 @@ Note: You need to know the wiki page ID or wiki page title to retrieve it ``` ### Download an attachment ```python -{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=168-175} +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=167-175} ``` ### Get attachment URL without downloading @@ -117,7 +127,7 @@ Note: You need to know the wiki page ID or wiki page title to retrieve it ### Accessing Wiki History ```python -{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=203-207} +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=204-208} ``` ## 6. WikiOrderHint - Managing Wiki Order From 0a5f2e606fc0c55da3e3d32f6a9f2d477e5a579d Mon Sep 17 00:00:00 2001 From: danlu1 Date: Tue, 24 Jun 2025 15:28:14 -0700 Subject: [PATCH 25/42] update markdown name if it's created from text to make it more informative --- synapseclient/models/wiki.py | 41 ++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/synapseclient/models/wiki.py b/synapseclient/models/wiki.py index 83dbb3f93..6d44cf0fb 100644 --- a/synapseclient/models/wiki.py +++ b/synapseclient/models/wiki.py @@ -98,59 +98,59 @@ def to_synapse_request(self) -> Dict[str, List[str]]: return result @otel_trace_method( - method_to_trace_name=lambda self, **kwargs: f"Get_Wiki_Order_Hint: {self.owner_id}" + method_to_trace_name=lambda self, **kwargs: f"Store_Wiki_Order_Hint: {self.owner_id}" ) - async def get_async( + async def store_async( self, *, synapse_client: Optional["Synapse"] = None, ) -> "WikiOrderHint": """ - Get the order hint of a wiki page tree. + Store the order hint of a wiki page tree. Arguments: synapse_client: Optionally provide a Synapse client. Returns: - A WikiOrderHint object for the entity. + The updated WikiOrderHint object for the entity. Raises: - ValueError: If owner_id is not provided. + ValueError: If owner_id or request 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( + 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, ) - return self.fill_from_dict(order_hint_dict) + self.fill_from_dict(order_hint_dict) + return self @otel_trace_method( - method_to_trace_name=lambda self, **kwargs: f"Update_Wiki_Order_Hint: {self.owner_id}" + method_to_trace_name=lambda self, **kwargs: f"Get_Wiki_Order_Hint: {self.owner_id}" ) - async def store_async( + async def get_async( self, *, synapse_client: Optional["Synapse"] = None, ) -> "WikiOrderHint": """ - Store the order hint of a wiki page tree. + Get the order hint of a wiki page tree. Arguments: synapse_client: Optionally provide a Synapse client. Returns: - The updated WikiOrderHint object for the entity. + A WikiOrderHint object for the entity. Raises: - ValueError: If owner_id or request is not provided. + ValueError: If owner_id 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( + raise ValueError("Must provide owner_id to get wiki order hint.") + order_hint_dict = await get_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 + return self.fill_from_dict(order_hint_dict) @dataclass @@ -471,7 +471,7 @@ def _to_gzip_file( 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.id}.md.gz") + 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) @@ -970,6 +970,7 @@ async def get_markdown_async( 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. redirect: When set to false, the URL will be returned as text/plain instead of redirecting. Default is False. From c6077e02739637f1a50d3a8c5c5c4f73118385c5 Mon Sep 17 00:00:00 2001 From: danlu1 Date: Tue, 24 Jun 2025 15:28:47 -0700 Subject: [PATCH 26/42] reorder functions for order hint --- .../models/protocols/wikipage_protocol.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/synapseclient/models/protocols/wikipage_protocol.py b/synapseclient/models/protocols/wikipage_protocol.py index 0785ddaef..e9a3900b4 100644 --- a/synapseclient/models/protocols/wikipage_protocol.py +++ b/synapseclient/models/protocols/wikipage_protocol.py @@ -20,31 +20,31 @@ class WikiOrderHintSynchronousProtocol(Protocol): """Protocol for the methods of the WikiOrderHint class that have synchronous counterparts generated at runtime.""" - def get( + def store( self, *, - synapse_client: Optional[Synapse] = None, + synapse_client: Optional["Synapse"] = None, ) -> "WikiOrderHint": """ - Get the order hint of a wiki page tree. + Update the order hint of a wiki page tree. Arguments: synapse_client: Optionally provide a Synapse client. Returns: - A WikiOrderHint object for the entity. + The updated WikiOrderHint object for the entity. """ return self - def store( + def get( self, *, - synapse_client: Optional["Synapse"] = None, + synapse_client: Optional[Synapse] = None, ) -> "WikiOrderHint": """ - Update the order hint of a wiki page tree. + Get the order hint of a wiki page tree. Arguments: synapse_client: Optionally provide a Synapse client. Returns: - The updated WikiOrderHint object for the entity. + A WikiOrderHint object for the entity. """ return self From cef775b71c0c238783c70ea6ae2446807ab1c428 Mon Sep 17 00:00:00 2001 From: danlu1 Date: Tue, 24 Jun 2025 15:43:55 -0700 Subject: [PATCH 27/42] update wikipage name and instructions --- .../tutorials/python/tutorial_scripts/wiki.py | 172 +++++++++++------- 1 file changed, 106 insertions(+), 66 deletions(-) diff --git a/docs/tutorials/python/tutorial_scripts/wiki.py b/docs/tutorials/python/tutorial_scripts/wiki.py index 7f1eb1894..7954c1c43 100644 --- a/docs/tutorials/python/tutorial_scripts/wiki.py +++ b/docs/tutorials/python/tutorial_scripts/wiki.py @@ -37,46 +37,47 @@ # Section1: Create, read, and update wiki pages # Create a new wiki page for the project with plain text markdown -wiki_page_1 = WikiPage( +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 +# 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" -wiki_page_1 = WikiPage( +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 -wiki_page_2 = WikiPage( +root_wiki_page_new = WikiPage( owner_id=my_test_project.id, - title="My First Root Wiki Page Version 1", - markdown="# Welcome to My Root Wiki Version 1\n\nThis is a sample root wiki page created with the Synapse client.", - id=wiki_page_1.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=wiki_page_1.id, wiki_version="0" + owner_id=my_test_project.id, id=root_wiki_page.id, wiki_version="0" ).restore() # check if the content is restored comparisons = [ - wiki_page_1.markdown_file_handle_id == wiki_page_restored.markdown_file_handle_id, - wiki_page_1.id == wiki_page_restored.id, - wiki_page_1.title == wiki_page_restored.title, + 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 = WikiPage( +sub_wiki_1 = WikiPage( owner_id=my_test_project.id, title="Sub Wiki Page 1", - parent_id=wiki_page_1.id, # Use the ID of the parent wiki page we created '633033' + parent_id=root_wiki_page.id, # Use the ID of the parent wiki page we created '633033' markdown="# Sub Page 1\n\nThis is a sub-page of another wiki.", ).store() @@ -85,18 +86,18 @@ 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.id).get() +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=wiki_page_1.title).get() +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 = [ - wiki_page_1.markdown_file_handle_id == retrieved_wiki.markdown_file_handle_id, - wiki_page_1.id == retrieved_wiki.id, - wiki_page_1.title == retrieved_wiki.title, + 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)}") @@ -120,9 +121,9 @@ def hello_world(): """ # Create wiki page from markdown text -markdown_wiki_1 = WikiPage( +sub_wiki_2 = WikiPage( owner_id=my_test_project.id, - parent_id=wiki_page_1.id, + parent_id=root_wiki_page.id, title="Sub Page 2 created from markdown text", markdown=markdown_content, ).store() @@ -134,21 +135,42 @@ def hello_world(): gz.write("This is a markdown file") # Create wiki page from markdown file -markdown_wiki_2 = WikiPage( +sub_wiki_3 = WikiPage( owner_id=my_test_project.id, - parent_id=wiki_page_1.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 downloading +# 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) -markdown_file_2 = WikiPage( - owner_id=my_test_project.id, id=markdown_wiki_2.id -).get_markdown(download_file=True, download_location=".") - -print(f"Markdown file downloaded to: {markdown_file_2}") # Section 3: WikiPage with Attachments # Create a temporary file for the attachment @@ -156,13 +178,13 @@ def hello_world(): with gzip.open(attachment_file_name, "wt", encoding="utf-8") as gz: gz.write("This is a sample attachment.") -# reformat the attachment file name to be a valid attachment path +# 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") -wiki_with_attachments = WikiPage( +sub_wiki_4 = WikiPage( owner_id=my_test_project.id, - parent_id=wiki_page_1.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], @@ -170,75 +192,93 @@ def hello_world(): # Get attachment handles attachment_handles = WikiPage( - owner_id=my_test_project.id, id=wiki_with_attachments.id + owner_id=my_test_project.id, id=sub_wiki_4.id ).get_attachment_handles() -print(f"Found {len(attachment_handles['list'])} attachments") +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}") -# Delete the attachment file after uploading --> check if the file is deleted -os.remove(attachment_file_name) # Download an attachment -wiki_page = WikiPage( - owner_id=my_test_project.id, id=wiki_with_attachments.id +# 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) -# Get attachment URL without downloading -wiki_page_url = WikiPage( - owner_id=my_test_project.id, id=wiki_with_attachments.id -).get_attachment( +# 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 URL: {wiki_page_url}") +print(f"Attachment preview URL: {attachment_preview_url}") -# Download an attachment preview--? Failed to download the attachment preview, synapseclient.core.exceptions.SynapseHTTPError: 404 Client Error: Cannot find a wiki attachment for OwnerID: syn68493645, ObjectType: ENTITY, WikiPageId: 633100, fileName: preview.txt -attachment_handles = WikiPage( - owner_id=my_test_project.id, id=wiki_with_attachments.id -).get_attachment_handles() -print(f"Attachment handles: {attachment_handles}") -wiki_page = WikiPage( - owner_id=my_test_project.id, id=wiki_with_attachments.id +# Download an attachment preview +attachment_preview = WikiPage( + owner_id=my_test_project.id, id=sub_wiki_4.id ).get_attachment_preview( - file_name="preview.txt", + 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) -# Note: Uncomment to actually get the header tree 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 -history = WikiHistorySnapshot.get(owner_id=my_test_project.id, id=wiki_page_1.id) - -print(f"Found {len(history)} versions in history for {wiki_page_1.title}") +# 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 -# Get wiki order hint --> failed to get the order hint +# 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.id_list = [wiki_page_1.id] - -print(f"Created order hint for {len(order_hint.id_list)} wiki pages") - -# Update order hint -order_hint.id_list = ["633084", "633085", "633086", "633087", "633088"] # Reorder +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=wiki_with_attachments.id -).delete() +wiki_page_to_delete = WikiPage(owner_id=my_test_project.id, id=sub_wiki_3.id).delete() # clean up my_test_project.delete() From e32da1bd11e3ab58b5fe0a44eeb75bba376d8933 Mon Sep 17 00:00:00 2001 From: danlu1 Date: Tue, 24 Jun 2025 15:54:05 -0700 Subject: [PATCH 28/42] reorder and fill in details for tutorial md --- docs/tutorials/python/wiki.md | 69 +++++++++++++++++++---------------- 1 file changed, 37 insertions(+), 32 deletions(-) diff --git a/docs/tutorials/python/wiki.md b/docs/tutorials/python/wiki.md index f88572d09..17de93209 100644 --- a/docs/tutorials/python/wiki.md +++ b/docs/tutorials/python/wiki.md @@ -23,130 +23,135 @@ This tutorial shows how to: ## Basic Setup ```python -{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=14-29} +{!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=33-37} +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=40-44} ``` -### OR you can create a wiki page with an existing markdown file +### 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=40-45} +{!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=48-53} +{!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=56-64} +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=63-74} ``` ### Create a sub-wiki page ```python -{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=67-72} +{!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=75-76} +{!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=79-80} +{!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=83-84} +{!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=87-92} +{!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=96-119} +{!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=123-133} +{!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. +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=137-143} +{!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=147-161} +{!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=164-165} +{!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=167-175} +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=209-219} ``` -### Get attachment URL without downloading +### 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=178-182} +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=223-229} ``` -### Download an attachment preview (WIP) +#### Download an attachment preview ```python -{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=185-191} +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=232-240} ``` -#### Get attachment preview URL without downloading (WIP) - ## 4. WikiHeader - Working with Wiki Hierarchy ### Getting Wiki Header Tree ```python -{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=197-200} +{!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=204-208} +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=250-251} ``` ## 6. WikiOrderHint - Managing Wiki Order -### Get wiki order hint (No id_list returned, same result getting from direct endpoint calls) +Note: You need to have order hint set before pulling. +### Set the wiki order hint ```python -{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=212-213} +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=255-266} ``` + ### Update wiki order hint ```python -{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=216-222} +{!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=225} +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=281} ``` ## clean up ```python -{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=228} +{!docs/tutorials/python/tutorial_scripts/wiki.py!lines=284} ``` From 50417a37307d738f12baa9c70fec28d829d0e1ac Mon Sep 17 00:00:00 2001 From: danlu1 Date: Tue, 24 Jun 2025 15:57:11 -0700 Subject: [PATCH 29/42] remove unwanted comments --- docs/tutorials/python/tutorial_scripts/wiki.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorials/python/tutorial_scripts/wiki.py b/docs/tutorials/python/tutorial_scripts/wiki.py index 7954c1c43..9f23e4817 100644 --- a/docs/tutorials/python/tutorial_scripts/wiki.py +++ b/docs/tutorials/python/tutorial_scripts/wiki.py @@ -77,7 +77,7 @@ sub_wiki_1 = WikiPage( owner_id=my_test_project.id, title="Sub Wiki Page 1", - parent_id=root_wiki_page.id, # Use the ID of the parent wiki page we created '633033' + parent_id=root_wiki_page.id, markdown="# Sub Page 1\n\nThis is a sub-page of another wiki.", ).store() From d59a9d698df0aa092a87341dc1d653a11a14be93 Mon Sep 17 00:00:00 2001 From: danlu1 Date: Wed, 25 Jun 2025 16:08:36 -0700 Subject: [PATCH 30/42] make low-level functionalities importable from synapseclient.api --- synapseclient/api/__init__.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) 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", ] From a317bc9903825dda2b05231219693b321b7d3c62 Mon Sep 17 00:00:00 2001 From: danlu1 Date: Wed, 25 Jun 2025 16:09:31 -0700 Subject: [PATCH 31/42] rename post_wiki to post_wiki_page --- synapseclient/api/wiki_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/synapseclient/api/wiki_service.py b/synapseclient/api/wiki_service.py index 7de4e55b2..befc79aa5 100644 --- a/synapseclient/api/wiki_service.py +++ b/synapseclient/api/wiki_service.py @@ -9,7 +9,7 @@ from synapseclient import Synapse -async def post_wiki( +async def post_wiki_page( owner_id: str, request: Dict[str, Any], *, From 9ae4c817539442b13f0849a1498d870e971eb0c2 Mon Sep 17 00:00:00 2001 From: danlu1 Date: Wed, 25 Jun 2025 16:28:32 -0700 Subject: [PATCH 32/42] remove debug code --- synapseclient/models/wiki.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/synapseclient/models/wiki.py b/synapseclient/models/wiki.py index 6d44cf0fb..4cc1ea805 100644 --- a/synapseclient/models/wiki.py +++ b/synapseclient/models/wiki.py @@ -7,7 +7,7 @@ from typing import Any, Dict, List, Optional, Union from synapseclient import Synapse -from synapseclient.api.wiki_service import ( +from synapseclient.api import ( delete_wiki_page, get_attachment_handles, get_attachment_preview_url, @@ -17,7 +17,7 @@ get_wiki_history, get_wiki_order_hint, get_wiki_page, - post_wiki, + post_wiki_page, put_wiki_order_hint, put_wiki_page, put_wiki_version, @@ -579,7 +579,7 @@ async def store_async( "No wiki page exists within the owner. Create a new wiki page." ) # Create the wiki page - wiki_data = await post_wiki( + wiki_data = await post_wiki_page( owner_id=self.owner_id, request=self.to_synapse_request(), ) @@ -633,7 +633,7 @@ async def store_async( f"Creating sub-wiki page under parent ID: {self.parent_id}" ) # Create the sub-wiki page directly - wiki_data = await post_wiki( + wiki_data = await post_wiki_page( owner_id=self.owner_id, request=self.to_synapse_request(), synapse_client=client, From d52720cfdf4db799c78070ed09746ae5380bc3d1 Mon Sep 17 00:00:00 2001 From: danlu1 Date: Thu, 26 Jun 2025 17:12:56 -0700 Subject: [PATCH 33/42] remove unwanted decorator --- synapseclient/models/protocols/wikipage_protocol.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/synapseclient/models/protocols/wikipage_protocol.py b/synapseclient/models/protocols/wikipage_protocol.py index e9a3900b4..232a02476 100644 --- a/synapseclient/models/protocols/wikipage_protocol.py +++ b/synapseclient/models/protocols/wikipage_protocol.py @@ -4,7 +4,6 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Protocol, Union from synapseclient import Synapse -from synapseclient.core.async_utils import async_to_sync if TYPE_CHECKING: from synapseclient.models import ( @@ -15,7 +14,6 @@ ) -@async_to_sync class WikiOrderHintSynchronousProtocol(Protocol): """Protocol for the methods of the WikiOrderHint class that have synchronous counterparts generated at runtime.""" @@ -49,7 +47,6 @@ def get( return self -@async_to_sync class WikiHistorySnapshotSynchronousProtocol(Protocol): """Protocol for the methods of the WikiHistorySnapshot class that have synchronous counterparts generated at runtime.""" @@ -78,7 +75,6 @@ def get( return list({}) -@async_to_sync class WikiHeaderSynchronousProtocol(Protocol): """Protocol for the methods of the WikiHeader class that have synchronous counterparts generated at runtime.""" @@ -105,7 +101,6 @@ def get( return list({}) -@async_to_sync class WikiPageSynchronousProtocol(Protocol): """Protocol for the methods of the WikiPage class that have synchronous counterparts generated at runtime.""" From cbcbf35e9a19ee3ff7c2804f009d19843a254f6c Mon Sep 17 00:00:00 2001 From: danlu1 Date: Fri, 27 Jun 2025 09:46:13 -0700 Subject: [PATCH 34/42] refactor wikipage store function to put validation at the beginning and split it int small functions --- synapseclient/models/wiki.py | 312 +++++++++++++++++++++-------------- 1 file changed, 191 insertions(+), 121 deletions(-) diff --git a/synapseclient/models/wiki.py b/synapseclient/models/wiki.py index 4cc1ea805..2ba98dbd8 100644 --- a/synapseclient/models/wiki.py +++ b/synapseclient/models/wiki.py @@ -1,10 +1,10 @@ """Script to work with Synapse wiki pages.""" +import asyncio import gzip import os -import shutil from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Literal, Optional, Union from synapseclient import Synapse from synapseclient.api import ( @@ -31,7 +31,7 @@ ) 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 +from synapseclient.core.utils import delete_none_keys, merge_dataclass_entities from synapseclient.models.protocols.wikipage_protocol import ( WikiHeaderSynchronousProtocol, WikiHistorySnapshotSynchronousProtocol, @@ -47,14 +47,14 @@ 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 ID of the owner object (e.g., entity, evaluation, etc.). + 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 ID of the owner object (e.g., entity, evaluation, etc.).""" + """The Synapse ID of the owner object (e.g., entity, evaluation, etc.).""" owner_object_type: Optional[str] = None """The type of the owner object.""" @@ -198,8 +198,8 @@ def fill_from_dict( ) async def get_async( cls, - owner_id: str, - id: str, + owner_id: str = None, + id: str = None, *, offset: int = 0, limit: int = 20, @@ -209,7 +209,7 @@ async def get_async( Get the history of a wiki page as a list of WikiHistorySnapshot objects. Arguments: - owner_id: The ID of the owner entity. + 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. @@ -278,7 +278,7 @@ def fill_from_dict( ) async def get_async( cls, - owner_id: str, + owner_id: str = None, *, offset: int = 0, limit: int = 20, @@ -288,7 +288,7 @@ async def get_async( Get the header tree (hierarchy) of wiki pages for an entity. Arguments: - owner_id: The ID of the owner entity. + 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. @@ -323,12 +323,12 @@ class WikiPage(WikiPageSynchronousProtocol): 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 ID of the owning object (e.g., entity, evaluation, etc.). + 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. - version_number: The version number of this wiki 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. """ @@ -354,7 +354,7 @@ class WikiPage(WikiPageSynchronousProtocol): """A list of file attachments associated with this page.""" owner_id: Optional[str] = None - """The ID of the owning object (e.g., entity, evaluation, etc.).""" + """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.""" @@ -443,7 +443,7 @@ def _to_gzip_file( synapse_client: The Synapse client to use for cache access. Returns: - The path to the gzipped file. + The path to the gzipped file and the cache directory. """ # check if markdown is a string if not isinstance(wiki_content, str): @@ -455,10 +455,9 @@ def _to_gzip_file( # Check if markdown looks like a file path and exists if os.path.isfile(wiki_content): - # If it's already a gzipped file, save a copy to the cache + # If it's already a gzipped file, use the file path directly if wiki_content.endswith(".gz"): - file_path = os.path.join(cache_dir, os.path.basename(wiki_content)) - shutil.copyfile(wiki_content, file_path) + file_path = wiki_content else: # If it's a regular html or markdown file, compress it with open(wiki_content, "rb") as f_in: @@ -496,139 +495,196 @@ def _get_file_size(filehandle_dict: dict, file_name: str) -> str: ) @otel_trace_method( - method_to_trace_name=lambda self, **kwargs: f"Store the wiki page: {self.owner_id}" + method_to_trace_name=lambda self, **kwargs: f"Get the markdown file handle: {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. - + async def _get_markdown_file_handle(self, synapse_client: Synapse) -> "WikiPage": + """Get the markdown file handle from the synapse client. 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. - + synapse_client: The Synapse client to use for cache access. Returns: - The created wiki page. - - Raises: - ValueError: If owner_id is not provided or if required fields are missing. + A WikiPage with the updated markdown file handle id. """ - client = Synapse.get_client(synapse_client=synapse_client) - if not self.owner_id: - raise ValueError("Must provide owner_id to modify a wiki page.") - # Convert markdown to gzipped file if needed - if self.markdown: + if not self.markdown: + return self + else: file_path = self._to_gzip_file( - wiki_content=self.markdown, synapse_client=client + 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=client, + syn=synapse_client, parent_entity_id=self.owner_id, path=file_path, ) - client.logger.info( + 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 gzip file - os.remove(file_path) - client.logger.debug(f"Deleted temp gzip file {file_path}") + # 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 - # Convert attachments to gzipped file if needed - if self.attachments: - try: - file_handles = [] - for attachment in self.attachments: - file_path = self._to_gzip_file( - wiki_content=attachment, synapse_client=client - ) + @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=client, + syn=synapse_client, parent_entity_id=self.owner_id, path=file_path, ) - file_handles.append(file_handle.get("id")) - client.logger.info( + synapse_client.logger.info( f"Uploaded file handle {file_handle.get('id')} for wiki page attachment." ) - # Set the attachment file handle IDs from the upload response - self.attachment_file_handle_ids = file_handles - finally: - # delete the temp gzip file - os.remove(file_path) - client.logger.debug(f"Deleted temp gzip file {file_path}") + 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 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. + + 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) + if not self.owner_id: + raise ValueError("Must provide owner_id to modify a wiki page.") + + 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: + self = await self._get_markdown_file_handle(synapse_client=synapse_client) + self = await self._get_attachment_file_handles( + synapse_client=synapse_client + ) # Handle root wiki page creation if parent_id is not given - if not self.parent_id: - try: - WikiHeader.get( - owner_id=self.owner_id, - ) - except SynapseHTTPError as e: - if e.response.status_code == 404: - client.logger.debug( - "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')}." - ) - else: - raise e - else: - client.logger.info( - "A wiki page already exists within the owner. Update the existing wiki page." - ) - # Update the existing wiki page - if not (self.id): - raise ValueError("Must provide id to update a wiki page.") - - # retrieve the wiki page - existing_wiki = await get_wiki_page( - owner_id=self.owner_id, - wiki_id=self.id, - wiki_version=self.wiki_version, - ) - # Update existing_wiki with current object's attributes if they are not None - updates = { - k: v - for k, v in { - "id": self.id, - "title": self.title, - "markdown": self.markdown, - "parentWikiId": self.parent_id, - "attachments": self.attachments, - "markdownFileHandleId": self.markdown_file_handle_id, - "attachmentFileHandleIds": self.attachment_file_handle_ids, - }.items() - if v is not None - } - existing_wiki.update(updates) - # update the wiki page - wiki_data = await put_wiki_page( - owner_id=self.owner_id, - wiki_id=self.id, - request=existing_wiki, - ) - client.logger.info( - f"Updated wiki page: {wiki_data.get('title')} with ID: {wiki_data.get('id')}." - ) + 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( + existing_wiki, + self, + fields_to_ignore=[ + "etag", + "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 - else: + if wiki_action == "create_sub_wiki_page": client.logger.info( f"Creating sub-wiki page under parent ID: {self.parent_id}" ) @@ -1016,3 +1072,17 @@ async def get_markdown_async( ) else: return markdown_url + + @classmethod + def from_dict( + cls, synapse_wiki: Dict[str, Union[str, List[str], List[Dict[str, Any]]]] + ) -> "WikiPage": + """Create a new WikiPage instance from a dictionary. + + Arguments: + synapse_wiki: The dictionary containing wiki page data. + + Returns: + A new WikiPage instance filled with the dictionary data. + """ + return cls().fill_from_dict(synapse_wiki) From 5c66880028d0f0a1a1d3e6b0e745accc1b003b94 Mon Sep 17 00:00:00 2001 From: danlu1 Date: Mon, 30 Jun 2025 11:26:10 -0700 Subject: [PATCH 35/42] remove debug statement --- synapseclient/models/wiki.py | 22 +- .../models/async/unit_test_wiki_async.py | 1447 +++++++++++++++++ 2 files changed, 1459 insertions(+), 10 deletions(-) create mode 100644 tests/unit/synapseclient/models/async/unit_test_wiki_async.py diff --git a/synapseclient/models/wiki.py b/synapseclient/models/wiki.py index 2ba98dbd8..70dce463c 100644 --- a/synapseclient/models/wiki.py +++ b/synapseclient/models/wiki.py @@ -517,7 +517,6 @@ async def _get_markdown_file_handle(self, synapse_client: Synapse) -> "WikiPage" 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." ) @@ -585,6 +584,9 @@ async def _determine_wiki_action( 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" @@ -610,6 +612,8 @@ async def store_async( ) -> "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 @@ -623,16 +627,14 @@ async def store_async( ValueError: If owner_id is not provided or if required fields are missing. """ client = Synapse.get_client(synapse_client=synapse_client) - if not self.owner_id: - raise ValueError("Must provide owner_id to modify a wiki page.") 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: - self = await self._get_markdown_file_handle(synapse_client=synapse_client) - self = await self._get_attachment_file_handles( - synapse_client=synapse_client - ) + # 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( @@ -663,8 +665,8 @@ async def store_async( ) # Update existing_wiki with current object's attributes if they are not None updated_wiki = merge_dataclass_entities( - existing_wiki, - self, + source=self, + destination=existing_wiki, fields_to_ignore=[ "etag", "created_on", @@ -922,7 +924,7 @@ async def get_attachment_async( else: # download the file download_from_url_multi_threaded( - presigned_url=presigned_url_info.url, destination=download_location + presigned_url=presigned_url_info, destination=download_location ) client.logger.debug( f"Downloaded file {presigned_url_info.file_name} to {download_location}" 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..e0e4f6f4e --- /dev/null +++ b/tests/unit/synapseclient/models/async/unit_test_wiki_async.py @@ -0,0 +1,1447 @@ +"""Tests for the synapseclient.models.wiki classes.""" +import copy +import os +import uuid +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.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 pytest.raises( + ValueError, match="Must provide owner_id to store wiki order hint." + ): + await order_hint.store_async(synapse_client=self.syn) + + 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: + # WHEN I call `get_async` + # THEN it should raise ValueError + with pytest.raises( + ValueError, match="Must provide owner_id to get wiki order hint." + ): + await self.order_hint.get_async(synapse_client=self.syn) + + +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` without owner_id + # THEN it should raise ValueError + with pytest.raises( + ValueError, match="Must provide owner_id to get wiki history." + ): + await WikiHistorySnapshot.get_async( + id="wiki1", + synapse_client=self.syn, + ) + + async def test_get_async_missing_id(self) -> None: + # WHEN I call `get_async` without id + # THEN it should raise ValueError + with pytest.raises(ValueError, match="Must provide id to get wiki history."): + await WikiHistorySnapshot.get_async( + owner_id="syn123", + synapse_client=self.syn, + ) + + +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` without owner_id + # THEN it should raise ValueError + with pytest.raises( + ValueError, match="Must provide owner_id to get wiki header tree." + ): + await WikiHeader.get_async(synapse_client=self.syn) + + +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", + "parentId": "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": [], + } + + 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() + + # 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 WikiPage object with None values + wiki_page = WikiPage( + id="wiki1", + title="Test Wiki", + markdown="# Test Content", + owner_id="syn123", + parent_id=None, + ) + + # WHEN I call `to_synapse_request` + results = wiki_page.to_synapse_request() + + # THEN the request should not contain None values + assert results == { + "id": "wiki1", + "title": "Test Wiki", + "markdown": "# Test Content", + "ownerId": "syn123", + } + + 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, cache_dir = 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" + assert cache_dir == "/tmp/cache/wiki_content" + + 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, cache_dir = 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 + assert cache_dir == "/tmp/cache/wiki_content" + + 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, cache_dir = 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" + assert cache_dir == "/tmp/cache/wiki_content" + + 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 + self.wiki_page.markdown_file_handle_id = "handle1" + assert results == self.wiki_page + + 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"], + ) + + # 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 self.syn.logger.info.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 + self.wiki_page.attachment_file_handle_ids = expected_attachment_handles + assert results == self.wiki_page + + 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"), + ) as mock_to_gzip_file, patch( + "synapseclient.models.wiki.upload_file_handle", + return_value={"id": "handle1"}, + ) as mock_upload, 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"), + ) as mock_to_gzip_file, patch( + "synapseclient.models.wiki.upload_file_handle", + side_effect=Exception("Upload failed"), + ) as mock_upload, 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: + # Update the wiki_page with file handle ids + self.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(self.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 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( + "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 + self.wiki_page.markdown_file_handle_id = "markdown_file_handle_id" + self.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=self.wiki_page.to_synapse_request(), + ) + + # AND the result should be filled with the response + expected_wiki = self.wiki_page.fill_from_dict(post_api_response) + assert results == expected_wiki + + async def test_store_async_update_existing_wiki_success(self) -> None: + # Update the wiki_page with file handle ids + self.wiki_page.parent_id = None + self.wiki_page.etag = None + self.wiki_page.created_on = None + self.wiki_page.created_by = None + self.wiki_page.modified_on = None + self.wiki_page.modified_by = None + + # AND mock the get_wiki_page response + mock_get_wiki_response = copy.deepcopy(self.api_response) + mock_get_wiki_response["parentId"] = 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 with the expected file handle IDs for markdown + mock_wiki_with_markdown = copy.deepcopy(self.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 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["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 self.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 + self.wiki_page.etag = "etag123" + self.wiki_page.created_on = "2023-01-01T00:00:00.000Z" + self.wiki_page.created_by = "12345" + self.wiki_page.modified_on = "2023-01-02T00:00:00.000Z" + self.wiki_page.modified_by = "12345" + self.wiki_page.markdown_file_handle_id = "markdown_file_handle_id" + self.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=self.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_wiki = self.wiki_page.fill_from_dict(mock_put_wiki_response) + assert results == expected_wiki + + 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 = copy.deepcopy(self.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 + self.wiki_page.markdown_file_handle_id = "markdown_file_handle_id" + self.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=self.wiki_page.to_synapse_request(), + synapse_client=self.syn, + ) + + # AND the result should be filled with the response + expected_wiki = self.wiki_page.fill_from_dict(post_api_response) + assert results == expected_wiki + + @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 pytest.raises(ValueError, match=expected_error): + await wiki_page.restore_async(synapse_client=self.syn) + + async def test_restore_async_success(self) -> None: + 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=self.wiki_page.to_synapse_request(), + 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_id_success(self) -> None: + # GIVEN a WikiPage object with id + wiki = WikiPage( + id="wiki1", + owner_id="syn123", + ) + + # AND a mock response + with patch("synapseclient.api.wiki_service.get_wiki_page") as mock_get_wiki: + mock_get_wiki.return_value = self.api_response + + # WHEN I call `get_async` + result = 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 + assert result.id == "wiki1" + assert result.title == "Test Wiki Page" + + 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}] + + # Create an async generator mock + async def mock_async_generator(): + for item in mock_responses: + yield item + + with patch( + "synapseclient.api.wiki_service.get_wiki_header_tree" + ) as mock_get_header_tree, patch( + "synapseclient.api.wiki_service.get_wiki_page" + ) as mock_get_wiki: + mock_get_header_tree.return_value = mock_async_generator() + mock_get_wiki.return_value = self.api_response + + # WHEN I call `get_async` + result = 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 + assert result.id == "wiki1" + assert result.title == "Test Wiki Page" + + 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(): + for item in mock_responses: + yield item + + with patch( + "synapseclient.api.wiki_service.get_wiki_header_tree" + ) as mock_get_header_tree: + mock_get_header_tree.return_value = mock_async_generator() + + # 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) + + async def test_get_async_missing_owner_id(self) -> None: + # GIVEN a WikiPage object without owner_id + wiki = WikiPage( + id="wiki1", + ) + + # WHEN I call `get_async` + # THEN it should raise ValueError + with pytest.raises( + ValueError, match="Must provide owner_id to get a wiki page." + ): + await wiki.get_async(synapse_client=self.syn) + + async def test_delete_async_success(self) -> None: + # GIVEN a WikiPage object + wiki = WikiPage( + id="wiki1", + owner_id="syn123", + ) + + # WHEN I call `delete_async` + with patch("synapseclient.api.wiki_service.delete_wiki_page") as mock_delete: + await wiki.delete_async(synapse_client=self.syn) + + # THEN the API should be called with correct parameters + mock_delete.assert_called_once_with( + owner_id="syn123", + wiki_id="wiki1", + synapse_client=self.syn, + ) + + async def test_delete_async_missing_owner_id(self) -> None: + # GIVEN a WikiPage object without owner_id + wiki = WikiPage( + id="wiki1", + ) + + # WHEN I call `delete_async` + # THEN it should raise ValueError + with pytest.raises( + ValueError, match="Must provide owner_id to delete a wiki page." + ): + await wiki.delete_async(synapse_client=self.syn) + + async def test_delete_async_missing_id(self) -> None: + # GIVEN a WikiPage object without id + wiki = WikiPage( + owner_id="syn123", + ) + + # WHEN I call `delete_async` + # THEN it should raise ValueError + with pytest.raises(ValueError, match="Must provide id to delete a wiki page."): + await wiki.delete_async(synapse_client=self.syn) + + @patch("synapseclient.api.wiki_service.get_attachment_handles") + async def test_get_attachment_handles_async_success(self, mock_get_handles) -> None: + # GIVEN a WikiPage object + wiki = WikiPage( + id="wiki1", + owner_id="syn123", + ) + + # AND a mock response + mock_handles = [{"id": "handle1", "fileName": "test.txt"}] + mock_get_handles.return_value = mock_handles + + # WHEN I call `get_attachment_handles_async` + result = await wiki.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=None, + synapse_client=self.syn, + ) + + # AND the result should be the handles + assert result == mock_handles + + async def test_get_attachment_handles_async_missing_owner_id(self) -> None: + # GIVEN a WikiPage object without owner_id + wiki = WikiPage( + id="wiki1", + ) + + # WHEN I call `get_attachment_handles_async` + # THEN it should raise ValueError + with pytest.raises( + ValueError, match="Must provide owner_id to get attachment handles." + ): + await wiki.get_attachment_handles_async(synapse_client=self.syn) + + async def test_get_attachment_handles_async_missing_id(self) -> None: + # GIVEN a WikiPage object without id + wiki = WikiPage( + owner_id="syn123", + ) + + # WHEN I call `get_attachment_handles_async` + # THEN it should raise ValueError + with pytest.raises( + ValueError, match="Must provide id to get attachment handles." + ): + await wiki.get_attachment_handles_async(synapse_client=self.syn) + + @patch("synapseclient.api.wiki_service.get_attachment_url") + async def test_get_attachment_async_url_only(self, mock_get_url) -> None: + # GIVEN a WikiPage object + wiki = WikiPage( + id="wiki1", + owner_id="syn123", + ) + + # AND a mock response + mock_url = "https://example.com/attachment.txt" + mock_get_url.return_value = mock_url + + # WHEN I call `get_attachment_async` with download_file=False + result = await wiki.get_attachment_async( + file_name="test.txt", + download_file=False, + synapse_client=self.syn, + ) + + # THEN the API should be called with correct parameters + mock_get_url.assert_called_once_with( + owner_id="syn123", + wiki_id="wiki1", + file_name="test.txt", + wiki_version=None, + redirect=False, + synapse_client=self.syn, + ) + + # AND the result should be the URL + assert result == mock_url + + async def test_get_attachment_async_missing_owner_id(self) -> None: + # GIVEN a WikiPage object without owner_id + wiki = WikiPage( + id="wiki1", + ) + + # WHEN I call `get_attachment_async` + # THEN it should raise ValueError + with pytest.raises( + ValueError, match="Must provide owner_id to get attachment URL." + ): + await wiki.get_attachment_async( + file_name="test.txt", + synapse_client=self.syn, + ) + + async def test_get_attachment_async_missing_id(self) -> None: + # GIVEN a WikiPage object without id + wiki = WikiPage( + owner_id="syn123", + ) + + # WHEN I call `get_attachment_async` + # THEN it should raise ValueError + with pytest.raises(ValueError, match="Must provide id to get attachment URL."): + await wiki.get_attachment_async( + file_name="test.txt", + synapse_client=self.syn, + ) + + async def test_get_attachment_async_missing_file_name(self) -> None: + # GIVEN a WikiPage object + wiki = WikiPage( + id="wiki1", + owner_id="syn123", + ) + + # WHEN I call `get_attachment_async` without file_name + # THEN it should raise ValueError + with pytest.raises( + ValueError, match="Must provide file_name to get attachment URL." + ): + await wiki.get_attachment_async( + file_name="", + synapse_client=self.syn, + ) + + async def test_restore_async_missing_owner_id(self) -> None: + # GIVEN a WikiPage object without owner_id + wiki = WikiPage( + id="wiki1", + wiki_version="1", + ) + + # WHEN I call `restore_async` + # THEN it should raise ValueError + with pytest.raises( + ValueError, match="Must provide owner_id to restore a wiki page." + ): + await wiki.restore_async(synapse_client=self.syn) + + async def test_restore_async_missing_id(self) -> None: + # GIVEN a WikiPage object without id + wiki = WikiPage( + owner_id="syn123", + wiki_version="1", + ) + + # WHEN I call `restore_async` + # THEN it should raise ValueError + with pytest.raises(ValueError, match="Must provide id to restore a wiki page."): + await wiki.restore_async(synapse_client=self.syn) From d8055d637610ad0fa78a6f9da0807bd279afef9b Mon Sep 17 00:00:00 2001 From: danlu1 Date: Mon, 30 Jun 2025 15:59:12 -0700 Subject: [PATCH 36/42] simplify get_async for wikipage --- synapseclient/models/wiki.py | 33 +++++++++++++-------------------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/synapseclient/models/wiki.py b/synapseclient/models/wiki.py index 70dce463c..0663f269a 100644 --- a/synapseclient/models/wiki.py +++ b/synapseclient/models/wiki.py @@ -665,10 +665,9 @@ async def store_async( ) # Update existing_wiki with current object's attributes if they are not None updated_wiki = merge_dataclass_entities( - source=self, - destination=existing_wiki, + source=existing_wiki, + destination=self, fields_to_ignore=[ - "etag", "created_on", "created_by", "modified_on", @@ -762,17 +761,11 @@ async def get_async( """ 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 have an ID, use it directly (TO SIMPLIFY) - elif self.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, - ) - # If we only have a title, find the wiki page with matching title - else: + # 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, @@ -785,14 +778,14 @@ async def get_async( 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=matching_header["id"], - wiki_version=self.wiki_version, - synapse_client=synapse_client, - ) - + 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 From 9d923ff9a443dfd88cf319aab8a2a4af602f8f05 Mon Sep 17 00:00:00 2001 From: danlu1 Date: Thu, 3 Jul 2025 14:23:17 -0700 Subject: [PATCH 37/42] remove redirect and reorganize args and kwargs --- synapseclient/api/wiki_service.py | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/synapseclient/api/wiki_service.py b/synapseclient/api/wiki_service.py index befc79aa5..595acb5b9 100644 --- a/synapseclient/api/wiki_service.py +++ b/synapseclient/api/wiki_service.py @@ -39,9 +39,9 @@ async def post_wiki_page( 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. @@ -242,8 +242,8 @@ async def get_wiki_history( 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. @@ -279,9 +279,8 @@ async def get_attachment_url( owner_id: str, wiki_id: str, file_name: str, - *, - redirect: Optional[bool] = False, wiki_version: Optional[int] = None, + *, synapse_client: Optional["Synapse"] = None, ) -> Dict[str, Any]: """Get the URL of a wiki page attachment. @@ -292,7 +291,6 @@ async def get_attachment_url( 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. - redirect: When set to false, the URL will be returned as text/plain instead of redirecting. Default is False. 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 @@ -305,13 +303,11 @@ async def get_attachment_url( client = Synapse.get_client(synapse_client=synapse_client) - # Add version and redirect as a query parameter if provided + # Add version as a query parameter if provided params = {} params["fileName"] = file_name if wiki_version is not None: params["wikiVersion"] = wiki_version - if redirect is not None: - params["redirect"] = redirect return await client.rest_get_async( uri=f"/entity/{owner_id}/wiki2/{wiki_id}/attachment", @@ -323,7 +319,6 @@ async def get_attachment_preview_url( owner_id: str, wiki_id: str, file_name: str, - redirect: Optional[bool] = False, wiki_version: Optional[int] = None, *, synapse_client: Optional["Synapse"] = None, @@ -336,7 +331,6 @@ async def get_attachment_preview_url( 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. - redirect: When set to false, the URL will be returned as text/plain instead of redirecting. Default is False. 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 @@ -349,11 +343,9 @@ async def get_attachment_preview_url( client = Synapse.get_client(synapse_client=synapse_client) - # Add version and redirect as a query parameter if provided + # Add version as a query parameter if provided params = {} params["fileName"] = file_name - if redirect is not None: - params["redirect"] = redirect if wiki_version is not None: params["wikiVersion"] = wiki_version @@ -366,7 +358,6 @@ async def get_attachment_preview_url( async def get_markdown_url( owner_id: str, wiki_id: str, - redirect: Optional[bool] = False, wiki_version: Optional[int] = None, *, synapse_client: Optional["Synapse"] = None, @@ -377,7 +368,6 @@ async def get_markdown_url( Arguments: owner_id: The ID of the owner entity. wiki_id: The ID of the wiki. - redirect: When set to false, the URL will be returned as text/plain instead of redirecting. Default is False. 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 @@ -390,10 +380,8 @@ async def get_markdown_url( client = Synapse.get_client(synapse_client=synapse_client) - # Add version and redirect as a query parameter if provided + # Add version as a query parameter if provided params = {} - if redirect is not None: - params["redirect"] = redirect if wiki_version is not None: params["wikiVersion"] = wiki_version From b36223063461861e8c88068886bfaaadbacddedb Mon Sep 17 00:00:00 2001 From: danlu1 Date: Tue, 8 Jul 2025 22:37:20 -0700 Subject: [PATCH 38/42] set default values for get_wiki_history --- synapseclient/api/wiki_service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/synapseclient/api/wiki_service.py b/synapseclient/api/wiki_service.py index 595acb5b9..ef298f419 100644 --- a/synapseclient/api/wiki_service.py +++ b/synapseclient/api/wiki_service.py @@ -202,8 +202,8 @@ async def get_wiki_header_tree( async def get_wiki_history( - owner_id: str, - wiki_id: str, + owner_id: str = None, + wiki_id: str = None, offset: Optional[int] = 0, limit: Optional[int] = 20, *, From 02b509aef22ad3833b9f58737743e9499cf70ab0 Mon Sep 17 00:00:00 2001 From: danlu1 Date: Wed, 9 Jul 2025 09:40:28 -0700 Subject: [PATCH 39/42] add async and sync unit test for wiki model --- .../models/async/unit_test_wiki_async.py | 1020 ++++++++--- .../models/synchronous/unit_test_wiki.py | 1558 +++++++++++++++++ 2 files changed, 2313 insertions(+), 265 deletions(-) create mode 100644 tests/unit/synapseclient/models/synchronous/unit_test_wiki.py diff --git a/tests/unit/synapseclient/models/async/unit_test_wiki_async.py b/tests/unit/synapseclient/models/async/unit_test_wiki_async.py index e0e4f6f4e..30ce165db 100644 --- a/tests/unit/synapseclient/models/async/unit_test_wiki_async.py +++ b/tests/unit/synapseclient/models/async/unit_test_wiki_async.py @@ -1,13 +1,12 @@ """Tests for the synapseclient.models.wiki classes.""" import copy -import os -import uuid 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, @@ -96,10 +95,16 @@ async def test_store_async_missing_owner_id(self) -> None: # WHEN I call `store_async` # THEN it should raise ValueError - with pytest.raises( + 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` @@ -120,12 +125,18 @@ async def test_get_async_success(self) -> None: 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 pytest.raises( + 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: @@ -225,24 +236,34 @@ async def mock_async_generator( assert results == history_snapshot_list async def test_get_async_missing_owner_id(self) -> None: - # WHEN I call `get_async` without owner_id - # THEN it should raise ValueError - with pytest.raises( + # 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` without id - # THEN it should raise ValueError - with pytest.raises(ValueError, match="Must provide id to get wiki history."): + # 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: @@ -321,12 +342,16 @@ async def mock_async_generator( assert results == wiki_header_list async def test_get_async_missing_owner_id(self) -> None: - # WHEN I call `get_async` without owner_id + # WHEN I call `get_async` # THEN it should raise ValueError - with pytest.raises( + 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(synapse_client=self.syn) + 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: @@ -357,7 +382,7 @@ def init_syn(self, syn: Synapse) -> None: "id": "wiki1", "etag": "etag123", "title": "Test Wiki Page", - "parentId": "parent_wiki", + "parentWikiId": "parent_wiki", "markdown": "# Test markdown text", "attachments": ["test_1.txt", "test_2.txt"], "ownerId": "syn123", @@ -370,6 +395,10 @@ def init_syn(self, syn: Synapse) -> 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) @@ -380,30 +409,19 @@ async def test_fill_from_dict(self) -> None: 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 == self.api_response + assert results == expected_results def test_to_synapse_request_with_none_values(self) -> None: - # GIVEN a WikiPage object with None values - wiki_page = WikiPage( - id="wiki1", - title="Test Wiki", - markdown="# Test Content", - owner_id="syn123", - parent_id=None, - ) - # WHEN I call `to_synapse_request` - results = wiki_page.to_synapse_request() - + results = self.wiki_page.to_synapse_request() # THEN the request should not contain None values - assert results == { - "id": "wiki1", - "title": "Test Wiki", - "markdown": "# Test Content", - "ownerId": "syn123", - } + 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" @@ -412,26 +430,20 @@ def test_to_gzip_file_with_string_content(self) -> None: 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, cache_dir = self.wiki_page._to_gzip_file( - self.wiki_page.markdown, self.syn - ) + 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" - assert cache_dir == "/tmp/cache/wiki_content" 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, cache_dir = self.wiki_page._to_gzip_file( - markdown_file_path, self.syn - ) + 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 - assert cache_dir == "/tmp/cache/wiki_content" def test_to_gzip_file_with_non_gzipped_file(self) -> None: self.syn.cache.cache_root_dir = "/tmp/cache" @@ -440,13 +452,10 @@ def test_to_gzip_file_with_non_gzipped_file(self) -> None: 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, cache_dir = self.wiki_page._to_gzip_file( - "/path/to/test.txt", self.syn - ) + 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" - assert cache_dir == "/tmp/cache/wiki_content" def test_to_gzip_file_with_invalid_content(self) -> None: # WHEN I call `_to_gzip_file` with invalid content type @@ -525,8 +534,9 @@ async def test_get_markdown_file_handle_success_with_markdown(self) -> WikiPage: assert mock_remove.call_count == 1 # AND the result should be filled with the response - self.wiki_page.markdown_file_handle_id = "handle1" - assert results == self.wiki_page + 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 @@ -535,13 +545,13 @@ async def test_get_markdown_file_handle_no_markdown(self) -> WikiPage: 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) - # 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 self.syn.logger.info.call_count == 0 - assert results == wiki_page + # 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, @@ -573,7 +583,6 @@ async def test_get_attachment_file_handles_success_multiple_attachments( 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( @@ -618,8 +627,9 @@ async def test_get_attachment_file_handles_success_multiple_attachments( assert results.attachment_file_handle_ids == expected_attachment_handles # AND the result should be the updated WikiPage - self.wiki_page.attachment_file_handle_ids = expected_attachment_handles - assert results == self.wiki_page + 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 @@ -706,10 +716,10 @@ async def test_get_attachment_file_handles_cache_dir_not_exists(self) -> WikiPag with patch( "synapseclient.models.wiki.WikiPage._to_gzip_file", return_value=("/tmp/cache/test_1.txt.gz"), - ) as mock_to_gzip_file, patch( + ), patch( "synapseclient.models.wiki.upload_file_handle", return_value={"id": "handle1"}, - ) as mock_upload, patch( + ), patch( "os.path.exists", return_value=False ), patch.object( self.syn.logger, "debug" @@ -744,10 +754,10 @@ async def test_get_attachment_file_handles_upload_failure(self) -> WikiPage: with patch( "synapseclient.models.wiki.WikiPage._to_gzip_file", return_value=("/tmp/cache/test_1.txt.gz"), - ) as mock_to_gzip_file, patch( + ), patch( "synapseclient.models.wiki.upload_file_handle", side_effect=Exception("Upload failed"), - ) as mock_upload, patch( + ), patch( "os.path.exists", return_value=True ), patch.object( self.syn.logger, "debug" @@ -857,8 +867,9 @@ async def test_determine_wiki_action_update_existing_without_passing_id( mock_get_header.assert_called_once_with(owner_id="syn123") async def test_store_async_new_root_wiki_success(self) -> None: - # Update the wiki_page with file handle ids - self.wiki_page.parent_id = 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) @@ -870,7 +881,7 @@ async def test_store_async_new_root_wiki_success(self) -> None: ] # Create mock WikiPage objects with the expected file handle IDs for markdown - mock_wiki_with_markdown = copy.deepcopy(self.wiki_page) + 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 @@ -897,7 +908,7 @@ async def test_store_async_new_root_wiki_success(self) -> None: ) as mock_logger: # WHEN I call `store_async` - results = await self.wiki_page.store_async(synapse_client=self.syn) + results = await new_wiki_page.store_async(synapse_client=self.syn) # THEN log messages should be printed assert mock_logger.call_count == 2 @@ -912,8 +923,8 @@ async def test_store_async_new_root_wiki_success(self) -> None: ] ) # Update the wiki_page with file handle ids for validation - self.wiki_page.markdown_file_handle_id = "markdown_file_handle_id" - self.wiki_page.attachment_file_handle_ids = [ + 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", ] @@ -921,32 +932,31 @@ async def test_store_async_new_root_wiki_success(self) -> None: # AND the wiki should be created mock_post_wiki.assert_called_once_with( owner_id="syn123", - request=self.wiki_page.to_synapse_request(), + request=new_wiki_page.to_synapse_request(), ) - # AND the result should be filled with the response - expected_wiki = self.wiki_page.fill_from_dict(post_api_response) - assert results == expected_wiki + 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: - # Update the wiki_page with file handle ids - self.wiki_page.parent_id = None - self.wiki_page.etag = None - self.wiki_page.created_on = None - self.wiki_page.created_by = None - self.wiki_page.modified_on = None - self.wiki_page.modified_by = 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["parentId"] = None + 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 with the expected file handle IDs for markdown - mock_wiki_with_markdown = copy.deepcopy(self.wiki_page) + # 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 @@ -959,6 +969,7 @@ async def test_store_async_update_existing_wiki_success(self) -> None: # 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"] = [ @@ -986,7 +997,7 @@ async def test_store_async_update_existing_wiki_success(self) -> None: self.syn.logger, "info" ) as mock_logger: # WHEN I call `store_async` - results = await self.wiki_page.store_async(synapse_client=self.syn) + 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", @@ -995,20 +1006,20 @@ async def test_store_async_update_existing_wiki_success(self) -> None: ) # AND the wiki should be updated after merging dataclass objects - self.wiki_page.etag = "etag123" - self.wiki_page.created_on = "2023-01-01T00:00:00.000Z" - self.wiki_page.created_by = "12345" - self.wiki_page.modified_on = "2023-01-02T00:00:00.000Z" - self.wiki_page.modified_by = "12345" - self.wiki_page.markdown_file_handle_id = "markdown_file_handle_id" - self.wiki_page.attachment_file_handle_ids = [ + 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=self.wiki_page.to_synapse_request(), + request=new_wiki_page.to_synapse_request(), ) # AND log messages should be printed @@ -1024,8 +1035,8 @@ async def test_store_async_update_existing_wiki_success(self) -> None: ] ) # AND the result should be filled with the response - expected_wiki = self.wiki_page.fill_from_dict(mock_put_wiki_response) - assert results == expected_wiki + 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 @@ -1037,7 +1048,7 @@ async def test_store_async_create_sub_wiki_success(self) -> None: ] # Create mock WikiPage objects with the expected file handle IDs for markdown - mock_wiki_with_markdown = copy.deepcopy(self.wiki_page) + 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 @@ -1077,8 +1088,9 @@ async def test_store_async_create_sub_wiki_success(self) -> None: ) # Update the wiki_page with file handle ids for validation - self.wiki_page.markdown_file_handle_id = "markdown_file_handle_id" - self.wiki_page.attachment_file_handle_ids = [ + 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", ] @@ -1086,13 +1098,13 @@ async def test_store_async_create_sub_wiki_success(self) -> None: # AND the wiki should be created mock_post_wiki.assert_called_once_with( owner_id="syn123", - request=self.wiki_page.to_synapse_request(), + request=new_wiki_page.to_synapse_request(), synapse_client=self.syn, ) # AND the result should be filled with the response - expected_wiki = self.wiki_page.fill_from_dict(post_api_response) - assert results == expected_wiki + expected_results = new_wiki_page.fill_from_dict(post_api_response) + assert results == expected_results @pytest.mark.parametrize( "wiki_page, expected_error", @@ -1116,26 +1128,33 @@ async def test_restore_async_missing_required_parameters( ) -> None: # WHEN I call `restore_async` # THEN it should raise ValueError - with pytest.raises(ValueError, match=expected_error): + 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=self.wiki_page.to_synapse_request(), + request=new_wiki_page.to_synapse_request(), 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 + 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 @@ -1145,11 +1164,11 @@ async def test_get_async_by_id_success(self) -> None: ) # AND a mock response - with patch("synapseclient.api.wiki_service.get_wiki_page") as mock_get_wiki: + 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` - result = await wiki.get_async(synapse_client=self.syn) + 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( @@ -1160,8 +1179,8 @@ async def test_get_async_by_id_success(self) -> None: ) # AND the result should be filled with the response - assert result.id == "wiki1" - assert result.title == "Test Wiki Page" + 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 @@ -1171,23 +1190,24 @@ async def test_get_async_by_title_success(self) -> None: ) # AND mock responses - mock_responses = [{"id": "wiki1", "title": "Test Wiki", "parentId": None}] + 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(): - for item in mock_responses: + async def mock_async_generator(values): + for item in values: yield item with patch( - "synapseclient.api.wiki_service.get_wiki_header_tree" + "synapseclient.models.wiki.get_wiki_header_tree", + return_value=mock_async_generator(mock_responses), ) as mock_get_header_tree, patch( - "synapseclient.api.wiki_service.get_wiki_page" + "synapseclient.models.wiki.get_wiki_page", return_value=self.api_response ) as mock_get_wiki: - mock_get_header_tree.return_value = mock_async_generator() - mock_get_wiki.return_value = self.api_response - # WHEN I call `get_async` - result = await wiki.get_async(synapse_client=self.syn) + results = await wiki.get_async(synapse_client=self.syn) # THEN the header tree should be retrieved mock_get_header_tree.assert_called_once_with( @@ -1204,8 +1224,8 @@ async def mock_async_generator(): ) # AND the result should be filled with the response - assert result.id == "wiki1" - assert result.title == "Test Wiki Page" + 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 @@ -1218,230 +1238,700 @@ async def test_get_async_by_title_not_found(self) -> None: mock_responses = [{"id": "wiki1", "title": "Different Wiki", "parentId": None}] # Create an async generator mock - async def mock_async_generator(): - for item in mock_responses: + async def mock_async_generator(values): + for item in values: yield item with patch( - "synapseclient.api.wiki_service.get_wiki_header_tree" + "synapseclient.models.wiki.get_wiki_header_tree", + return_value=mock_async_generator(mock_responses), ) as mock_get_header_tree: - mock_get_header_tree.return_value = mock_async_generator() - # 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, + ) - async def test_get_async_missing_owner_id(self) -> None: - # GIVEN a WikiPage object without owner_id - wiki = WikiPage( - id="wiki1", - ) - - # WHEN I call `get_async` + @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 pytest.raises( - ValueError, match="Must provide owner_id to get a wiki page." - ): - await wiki.get_async(synapse_client=self.syn) + 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: - # GIVEN a WikiPage object - wiki = WikiPage( - id="wiki1", - owner_id="syn123", - ) - # WHEN I call `delete_async` - with patch("synapseclient.api.wiki_service.delete_wiki_page") as mock_delete: - await wiki.delete_async(synapse_client=self.syn) + # 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_delete.assert_called_once_with( + 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 - async def test_delete_async_missing_owner_id(self) -> None: - # GIVEN a WikiPage object without owner_id - wiki = WikiPage( - id="wiki1", - ) - - # WHEN I call `delete_async` + @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 pytest.raises( - ValueError, match="Must provide owner_id to delete a wiki page." - ): - await wiki.delete_async(synapse_client=self.syn) + 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() - async def test_delete_async_missing_id(self) -> None: - # GIVEN a WikiPage object without id - wiki = WikiPage( - owner_id="syn123", - ) + @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), + } + ] + } - # WHEN I call `delete_async` - # THEN it should raise ValueError - with pytest.raises(ValueError, match="Must provide id to delete a wiki page."): - await wiki.delete_async(synapse_client=self.syn) + 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, + ) - @patch("synapseclient.api.wiki_service.get_attachment_handles") - async def test_get_attachment_handles_async_success(self, mock_get_handles) -> None: + # 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 response - mock_handles = [{"id": "handle1", "fileName": "test.txt"}] - mock_get_handles.return_value = mock_handles + # AND a mock attachment URL + mock_attachment_url = "https://example.com/attachment.txt" - # WHEN I call `get_attachment_handles_async` - result = await wiki.get_attachment_handles_async(synapse_client=self.syn) + 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, + ) - # THEN the API should be called with correct parameters - mock_get_handles.assert_called_once_with( - owner_id="syn123", - wiki_id="wiki1", - wiki_version=None, - synapse_client=self.syn, - ) + # AND the attachment handles should not be retrieved + mock_get_handles.assert_not_called() - # AND the result should be the handles - assert result == mock_handles + @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() - async def test_get_attachment_handles_async_missing_owner_id(self) -> None: - # GIVEN a WikiPage object without owner_id - wiki = WikiPage( - id="wiki1", - ) + @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), + } + ] + } - # WHEN I call `get_attachment_handles_async` - # THEN it should raise ValueError - with pytest.raises( - ValueError, match="Must provide owner_id to get attachment handles." - ): - await wiki.get_attachment_handles_async(synapse_client=self.syn) + 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, + ) - async def test_get_attachment_handles_async_missing_id(self) -> None: - # GIVEN a WikiPage object without id - wiki = WikiPage( - owner_id="syn123", - ) + # 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, + ) - # WHEN I call `get_attachment_handles_async` - # THEN it should raise ValueError - with pytest.raises( - ValueError, match="Must provide id to get attachment handles." - ): - await wiki.get_attachment_handles_async(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 - @patch("synapseclient.api.wiki_service.get_attachment_url") - async def test_get_attachment_async_url_only(self, mock_get_url) -> 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 response - mock_url = "https://example.com/attachment.txt" - mock_get_url.return_value = mock_url - - # WHEN I call `get_attachment_async` with download_file=False - result = await wiki.get_attachment_async( - file_name="test.txt", - download_file=False, - synapse_client=self.syn, - ) + # AND a mock attachment URL + mock_attachment_url = "https://example.com/attachment.txt" - # THEN the API should be called with correct parameters - mock_get_url.assert_called_once_with( - owner_id="syn123", - wiki_id="wiki1", - file_name="test.txt", - wiki_version=None, - redirect=False, - synapse_client=self.syn, - ) + 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() - # AND the result should be the URL - assert result == mock_url + async def test_get_markdown_async_download_file_success(self) -> None: + # Mock responses + mock_markdown_url = "https://example.com/markdown.md" - async def test_get_attachment_async_missing_owner_id(self) -> None: - # GIVEN a WikiPage object without owner_id - wiki = WikiPage( - id="wiki1", - ) + 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, + ) - # WHEN I call `get_attachment_async` - # THEN it should raise ValueError - with pytest.raises( - ValueError, match="Must provide owner_id to get attachment URL." - ): - await wiki.get_attachment_async( - file_name="test.txt", + # 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, ) - async def test_get_attachment_async_missing_id(self) -> None: - # GIVEN a WikiPage object without id + # 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", ) - # WHEN I call `get_attachment_async` - # THEN it should raise ValueError - with pytest.raises(ValueError, match="Must provide id to get attachment URL."): - await wiki.get_attachment_async( - file_name="test.txt", + # 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_attachment_async_missing_file_name(self) -> None: + 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", ) - # WHEN I call `get_attachment_async` without file_name - # THEN it should raise ValueError - with pytest.raises( - ValueError, match="Must provide file_name to get attachment URL." - ): - await wiki.get_attachment_async( - file_name="", + # 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_restore_async_missing_owner_id(self) -> None: - # GIVEN a WikiPage object without owner_id + 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", - wiki_version="1", + owner_id="syn123", + wiki_version="2", ) - # WHEN I call `restore_async` - # THEN it should raise ValueError - with pytest.raises( - ValueError, match="Must provide owner_id to restore a wiki page." - ): - await wiki.restore_async(synapse_client=self.syn) + 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_restore_async_missing_id(self) -> None: - # GIVEN a WikiPage object without id + 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="1", + wiki_version=None, ) - # WHEN I call `restore_async` - # THEN it should raise ValueError - with pytest.raises(ValueError, match="Must provide id to restore a wiki page."): - await wiki.restore_async(synapse_client=self.syn) + 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" From 62f03d47a2c34a82d6ea2a0245d0cf1bc82bced7 Mon Sep 17 00:00:00 2001 From: danlu1 Date: Wed, 9 Jul 2025 09:43:08 -0700 Subject: [PATCH 40/42] remove redirect params --- synapseclient/models/wiki.py | 43 ++++++++++-------------------------- 1 file changed, 12 insertions(+), 31 deletions(-) diff --git a/synapseclient/models/wiki.py b/synapseclient/models/wiki.py index 0663f269a..ce5cfa217 100644 --- a/synapseclient/models/wiki.py +++ b/synapseclient/models/wiki.py @@ -39,6 +39,9 @@ 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 @@ -146,6 +149,7 @@ async def get_async( """ 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, @@ -554,7 +558,7 @@ async def task_of_uploading_attachment(attachment: str) -> tuple[str, str]: parent_entity_id=self.owner_id, path=file_path, ) - synapse_client.logger.info( + synapse_client.logger.debug( f"Uploaded file handle {file_handle.get('id')} for wiki page attachment." ) return file_handle.get("id") @@ -851,11 +855,10 @@ async def get_attachment_handles_async( ) async def get_attachment_async( self, - file_name: str, *, + file_name: str, download_file: bool = True, download_location: Optional[str] = None, - redirect: Optional[bool] = False, synapse_client: Optional["Synapse"] = None, ) -> Union[str, None]: """ @@ -865,7 +868,6 @@ async def get_attachment_async( 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. - 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. @@ -885,7 +887,6 @@ async def get_attachment_async( wiki_id=self.id, file_name=file_name, wiki_version=self.wiki_version, - redirect=redirect, synapse_client=client, ) @@ -908,7 +909,7 @@ async def get_attachment_async( # 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 < 8388608: + if file_size < SINGLE_THREAD_DOWNLOAD_SIZE_LIMIT: download_from_url( url=presigned_url_info.url, destination=download_location, @@ -934,7 +935,6 @@ async def get_attachment_preview_async( *, download_file: bool = True, download_location: Optional[str] = None, - redirect: Optional[bool] = False, synapse_client: Optional["Synapse"] = None, ) -> Union[str, None]: """ @@ -944,7 +944,6 @@ async def get_attachment_preview_async( 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. - 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. @@ -964,7 +963,6 @@ async def get_attachment_preview_async( wiki_id=self.id, file_name=file_name, wiki_version=self.wiki_version, - redirect=redirect, synapse_client=client, ) # download the file if download_file is True @@ -988,7 +986,7 @@ async def get_attachment_preview_async( # 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 < 8388608: + if file_size < SINGLE_THREAD_DOWNLOAD_SIZE_LIMIT: download_from_url( url=presigned_url_info.url, destination=download_location, @@ -997,11 +995,11 @@ async def get_attachment_preview_async( else: # download the file download_from_url_multi_threaded( - presigned_url=presigned_url_info.url, destination=download_location - ) - client.logger.debug( - f"Downloaded the preview file {presigned_url_info.file_name} to {download_location}" + 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 @@ -1014,7 +1012,6 @@ async def get_markdown_async( download_file_name: Optional[str] = None, download_file: bool = True, download_location: Optional[str] = None, - redirect: Optional[bool] = False, synapse_client: Optional["Synapse"] = None, ) -> Union[str, None]: """ @@ -1024,7 +1021,6 @@ async def get_markdown_async( 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. - 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. @@ -1041,7 +1037,6 @@ async def get_markdown_async( owner_id=self.owner_id, wiki_id=self.id, wiki_version=self.wiki_version, - redirect=redirect, synapse_client=client, ) # download the file if download_file is True @@ -1067,17 +1062,3 @@ async def get_markdown_async( ) else: return markdown_url - - @classmethod - def from_dict( - cls, synapse_wiki: Dict[str, Union[str, List[str], List[Dict[str, Any]]]] - ) -> "WikiPage": - """Create a new WikiPage instance from a dictionary. - - Arguments: - synapse_wiki: The dictionary containing wiki page data. - - Returns: - A new WikiPage instance filled with the dictionary data. - """ - return cls().fill_from_dict(synapse_wiki) From e56540eb29e272a551fa1a339997e642936bd53f Mon Sep 17 00:00:00 2001 From: danlu1 Date: Wed, 9 Jul 2025 09:45:13 -0700 Subject: [PATCH 41/42] update presigned url provider params for multithread downloader --- synapseclient/core/download/download_async.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/synapseclient/core/download/download_async.py b/synapseclient/core/download/download_async.py index 859bf9a4d..2de4c19b5 100644 --- a/synapseclient/core/download/download_async.py +++ b/synapseclient/core/download/download_async.py @@ -272,7 +272,11 @@ async def download_file(self) -> None: Splits up and downloads a file in chunks from a URL. """ if self._download_request.presigned_url: - url_provider = 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 From a46417ccc175b20591dd371a617e22563c9e322d Mon Sep 17 00:00:00 2001 From: danlu1 Date: Mon, 14 Jul 2025 09:48:12 -0700 Subject: [PATCH 42/42] remove duplicate expired presigned url checking --- .../core/download/download_functions.py | 41 ++++++++----------- 1 file changed, 18 insertions(+), 23 deletions(-) diff --git a/synapseclient/core/download/download_functions.py b/synapseclient/core/download/download_functions.py index 9f3a17d4e..b7ecc3868 100644 --- a/synapseclient/core/download/download_functions.py +++ b/synapseclient/core/download/download_functions.py @@ -824,29 +824,24 @@ def _ftp_report_hook( url ) if url_is_expired: - if url_is_presigned: - raise SynapseError( - "The provided pre-signed URL has expired. Please provide a new pre-signed URL." - ) - else: - 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, - ) - refreshed_url = response["preSignedURL"] - response = with_retry( - lambda url=refreshed_url, range_header=range_header, auth=auth: client._requests_session.get( - url=url, - headers=client._generate_headers(range_header), - stream=True, - allow_redirects=False, - auth=auth, - ), - verbose=client.debug, - **STANDARD_RETRY_PARAMS, - ) + 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, + ) + refreshed_url = response["preSignedURL"] + response = with_retry( + lambda url=refreshed_url, range_header=range_header, auth=auth: client._requests_session.get( + url=url, + headers=client._generate_headers(range_header), + stream=True, + allow_redirects=False, + auth=auth, + ), + verbose=client.debug, + **STANDARD_RETRY_PARAMS, + ) else: raise elif err.response.status_code == 404: