diff --git a/python/queries.gql b/python/queries.gql index 8005e326..d2eda377 100644 --- a/python/queries.gql +++ b/python/queries.gql @@ -122,6 +122,10 @@ fragment CollectionFileReference on CollectionFile { } } +fragment CollectionFileNotFound on CollectionFileNotFound { + id +} + mutation CollectionCreate($organizationID: ID!, $key: ID!, $parentID: ID) { collectionCreate( organizationID: $organizationID @@ -248,7 +252,7 @@ query CollectionDocuments( } } -mutation CollectionFile($collectionID: ID!, $key: ID!) { +mutation CollectionFileCreate($collectionID: ID!, $key: ID!) { collectionFileCreate(collectionID: $collectionID, key: $key) { __typename ... on CollectionFile { @@ -257,23 +261,25 @@ mutation CollectionFile($collectionID: ID!, $key: ID!) { } } -mutation collectionFileDelete($id: ID!) { +mutation CollectionFileDelete($id: ID!) { collectionFileDelete(id: $id) { __typename ... on CollectionFile { ...CollectionFileReference } + ... on CollectionFileNotFound { + ...CollectionFileNotFound + } } } -mutation collectionFiles( - $organizationID: ID! - $key: ID! +query CollectionFiles( + $collectionID: ID! $tag: TagInput $after: ID $first: Int ) { - collectionCreate(organizationID: $organizationID, key: $key) { + collection(id: $collectionID) { __typename ... on Collection { id @@ -296,7 +302,7 @@ mutation collectionFiles( } } -mutation collectionFileTagAdd($id: ID!, $tag: TagInput!) { +mutation CollectionFileTagAdd($id: ID!, $tag: TagInput!) { collectionFileTagAdd(id: $id, tag: $tag) { __typename ... on CollectionFile { @@ -305,7 +311,7 @@ mutation collectionFileTagAdd($id: ID!, $tag: TagInput!) { } } -mutation collectionFileTagDelete($id: ID!, $tag_key: String!) { +mutation CollectionFileTagDelete($id: ID!, $tag_key: String!) { collectionFileTagDelete(id: $id, key: $tag_key) { __typename ... on CollectionFile { @@ -313,3 +319,12 @@ mutation collectionFileTagDelete($id: ID!, $tag_key: String!) { } } } + +query CollectionFile($id: ID!) { + collectionFile(id: $id) { + __typename + ... on CollectionFile { + ...CollectionFileReference + } + } +} \ No newline at end of file diff --git a/python/src/numerous/_client/_fs_client.py b/python/src/numerous/_client/_fs_client.py index 2924660d..8eeb95c1 100644 --- a/python/src/numerous/_client/_fs_client.py +++ b/python/src/numerous/_client/_fs_client.py @@ -1,10 +1,9 @@ import json from dataclasses import asdict, dataclass -from io import TextIOWrapper from pathlib import Path from typing import Any, BinaryIO, Optional, Union -from numerous.collection.numerous_file import NumerousFile +from numerous.collection.file_reference import FileReference from numerous.generated.graphql.fragments import ( CollectionDocumentReference, CollectionDocumentReferenceTags, @@ -50,8 +49,9 @@ def to_document_reference_tag(self) -> CollectionDocumentReferenceTags: @dataclass -class FileSystemCollectionFile: - path: Path +class FileSystemFileMetadata: + file_id: str + file_key: str tags: list[FileSystemCollectionTag] def save(self, path: Path) -> None: @@ -64,7 +64,7 @@ def convert_to_serializable(obj: Path) -> str: json.dump(asdict(self), f, default=convert_to_serializable) @staticmethod - def load(file_path: Path) -> "FileSystemCollectionFile": + def load(file_path: Path) -> "FileSystemFileMetadata": with file_path.open("r") as f: file_content = json.load(f) @@ -73,29 +73,33 @@ def load(file_path: Path) -> "FileSystemCollectionFile": msg = f"FileSystemCollection file must be a dict, found {tname}" raise TypeError(msg) + file_id = file_content.get("file_id") + if not isinstance(file_id, str): + tname = type(file_content).__name__ + msg = f"FileSystemCollection file id must be a str, found {tname}" + raise TypeError(msg) + + file_key = file_content.get("file_key") + if not isinstance(file_key, str): + tname = type(file_content).__name__ + msg = f"FileSystemCollection file id must be a str, found {tname}" + raise TypeError(msg) + tags = file_content.get("tags", []) if not isinstance(tags, list): tname = type(tags).__name__ msg = f"FileSystemCollection tags must be a list, found {tname}" raise TypeError(msg) - path = file_content.get("path", {}) - if not isinstance(path, str): - tname = type(path).__name__ - msg = f"FileSystemCollection data must be a dict, found {tname}" - raise TypeError(msg) - path = Path(path) - - return FileSystemCollectionFile( - path=path, tags=[FileSystemCollectionTag.load(tag) for tag in tags] + return FileSystemFileMetadata( + file_id=file_id, + file_key=file_key, + tags=[FileSystemCollectionTag.load(tag) for tag in tags], ) def reference_tags(self) -> list[CollectionFileReferenceTags]: return [ - CollectionFileReferenceTags( - key=tag.key, - value=tag.value, - ) + CollectionFileReferenceTags(key=tag.key, value=tag.value) for tag in self.tags ] @@ -169,15 +173,52 @@ def tag_matches(self, tag_input: TagInput) -> bool: return matching_tag is not None +@dataclass +class FileIndexEntry: + collection_id: str + file_key: str + _path: Path | None = None + + @staticmethod + def load(path: Path) -> "FileIndexEntry": + return FileIndexEntry(**json.loads(path.read_text()), _path=path) + + def remove(self) -> None: + if self._path is not None: + self._path.unlink() + + def save(self, path: Path) -> None: + if self._path: + msg = "Cannot save file index entry that was already saved." + raise RuntimeError(msg) + self._path = path + path.write_text( + json.dumps({"collection_id": self.collection_id, "file_key": self.file_key}) + ) + + class FileSystemClient: FILE_INDEX_DIR = "__file_index__" def __init__(self, base_path: Path) -> None: self._base_path = base_path self._base_path.mkdir(exist_ok=True) - - def _file_path(self, file_id: str) -> Path: - return self._base_path / (self._base_path / self.FILE_INDEX_DIR / file_id).read_text() + (self._base_path / self.FILE_INDEX_DIR).mkdir(exist_ok=True) + + def _file_index_entry(self, file_id: str) -> FileIndexEntry: + return FileIndexEntry.load(self._base_path / self.FILE_INDEX_DIR / file_id) + + def _file_metadata_path(self, collection_id: str, file_key: str) -> Path: + return self._base_path / collection_id / f"{_escape(file_key)}.file.meta.json" + + def _file_data_path(self, collection_id: str, file_key: str) -> Path: + return self._base_path / collection_id / f"{_escape(file_key)}.file.data" + + def _document_path(self, collection_id: str, document_key: str) -> Path: + return self._base_path / collection_id / f"{document_key}.doc.json" + + def _document_path_from_id(self, document_id: str) -> Path: + return self._base_path / (document_id + ".doc.json") def get_collection_reference( self, collection_key: str, parent_collection_id: Optional[str] = None @@ -195,7 +236,7 @@ def get_collection_reference( def get_collection_document( self, collection_id: str, document_key: str ) -> Optional[CollectionDocumentReference]: - path = self._base_path / collection_id / f"{document_key}.json" + path = self._document_path(collection_id, document_key) if not path.exists(): return None @@ -212,14 +253,14 @@ def get_collection_document( def set_collection_document( self, collection_id: str, document_key: str, encoded_data: str ) -> Optional[CollectionDocumentReference]: - doc_path = self._base_path / collection_id / f"{document_key}.json" + path = self._document_path(collection_id, document_key) data = base64_to_dict(encoded_data) - if doc_path.exists(): - doc = FileSystemCollectionDocument.load(doc_path) + if path.exists(): + doc = FileSystemCollectionDocument.load(path) doc.data = data else: doc = FileSystemCollectionDocument(data, []) - doc.save(doc_path) + doc.save(path) doc_id = str(Path(collection_id) / document_key) return CollectionDocumentReference( @@ -232,7 +273,7 @@ def set_collection_document( def delete_collection_document( self, document_id: str ) -> Optional[CollectionDocumentReference]: - doc_path = self._base_path / (document_id + ".json") + doc_path = self._document_path_from_id(document_id) if not doc_path.exists(): return None @@ -242,7 +283,7 @@ def delete_collection_document( return CollectionDocumentReference( id=document_id, - key=doc_path.stem, + key=doc_path.name.removesuffix(".doc.json"), data=dict_to_base64(doc.data), tags=doc.reference_tags(), ) @@ -250,7 +291,7 @@ def delete_collection_document( def add_collection_document_tag( self, document_id: str, tag: TagInput ) -> Optional[CollectionDocumentReference]: - doc_path = self._base_path / (document_id + ".json") + doc_path = self._document_path_from_id(document_id) if not doc_path.exists(): return None @@ -268,7 +309,7 @@ def add_collection_document_tag( def delete_collection_document_tag( self, document_id: str, tag_key: str ) -> Optional[CollectionDocumentReference]: - doc_path = self._base_path / (document_id + ".json") + doc_path = self._document_path_from_id(document_id) if not doc_path.exists(): return None @@ -295,7 +336,7 @@ def get_collection_documents( documents: list[Optional[CollectionDocumentReference]] = [] for doc_path in col_path.iterdir(): - if doc_path.suffix != ".json": + if not doc_path.name.endswith(".doc.json"): continue doc = FileSystemCollectionDocument.load(doc_path) @@ -304,11 +345,15 @@ def get_collection_documents( # skips files that do not match tag input, if it is given continue - doc_id = str(doc_path.relative_to(self._base_path).with_suffix("")) + doc_id = str( + doc_path.relative_to(self._base_path).with_name( + doc_path.name.removesuffix(".doc.json") + ) + ) documents.append( CollectionDocumentReference( id=doc_id, - key=doc_path.stem, + key=doc_path.name.removesuffix(".doc.json"), data=dict_to_base64(doc.data), tags=doc.reference_tags(), ) @@ -316,117 +361,107 @@ def get_collection_documents( return sorted(documents, key=lambda d: d.id if d else ""), False, "" - def get_collection_file( + def create_collection_file_reference( self, collection_id: str, file_key: str - ) -> Optional[NumerousFile]: - file_key = self._fs_file_name(file_key) - path = self._base_path / collection_id / f"{file_key}.json" - if not path.exists(): - return None - - file = FileSystemCollectionFile.load(path) + ) -> Optional[FileReference]: + meta_path = self._file_metadata_path(collection_id, file_key) + if meta_path.exists(): + meta = FileSystemFileMetadata.load(meta_path) + else: + file_id = _escape(collection_id + "_" + file_key) + index_entry = FileIndexEntry(collection_id=collection_id, file_key=file_key) + index_entry.save(self._base_path / self.FILE_INDEX_DIR / file_id) + meta = FileSystemFileMetadata(file_id=file_id, file_key=file_key, tags=[]) + meta.save(self._file_metadata_path(collection_id, file_key)) - file_id = str(Path(collection_id) / file_key) - return NumerousFile( + return FileReference( client=self, - file_id=file_id, + file_id=meta.file_id, key=file_key, - exists=True, - numerous_file_tags=[tag.to_file_reference_tag() for tag in file.tags], ) - def _fs_file_name(self, file_key: str) -> str: - return f"file_{file_key}" + def collection_file_tags(self, file_id: str) -> dict[str, str] | None: + try: + index_entry = self._file_index_entry(file_id) + except FileNotFoundError: + return + + meta_path = self._file_metadata_path( + index_entry.collection_id, index_entry.file_key + ) - def delete_collection_file(self, file_id: str) -> Optional[NumerousFile]: - file_path = self._base_path / (file_id + ".json") - if not file_path.exists(): + if not meta_path.exists(): return None - file = FileSystemCollectionFile.load(file_path) - - file_path.unlink() - file.path.unlink() + meta = FileSystemFileMetadata.load(meta_path) + return {tag.key: tag.value for tag in meta.tags} - return NumerousFile( - client=self, - file_id=file_id, - key=file_path.stem, - exists=False, - numerous_file_tags=file.reference_tags(), + def delete_collection_file(self, file_id: str) -> None: + index_entry = self._file_index_entry(file_id) + meta_path = self._file_metadata_path( + index_entry.collection_id, index_entry.file_key ) + data_path = self._file_data_path( + index_entry.collection_id, index_entry.file_key + ) + + if not meta_path.exists(): + return + + meta_path.unlink() + data_path.unlink() def get_collection_files( self, - collection_key: str, + collection_id: str, end_cursor: str, # noqa: ARG002 tag_input: Optional[TagInput], - ) -> tuple[Optional[list[Optional[NumerousFile]]], bool, str]: - col_path = self._base_path / collection_key + ) -> tuple[list[FileReference], bool, str]: + col_path = self._base_path / collection_id if not col_path.exists(): return [], False, "" - files: list[Optional[NumerousFile]] = [] + files: list[FileReference] = [] for file_path in col_path.iterdir(): - if file_path.suffix != ".json": + if not file_path.name.endswith(".file.meta.json"): continue - file = FileSystemCollectionFile.load(file_path) + meta = FileSystemFileMetadata.load(file_path) - if tag_input and not file.tag_matches(tag_input): + if tag_input and not meta.tag_matches(tag_input): # skips files that do not match tag input, if it is given continue - file_id = str(file_path.relative_to(self._base_path).with_suffix("")) files.append( - NumerousFile( - client=self, - file_id=file_id, - key=file_path.stem, - exists=True, - numerous_file_tags=file.reference_tags(), - ) + FileReference(client=self, file_id=meta.file_id, key=meta.file_key) ) return files, False, "" - def add_collection_file_tag( - self, file_id: str, tag: TagInput - ) -> Optional[NumerousFile]: - file_path = self._base_path / (file_id + ".json") - if not file_path.exists(): - return None - - file = FileSystemCollectionFile.load(file_path) - file.tags.append(FileSystemCollectionTag(key=tag.key, value=tag.value)) - file.save(file_path) - - return NumerousFile( - client=self, - file_id=file_id, - key=file_path.stem, - exists=True, - numerous_file_tags=file.reference_tags(), + def add_collection_file_tag(self, file_id: str, tag: TagInput) -> None: + index_entry = self._file_index_entry(file_id) + meta_path = self._file_metadata_path( + index_entry.collection_id, index_entry.file_key ) - - def delete_collection_file_tag( - self, file_id: str, tag_key: str - ) -> Optional[NumerousFile]: - file_path = self._base_path / (file_id + ".json") - if not file_path.exists(): - return None - - file = FileSystemCollectionFile.load(file_path) - file.tags = [tag for tag in file.tags if tag.key != tag_key] - file.save(file_path) - - return NumerousFile( - client=self, - file_id=file_id, - key=file_path.stem, - exists=True, - numerous_file_tags=file.reference_tags(), + if not meta_path.exists(): + return + + meta = FileSystemFileMetadata.load(meta_path) + if not meta.tag_matches(tag): + meta.tags.append(FileSystemCollectionTag(key=tag.key, value=tag.value)) + meta.save(meta_path) + + def delete_collection_file_tag(self, file_id: str, tag_key: str) -> None: + index_entry = self._file_index_entry(file_id) + meta_path = self._file_metadata_path( + index_entry.collection_id, index_entry.file_key ) + if not meta_path.exists(): + return + + meta = FileSystemFileMetadata.load(meta_path) + meta.tags = [tag for tag in meta.tags if tag.key != tag_key] + meta.save(meta_path) def get_collection_collections( self, @@ -446,38 +481,46 @@ def get_collection_collections( return sorted(collections, key=lambda c: c.id), False, "" def read_text(self, file_id: str) -> str: - path = self._file_path(file_id) - file = FileSystemCollectionFile.load(path) - - with Path.open(file.path, "r") as f: - return f.read() + index_entry = self._file_index_entry(file_id) + data_path = self._file_data_path( + index_entry.collection_id, index_entry.file_key + ) + return data_path.read_text() def read_bytes(self, file_id: str) -> bytes: - path = self._file_path(file_id) - file = FileSystemCollectionFile.load(path) - with Path.open(file.path, "rb") as f: - return f.read() - - def save_data_file(self, file_id: str, data: Union[bytes, str]) -> None: - path = self._file_path(file_id) - file = FileSystemCollectionFile.load(path) - - write_mode = "wb" if isinstance(data, bytes) else "w" - with Path.open(file.path, write_mode) as file_obj: - file_obj.write(data) - - def save_file(self, file_id: str, data: TextIOWrapper) -> None: - path = self._file_path(file_id) - file = FileSystemCollectionFile.load(path) - try: - with Path.open(file.path, "w") as dest_file: - content = data.read() - dest_file.write(content) - finally: - if not data.closed: - data.close() + index_entry = self._file_index_entry(file_id) + data_path = self._file_data_path( + index_entry.collection_id, index_entry.file_key + ) + return data_path.read_bytes() + + def save_file(self, file_id: str, data: Union[bytes, str]) -> None: + index_entry = self._file_index_entry(file_id) + data_path = self._file_data_path( + index_entry.collection_id, index_entry.file_key + ) + if isinstance(data, bytes): + data_path.write_bytes(data) + else: + data_path.write_text(data) def open_file(self, file_id: str) -> BinaryIO: - path = self._file_path(file_id) - file = FileSystemCollectionFile.load(path) - return Path.open(file.path, "rb") + index_entry = self._file_index_entry(file_id) + data_path = self._file_data_path( + index_entry.collection_id, index_entry.file_key + ) + return data_path.open("rb") + + def file_exists(self, file_id: str) -> bool: + try: + index_entry = self._file_index_entry(file_id) + except FileNotFoundError: + return False + + return self._file_data_path( + index_entry.collection_id, index_entry.file_key + ).exists() + + +def _escape(key: str) -> str: + return key.replace("/", "__") diff --git a/python/src/numerous/_client/_graphql_client.py b/python/src/numerous/_client/_graphql_client.py index ff60ae80..fe9821a8 100644 --- a/python/src/numerous/_client/_graphql_client.py +++ b/python/src/numerous/_client/_graphql_client.py @@ -1,15 +1,15 @@ """GraphQL client wrapper for numerous.""" +from __future__ import annotations + import io import os -from io import TextIOWrapper -from typing import BinaryIO, Optional, Union +from typing import TYPE_CHECKING, BinaryIO import requests from numerous.collection.exceptions import ParentCollectionNotFoundError -from numerous.collection.numerous_file import NumerousFile -from numerous.generated.graphql.client import Client as GQLClient +from numerous.collection.file_reference import FileReference from numerous.generated.graphql.collection_collections import ( CollectionCollectionsCollectionCollection, CollectionCollectionsCollectionCollectionCollectionsEdgesNode, @@ -18,45 +18,17 @@ CollectionDocumentCollectionCollectionDocument, CollectionDocumentCollectionCollectionNotFound, ) -from numerous.generated.graphql.collection_document_delete import ( - CollectionDocumentDeleteCollectionDocumentDeleteCollectionDocument, - CollectionDocumentDeleteCollectionDocumentDeleteCollectionDocumentNotFound, -) -from numerous.generated.graphql.collection_document_set import ( - CollectionDocumentSetCollectionDocumentSetCollectionDocument, - CollectionDocumentSetCollectionDocumentSetCollectionNotFound, -) -from numerous.generated.graphql.collection_document_tag_add import ( - CollectionDocumentTagAddCollectionDocumentTagAddCollectionDocument, - CollectionDocumentTagAddCollectionDocumentTagAddCollectionDocumentNotFound, -) -from numerous.generated.graphql.collection_document_tag_delete import ( - CollectionDocumentTagDeleteCollectionDocumentTagDeleteCollectionDocument, - CollectionDocumentTagDeleteCollectionDocumentTagDeleteCollectionDocumentNotFound, -) from numerous.generated.graphql.collection_documents import ( CollectionDocumentsCollectionCollection, CollectionDocumentsCollectionCollectionDocumentsEdgesNode, ) from numerous.generated.graphql.collection_file import ( - CollectionFileCollectionFileCreateCollectionFile, - CollectionFileCollectionFileCreateCollectionNotFound, -) -from numerous.generated.graphql.collection_file_delete import ( - CollectionFileDeleteCollectionFileDeleteCollectionFile, - CollectionFileDeleteCollectionFileDeleteCollectionFileNotFound, -) -from numerous.generated.graphql.collection_file_tag_add import ( - CollectionFileTagAddCollectionFileTagAddCollectionFile, - CollectionFileTagAddCollectionFileTagAddCollectionFileNotFound, -) -from numerous.generated.graphql.collection_file_tag_delete import ( - CollectionFileTagDeleteCollectionFileTagDeleteCollectionFile, - CollectionFileTagDeleteCollectionFileTagDeleteCollectionFileNotFound, + CollectionFileCollectionFileCollectionFile, + CollectionFileCollectionFileCollectionFileNotFound, ) from numerous.generated.graphql.collection_files import ( - CollectionFilesCollectionCreateCollection, - CollectionFilesCollectionCreateCollectionFilesEdgesNode, + CollectionFilesCollectionCollection, + CollectionFilesCollectionCollectionFilesEdgesNode, ) from numerous.generated.graphql.fragments import ( CollectionDocumentReference, @@ -64,10 +36,45 @@ CollectionNotFound, CollectionReference, ) -from numerous.generated.graphql.input_types import TagInput from numerous.threaded_event_loop import ThreadedEventLoop +if TYPE_CHECKING: + from numerous.generated.graphql.client import Client as GQLClient + from numerous.generated.graphql.collection_document_delete import ( + CollectionDocumentDeleteCollectionDocumentDeleteCollectionDocument, + CollectionDocumentDeleteCollectionDocumentDeleteCollectionDocumentNotFound, + ) + from numerous.generated.graphql.collection_document_set import ( + CollectionDocumentSetCollectionDocumentSetCollectionDocument, + CollectionDocumentSetCollectionDocumentSetCollectionNotFound, + ) + from numerous.generated.graphql.collection_document_tag_add import ( + CollectionDocumentTagAddCollectionDocumentTagAddCollectionDocument, + CollectionDocumentTagAddCollectionDocumentTagAddCollectionDocumentNotFound, + ) + from numerous.generated.graphql.collection_document_tag_delete import ( + CollectionDocumentTagDeleteCollectionDocumentTagDeleteCollectionDocument, + CollectionDocumentTagDeleteCollectionDocumentTagDeleteCollectionDocumentNotFound, + ) + from numerous.generated.graphql.collection_file_create import ( + CollectionFileCreateCollectionFileCreateCollectionFile, + CollectionFileCreateCollectionFileCreateCollectionNotFound, + ) + from numerous.generated.graphql.collection_file_delete import ( + CollectionFileDeleteCollectionFileDeleteCollectionFile, + CollectionFileDeleteCollectionFileDeleteCollectionFileNotFound, + ) + from numerous.generated.graphql.collection_file_tag_add import ( + CollectionFileTagAddCollectionFileTagAddCollectionFile, + CollectionFileTagAddCollectionFileTagAddCollectionFileNotFound, + ) + from numerous.generated.graphql.collection_file_tag_delete import ( + CollectionFileTagDeleteCollectionFileTagDeleteCollectionFile, + CollectionFileTagDeleteCollectionFileTagDeleteCollectionFileNotFound, + ) + from numerous.generated.graphql.input_types import TagInput + COLLECTED_OBJECTS_NUMBER = 100 _REQUEST_TIMEOUT_SECONDS_ = 1.5 @@ -96,9 +103,8 @@ def __init__(self) -> None: class GraphQLClient: def __init__(self, gql: GQLClient) -> None: self._gql = gql - self._threaded_event_loop = ThreadedEventLoop() - self._threaded_event_loop.start() - self._files_references: dict[str, CollectionFileReference] = {} + self._loop = ThreadedEventLoop() + self._loop.start() organization_id = os.getenv("NUMEROUS_ORGANIZATION_ID") if not organization_id: @@ -113,11 +119,9 @@ def __init__(self, gql: GQLClient) -> None: def _create_collection_ref( self, - collection_response: Union[ - CollectionReference, - CollectionCollectionsCollectionCollectionCollectionsEdgesNode, - CollectionNotFound, - ], + collection_response: CollectionReference + | CollectionCollectionsCollectionCollectionCollectionsEdgesNode + | CollectionNotFound, ) -> CollectionReference: if isinstance(collection_response, CollectionNotFound): raise ParentCollectionNotFoundError(collection_id=collection_response.id) @@ -127,7 +131,7 @@ def _create_collection_ref( ) async def _create_collection( - self, collection_key: str, parent_collection_id: Optional[str] = None + self, collection_key: str, parent_collection_id: str | None = None ) -> CollectionReference: response = await self._gql.collection_create( self._organization_id, @@ -138,7 +142,7 @@ async def _create_collection( return self._create_collection_ref(response.collection_create) def get_collection_reference( - self, collection_key: str, parent_collection_id: Optional[str] = None + self, collection_key: str, parent_collection_id: str | None = None ) -> CollectionReference: """ Retrieve a collection by its key and parent key. @@ -146,39 +150,36 @@ def get_collection_reference( This method retrieves a collection based on its key and parent key, or creates it if it doesn't exist. """ - return self._threaded_event_loop.await_coro( + return self._loop.await_coro( self._create_collection(collection_key, parent_collection_id) ) def _create_collection_document_ref( self, - collection_response: Optional[ - Union[ - CollectionDocumentTagDeleteCollectionDocumentTagDeleteCollectionDocument, - CollectionDocumentTagAddCollectionDocumentTagAddCollectionDocument, - CollectionDocumentDeleteCollectionDocumentDeleteCollectionDocument, - CollectionDocumentSetCollectionDocumentSetCollectionDocument, - CollectionDocumentTagAddCollectionDocumentTagAddCollectionDocumentNotFound, - CollectionDocumentSetCollectionDocumentSetCollectionNotFound, - CollectionDocumentDeleteCollectionDocumentDeleteCollectionDocumentNotFound, - CollectionDocumentCollectionCollectionDocument, - CollectionDocumentsCollectionCollectionDocumentsEdgesNode, - CollectionDocumentTagDeleteCollectionDocumentTagDeleteCollectionDocumentNotFound, - ] - ], - ) -> Optional[CollectionDocumentReference]: - if isinstance(collection_response, CollectionDocumentReference): + resp: CollectionDocumentTagDeleteCollectionDocumentTagDeleteCollectionDocument + | CollectionDocumentTagAddCollectionDocumentTagAddCollectionDocument + | CollectionDocumentDeleteCollectionDocumentDeleteCollectionDocument + | CollectionDocumentSetCollectionDocumentSetCollectionDocument + | CollectionDocumentTagAddCollectionDocumentTagAddCollectionDocumentNotFound + | CollectionDocumentSetCollectionDocumentSetCollectionNotFound + | CollectionDocumentDeleteCollectionDocumentDeleteCollectionDocumentNotFound + | CollectionDocumentCollectionCollectionDocument + | CollectionDocumentsCollectionCollectionDocumentsEdgesNode + | CollectionDocumentTagDeleteCollectionDocumentTagDeleteCollectionDocumentNotFound # noqa: E501 + | None, + ) -> CollectionDocumentReference | None: + if isinstance(resp, CollectionDocumentReference): return CollectionDocumentReference( - id=collection_response.id, - key=collection_response.key, - data=collection_response.data, - tags=collection_response.tags, + id=resp.id, + key=resp.key, + data=resp.data, + tags=resp.tags, ) return None async def _get_collection_document( self, collection_id: str, document_key: str - ) -> Optional[CollectionDocumentReference]: + ) -> CollectionDocumentReference | None: response = await self._gql.collection_document( collection_id, document_key, @@ -195,14 +196,14 @@ async def _get_collection_document( def get_collection_document( self, collection_id: str, document_key: str - ) -> Optional[CollectionDocumentReference]: - return self._threaded_event_loop.await_coro( + ) -> CollectionDocumentReference | None: + return self._loop.await_coro( self._get_collection_document(collection_id, document_key) ) async def _set_collection_document( self, collection_id: str, document_key: str, document_data: str - ) -> Optional[CollectionDocumentReference]: + ) -> CollectionDocumentReference | None: response = await self._gql.collection_document_set( collection_id, document_key, @@ -213,14 +214,14 @@ async def _set_collection_document( def set_collection_document( self, collection_id: str, document_key: str, document_data: str - ) -> Optional[CollectionDocumentReference]: - return self._threaded_event_loop.await_coro( + ) -> CollectionDocumentReference | None: + return self._loop.await_coro( self._set_collection_document(collection_id, document_key, document_data) ) async def _delete_collection_document( self, document_id: str - ) -> Optional[CollectionDocumentReference]: + ) -> CollectionDocumentReference | None: response = await self._gql.collection_document_delete( document_id, headers=self._headers ) @@ -228,14 +229,12 @@ async def _delete_collection_document( def delete_collection_document( self, document_id: str - ) -> Optional[CollectionDocumentReference]: - return self._threaded_event_loop.await_coro( - self._delete_collection_document(document_id) - ) + ) -> CollectionDocumentReference | None: + return self._loop.await_coro(self._delete_collection_document(document_id)) async def _add_collection_document_tag( self, document_id: str, tag: TagInput - ) -> Optional[CollectionDocumentReference]: + ) -> CollectionDocumentReference | None: response = await self._gql.collection_document_tag_add( document_id, tag, headers=self._headers ) @@ -245,14 +244,14 @@ async def _add_collection_document_tag( def add_collection_document_tag( self, document_id: str, tag: TagInput - ) -> Optional[CollectionDocumentReference]: - return self._threaded_event_loop.await_coro( + ) -> CollectionDocumentReference | None: + return self._loop.await_coro( self._add_collection_document_tag(document_id, tag) ) async def _delete_collection_document_tag( self, document_id: str, tag_key: str - ) -> Optional[CollectionDocumentReference]: + ) -> CollectionDocumentReference | None: response = await self._gql.collection_document_tag_delete( document_id, tag_key, headers=self._headers ) @@ -262,8 +261,8 @@ async def _delete_collection_document_tag( def delete_collection_document_tag( self, document_id: str, tag_key: str - ) -> Optional[CollectionDocumentReference]: - return self._threaded_event_loop.await_coro( + ) -> CollectionDocumentReference | None: + return self._loop.await_coro( self._delete_collection_document_tag(document_id, tag_key) ) @@ -271,8 +270,8 @@ async def _get_collection_documents( self, collection_id: str, end_cursor: str, - tag_input: Optional[TagInput], - ) -> tuple[Optional[list[Optional[CollectionDocumentReference]]], bool, str]: + tag_input: TagInput | None, + ) -> tuple[list[CollectionDocumentReference | None] | None, bool, str]: response = await self._gql.collection_documents( collection_id, tag_input, @@ -297,100 +296,89 @@ async def _get_collection_documents( return result, has_next_page, end_cursor def get_collection_documents( - self, collection_id: str, end_cursor: str, tag_input: Optional[TagInput] - ) -> tuple[Optional[list[Optional[CollectionDocumentReference]]], bool, str]: - return self._threaded_event_loop.await_coro( + self, collection_id: str, end_cursor: str, tag_input: TagInput | None + ) -> tuple[list[CollectionDocumentReference | None] | None, bool, str]: + return self._loop.await_coro( self._get_collection_documents(collection_id, end_cursor, tag_input) ) def _create_collection_files_ref( self, - collection_response: Optional[ - Union[ - CollectionDocumentTagDeleteCollectionDocumentTagDeleteCollectionDocumentNotFound, - CollectionFileCollectionFileCreateCollectionFile, - CollectionFileCollectionFileCreateCollectionNotFound, - CollectionFileDeleteCollectionFileDeleteCollectionFile, - CollectionFileDeleteCollectionFileDeleteCollectionFileNotFound, - CollectionFilesCollectionCreateCollectionFilesEdgesNode, - CollectionFileTagDeleteCollectionFileTagDeleteCollectionFile, - CollectionFileTagAddCollectionFileTagAddCollectionFile, - CollectionFileTagAddCollectionFileTagAddCollectionFileNotFound, - CollectionFileTagDeleteCollectionFileTagDeleteCollectionFileNotFound, - ] - ], - ) -> Optional[NumerousFile]: - if isinstance(collection_response, CollectionFileReference): - self._files_references[collection_response.id] = collection_response - exists = False - if ( - collection_response.download_url - and collection_response.download_url.strip() != "" - ): - exists = True - return NumerousFile( - client=self, - key=collection_response.key, - file_id=collection_response.id, - exists=exists, - numerous_file_tags=collection_response.tags, - ) + resp: ( + CollectionFileCreateCollectionFileCreateCollectionFile + | CollectionFileCreateCollectionFileCreateCollectionNotFound + | CollectionFileDeleteCollectionFileDeleteCollectionFile + | CollectionFileDeleteCollectionFileDeleteCollectionFileNotFound + | CollectionFilesCollectionCollectionFilesEdgesNode + | CollectionFileTagDeleteCollectionFileTagDeleteCollectionFile + | CollectionFileTagAddCollectionFileTagAddCollectionFile + | CollectionFileTagAddCollectionFileTagAddCollectionFileNotFound + | CollectionFileTagDeleteCollectionFileTagDeleteCollectionFileNotFound + | None + ), + ) -> FileReference | None: + if not isinstance(resp, CollectionFileReference): + return None - return None + return FileReference(client=self, key=resp.key, file_id=resp.id) - async def _get_collection_file( + async def _create_collection_file_reference( self, collection_id: str, file_key: str - ) -> Optional[NumerousFile]: - response = await self._gql.collection_file( + ) -> FileReference | None: + response = await self._gql.collection_file_create( collection_id, file_key, headers=self._headers, ) return self._create_collection_files_ref(response.collection_file_create) - def get_collection_file( + def create_collection_file_reference( self, collection_id: str, file_key: str - ) -> Optional[NumerousFile]: - return self._threaded_event_loop.await_coro( - self._get_collection_file(collection_id, file_key) + ) -> FileReference | None: + return self._loop.await_coro( + self._create_collection_file_reference(collection_id, file_key) ) - async def _delete_collection_file(self, file_id: str) -> Optional[NumerousFile]: - response = await self._gql.collection_file_delete( - file_id, - headers=self._headers, - ) - return self._create_collection_files_ref(response.collection_file_delete) + def collection_file_tags(self, file_id: str) -> dict[str, str] | None: + file = self._loop.await_coro(self._gql.collection_file(file_id)).collection_file - def delete_collection_file(self, file_id: str) -> Optional[NumerousFile]: - return self._threaded_event_loop.await_coro( - self._delete_collection_file(file_id) - ) + if not isinstance(file, CollectionFileCollectionFileCollectionFile): + return None + + return {tag.key: tag.value for tag in file.tags} + + async def _delete_collection_file(self, file_id: str) -> None: + await self._gql.collection_file_delete(file_id, headers=self._headers) + + def delete_collection_file(self, file_id: str) -> None: + self._loop.await_coro(self._delete_collection_file(file_id)) async def _get_collection_files( self, - collection_key: str, + collection_id: str, end_cursor: str, - tag_input: Optional[TagInput], - ) -> tuple[Optional[list[Optional[NumerousFile]]], bool, str]: + tag_input: TagInput | None, + ) -> tuple[list[FileReference], bool, str]: response = await self._gql.collection_files( - self._organization_id, - collection_key, + collection_id, tag_input, after=end_cursor, first=COLLECTED_OBJECTS_NUMBER, headers=self._headers, ) - collection = response.collection_create - if not isinstance(collection, CollectionFilesCollectionCreateCollection): + collection = response.collection + if not isinstance(collection, CollectionFilesCollectionCollection): return [], False, "" files = collection.files edges = files.edges page_info = files.page_info - result = [self._create_collection_files_ref(edge.node) for edge in edges] + result: list[FileReference] = [] + for edge in edges: + if ref := self._create_collection_files_ref(edge.node): + result.append(ref) # noqa: PERF401 end_cursor = page_info.end_cursor or "" has_next_page = page_info.has_next_page @@ -398,45 +386,29 @@ async def _get_collection_files( return result, has_next_page, end_cursor def get_collection_files( - self, collection_key: str, end_cursor: str, tag_input: Optional[TagInput] - ) -> tuple[Optional[list[Optional[NumerousFile]]], bool, str]: - return self._threaded_event_loop.await_coro( - self._get_collection_files(collection_key, end_cursor, tag_input) + self, collection_id: str, end_cursor: str, tag_input: TagInput | None + ) -> tuple[list[FileReference], bool, str]: + return self._loop.await_coro( + self._get_collection_files(collection_id, end_cursor, tag_input) ) - async def _add_collection_file_tag( - self, file_id: str, tag: TagInput - ) -> Optional[NumerousFile]: - response = await self._gql.collection_file_tag_add( - file_id, tag, headers=self._headers - ) - return self._create_collection_files_ref(response.collection_file_tag_add) + async def _add_collection_file_tag(self, file_id: str, tag: TagInput) -> None: + await self._gql.collection_file_tag_add(file_id, tag, headers=self._headers) - def add_collection_file_tag( - self, file_id: str, tag: TagInput - ) -> Optional[NumerousFile]: - return self._threaded_event_loop.await_coro( - self._add_collection_file_tag(file_id, tag) - ) + def add_collection_file_tag(self, file_id: str, tag: TagInput) -> None: + self._loop.await_coro(self._add_collection_file_tag(file_id, tag)) - async def _delete_collection_file_tag( - self, file_id: str, tag_key: str - ) -> Optional[NumerousFile]: - response = await self._gql.collection_file_tag_delete( + async def _delete_collection_file_tag(self, file_id: str, tag_key: str) -> None: + await self._gql.collection_file_tag_delete( file_id, tag_key, headers=self._headers ) - return self._create_collection_files_ref(response.collection_file_tag_delete) - def delete_collection_file_tag( - self, file_id: str, tag_key: str - ) -> Optional[NumerousFile]: - return self._threaded_event_loop.await_coro( - self._delete_collection_file_tag(file_id, tag_key) - ) + def delete_collection_file_tag(self, file_id: str, tag_key: str) -> None: + return self._loop.await_coro(self._delete_collection_file_tag(file_id, tag_key)) async def _get_collection_collections( self, collection_id: str, end_cursor: str - ) -> tuple[Optional[list[CollectionReference]], bool, str]: + ) -> tuple[list[CollectionReference] | None, bool, str]: response = await self._gql.collection_collections( collection_id, after=end_cursor, @@ -463,67 +435,63 @@ async def _get_collection_collections( def get_collection_collections( self, collection_key: str, end_cursor: str - ) -> tuple[Optional[list[CollectionReference]], bool, str]: - return self._threaded_event_loop.await_coro( + ) -> tuple[list[CollectionReference] | None, bool, str]: + return self._loop.await_coro( self._get_collection_collections(collection_key, end_cursor) ) - def read_text(self, file_id: str) -> str: - download_url = self._files_references[file_id].download_url - if download_url is None: - msg = "No download URL for this file." - raise ValueError(msg) - response = requests.get(download_url, timeout=_REQUEST_TIMEOUT_SECONDS_) - response.raise_for_status() - - return response.text - - def read_bytes(self, file_id: str) -> bytes: - download_url = self._files_references[file_id].download_url - if download_url is None: - msg = "No download URL for this file." - raise ValueError(msg) - response = requests.get(download_url, timeout=_REQUEST_TIMEOUT_SECONDS_) - response.raise_for_status() - - return response.content + def save_file(self, file_id: str, data: bytes | str) -> None: + file = self._loop.await_coro(self._gql.collection_file(file_id)).collection_file + if file is None or isinstance( + file, CollectionFileCollectionFileCollectionFileNotFound + ): + return - def save_data_file(self, file_id: str, data: Union[bytes, str]) -> None: - upload_url = self._files_references[file_id].upload_url - if upload_url is None: + if file.upload_url is None: msg = "No upload URL for this file." raise ValueError(msg) if isinstance(data, str): - data = data.encode("utf-8") # Convert string to bytes + data = data.encode() # Convert string to bytes response = requests.put( - upload_url, files={"file": data}, timeout=_REQUEST_TIMEOUT_SECONDS_ + file.upload_url, files={"file": data}, timeout=_REQUEST_TIMEOUT_SECONDS_ ) response.raise_for_status() - def save_file(self, file_id: str, data: TextIOWrapper) -> None: - upload_url = self._files_references[file_id].upload_url - if upload_url is None: - msg = "No upload URL for this file." - raise ValueError(msg) - - data.seek(0) - file_content = data.read().encode("utf-8") + def read_text(self, file_id: str) -> str: + return self._request_file(file_id).text - response = requests.put( - upload_url, - files={"file": file_content}, - timeout=_REQUEST_TIMEOUT_SECONDS_, - ) - response.raise_for_status() + def read_bytes(self, file_id: str) -> bytes: + return self._request_file(file_id).content def open_file(self, file_id: str) -> BinaryIO: - download_url = self._files_references[file_id].download_url - if download_url is None: + return io.BytesIO(self._request_file(file_id).content) + + def _request_file(self, file_id: str) -> requests.Response: + file = self._loop.await_coro(self._gql.collection_file(file_id)).collection_file + + if file is None or isinstance( + file, CollectionFileCollectionFileCollectionFileNotFound + ): + msg = "Collection file not found" + raise ValueError(msg) + + if file.download_url is None: msg = "No download URL for this file." raise ValueError(msg) - response = requests.get(download_url, timeout=_REQUEST_TIMEOUT_SECONDS_) + response = requests.get(file.download_url, timeout=_REQUEST_TIMEOUT_SECONDS_) response.raise_for_status() - return io.BytesIO(response.content) + + return response + + def file_exists(self, file_id: str) -> bool: + file = self._loop.await_coro(self._gql.collection_file(file_id)).collection_file + + if file is None or isinstance( + file, CollectionFileCollectionFileCollectionFileNotFound + ): + return False + + return file.download_url is not None and file.download_url.strip() != "" diff --git a/python/src/numerous/collection/__init__.py b/python/src/numerous/collection/__init__.py index 5fe5445b..fe63183a 100644 --- a/python/src/numerous/collection/__init__.py +++ b/python/src/numerous/collection/__init__.py @@ -1,7 +1,7 @@ """The Python SDK for numerous collections.""" -__all__ = ["collection", "NumerousCollection", "NumerousDocument"] +__all__ = ["collection", "CollectionReference", "DocumentReference"] from .collection import collection -from .numerous_collection import NumerousCollection -from .numerous_document import NumerousDocument +from .collection_reference import CollectionReference +from .document_reference import DocumentReference diff --git a/python/src/numerous/collection/_client.py b/python/src/numerous/collection/_client.py index a24b2b7a..621cbb01 100644 --- a/python/src/numerous/collection/_client.py +++ b/python/src/numerous/collection/_client.py @@ -11,9 +11,7 @@ if TYPE_CHECKING: - from io import TextIOWrapper - - from numerous.collection.numerous_file import NumerousFile + from numerous.collection.file_reference import FileReference from numerous.generated.graphql.fragments import ( CollectionDocumentReference, CollectionReference, @@ -54,30 +52,28 @@ def get_collection_collections( self, collection_key: str, end_cursor: str ) -> tuple[list[CollectionReference] | None, bool, str]: ... - def get_collection_file( + def get_collection_files( + self, collection_id: str, end_cursor: str, tag_input: TagInput | None + ) -> tuple[list[FileReference], bool, str]: ... + + def create_collection_file_reference( self, collection_id: str, file_key: str - ) -> NumerousFile | None: ... + ) -> FileReference | None: ... - def delete_collection_file(self, file_id: str) -> NumerousFile | None: ... + def collection_file_tags(self, file_id: str) -> dict[str, str] | None: ... - def get_collection_files( - self, collection_key: str, end_cursor: str, tag_input: TagInput | None - ) -> tuple[list[NumerousFile | None] | None, bool, str]: ... + def delete_collection_file(self, file_id: str) -> None: ... - def add_collection_file_tag( - self, file_id: str, tag: TagInput - ) -> NumerousFile | None: ... + def add_collection_file_tag(self, file_id: str, tag: TagInput) -> None: ... - def delete_collection_file_tag( - self, file_id: str, tag_key: str - ) -> NumerousFile | None: ... + def delete_collection_file_tag(self, file_id: str, tag_key: str) -> None: ... def read_text(self, file_id: str) -> str: ... def read_bytes(self, file_id: str) -> bytes: ... - def save_data_file(self, file_id: str, data: bytes | str) -> None: ... - - def save_file(self, file_id: str, data: TextIOWrapper) -> None: ... + def save_file(self, file_id: str, data: bytes | str) -> None: ... def open_file(self, file_id: str) -> BinaryIO: ... + + def file_exists(self, file_id: str) -> bool: ... diff --git a/python/src/numerous/collection/collection.py b/python/src/numerous/collection/collection.py index 8cc025a1..f7c2dceb 100644 --- a/python/src/numerous/collection/collection.py +++ b/python/src/numerous/collection/collection.py @@ -3,14 +3,14 @@ from typing import Optional from numerous._client._get_client import get_client -from numerous.collection.numerous_collection import NumerousCollection +from numerous.collection.collection_reference import CollectionReference from ._client import Client def collection( collection_key: str, _client: Optional[Client] = None -) -> NumerousCollection: +) -> CollectionReference: """ Get or create a root collection by key. @@ -28,4 +28,4 @@ def collection( if _client is None: _client = get_client() collection_ref = _client.get_collection_reference(collection_key) - return NumerousCollection(collection_ref, _client) + return CollectionReference(collection_ref.id, collection_ref.key, _client) diff --git a/python/src/numerous/collection/numerous_collection.py b/python/src/numerous/collection/collection_reference.py similarity index 68% rename from python/src/numerous/collection/numerous_collection.py rename to python/src/numerous/collection/collection_reference.py index ba50cec9..5729a5b4 100644 --- a/python/src/numerous/collection/numerous_collection.py +++ b/python/src/numerous/collection/collection_reference.py @@ -4,9 +4,8 @@ from typing import Generator, Iterator, Optional from numerous.collection._client import Client -from numerous.collection.numerous_document import NumerousDocument -from numerous.collection.numerous_file import NumerousFile -from numerous.generated.graphql.fragments import CollectionReference +from numerous.collection.document_reference import DocumentReference +from numerous.collection.file_reference import FileReference from numerous.generated.graphql.input_types import TagInput @@ -16,13 +15,15 @@ class CollectionNotFoundError(Exception): key: str -class NumerousCollection: - def __init__(self, collection_ref: CollectionReference, _client: Client) -> None: - self.key = collection_ref.key - self.id = collection_ref.id +class CollectionReference: + def __init__( + self, collection_id: str, collection_key: str, _client: Client + ) -> None: + self.key = collection_key + self.id = collection_id self._client = _client - def collection(self, collection_key: str) -> "NumerousCollection": + def collection(self, collection_key: str) -> "CollectionReference": """ Get or create a child collection of this collection by key. @@ -35,16 +36,16 @@ def collection(self, collection_key: str) -> "NumerousCollection": NumerousCollection: The child collection identified by the given key. """ - collection_ref = self._client.get_collection_reference( + ref = self._client.get_collection_reference( collection_key=collection_key, parent_collection_id=self.id ) - if collection_ref is None: + if ref is None: raise CollectionNotFoundError(parent_id=self.id, key=collection_key) - return NumerousCollection(collection_ref, self._client) + return CollectionReference(ref.id, ref.key, self._client) - def document(self, key: str) -> NumerousDocument: + def document(self, key: str) -> DocumentReference: """ Get or create a document by key. @@ -58,32 +59,33 @@ def document(self, key: str) -> NumerousDocument: """ numerous_doc_ref = self._client.get_collection_document(self.id, key) if numerous_doc_ref is not None: - numerous_document = NumerousDocument( + numerous_document = DocumentReference( self._client, numerous_doc_ref.key, (self.id, self.key), numerous_doc_ref, ) else: - numerous_document = NumerousDocument(self._client, key, (self.id, self.key)) + numerous_document = DocumentReference( + self._client, key, (self.id, self.key) + ) return numerous_document - def file(self, key: str) -> NumerousFile: + def file(self, key: str) -> FileReference: """ Get or create a file by key. - Attributes - ---------- - key (str): The key of the file. + Args: + key: The key of the file. """ - numerous_file = self._client.get_collection_file(self.id, key) - if numerous_file is None: + file = self._client.create_collection_file_reference(self.id, key) + if file is None: msg = "Failed to retrieve or create the file." raise ValueError(msg) - return numerous_file + return file def save_file(self, file_key: str, file_data: str) -> None: """ @@ -93,35 +95,28 @@ def save_file(self, file_key: str, file_data: str) -> None: it will be overwritten with the new data. Args: - ---- - file_key (str): The key of the file to save or update. - file_data (str): The data to be written to the file. + file_key: The key of the file to save or update. + file_data: The data to be written to the file. Raises: - ------ ValueError: If the file cannot be created or saved. """ - numerous_file = self.file(file_key) - numerous_file.save(file_data) + file = self.file(file_key) + file.save(file_data) def files( self, tag_key: Optional[str] = None, tag_value: Optional[str] = None - ) -> Iterator[NumerousFile]: + ) -> Iterator[FileReference]: """ Retrieve files from the collection, filtered by a tag key and value. - Parameters - ---------- - tag_key : Optional[str] - The key of the tag used to filter files (optional). - tag_value : Optional[str] - The value of the tag used to filter files (optional). + Args: + tag_key: The key of the tag used to filter files. + tag_value: The value of the tag used to filter files. - Yields - ------ - NumerousFile - Yields NumerousFile objects from the collection. + Yields: + File references from the collection. """ end_cursor = "" @@ -130,7 +125,7 @@ def files( tag_input = TagInput(key=tag_key, value=tag_value) has_next_page = True while has_next_page: - result = self._client.get_collection_files(self.key, end_cursor, tag_input) + result = self._client.get_collection_files(self.id, end_cursor, tag_input) if result is None: break numerous_files, has_next_page, end_cursor = result @@ -143,7 +138,7 @@ def files( def documents( self, tag_key: Optional[str] = None, tag_value: Optional[str] = None - ) -> Iterator[NumerousDocument]: + ) -> Iterator[DocumentReference]: """ Retrieve documents from the collection, filtered by a tag key and value. @@ -174,14 +169,14 @@ def documents( for numerous_doc_ref in numerous_doc_refs: if numerous_doc_ref is None: continue - yield NumerousDocument( + yield DocumentReference( self._client, numerous_doc_ref.key, (self.id, self.key), numerous_doc_ref, ) - def collections(self) -> Generator["NumerousCollection", None, None]: + def collections(self) -> Generator["CollectionReference", None, None]: """ Retrieve nested collections from the collection. @@ -195,8 +190,8 @@ def collections(self) -> Generator["NumerousCollection", None, None]: result = self._client.get_collection_collections(self.id, end_cursor) if result is None: break - collection_refs, has_next_page, end_cursor = result - if collection_refs is None: + refs, has_next_page, end_cursor = result + if refs is None: break - for collection_ref in collection_refs: - yield NumerousCollection(collection_ref, self._client) + for ref in refs: + yield CollectionReference(ref.id, ref.key, self._client) diff --git a/python/src/numerous/collection/numerous_document.py b/python/src/numerous/collection/document_reference.py similarity index 99% rename from python/src/numerous/collection/numerous_document.py rename to python/src/numerous/collection/document_reference.py index 50d23185..83b6846d 100644 --- a/python/src/numerous/collection/numerous_document.py +++ b/python/src/numerous/collection/document_reference.py @@ -8,7 +8,7 @@ from numerous.jsonbase64 import base64_to_dict, dict_to_base64 -class NumerousDocument: +class DocumentReference: """ Represents a document in a Numerous collection. diff --git a/python/src/numerous/collection/numerous_file.py b/python/src/numerous/collection/file_reference.py similarity index 52% rename from python/src/numerous/collection/numerous_file.py rename to python/src/numerous/collection/file_reference.py index 6df44025..c556a720 100644 --- a/python/src/numerous/collection/numerous_file.py +++ b/python/src/numerous/collection/file_reference.py @@ -11,14 +11,13 @@ from io import TextIOWrapper from numerous.collection._client import Client - from numerous.generated.graphql.fragments import CollectionFileReferenceTags -_NO_FILE_MSG_ = "File does not exist." +NO_FILE_ERROR_MSG = "File does not exist." -class NumerousFile: +class FileReference: """ - Represents a file in a Numerous collection. + Represents a file in a collection. Attributes: key: The key of the file. @@ -32,29 +31,20 @@ def __init__( client: Client, key: str, file_id: str, - exists: bool = False, - numerous_file_tags: list[CollectionFileReferenceTags] | None = None, ) -> None: """ - Initialize a NumerousFile instance. + Initialize a file reference. Args: client: The client used to interact with the Numerous collection. key: The key of the file. file_id: The unique identifier of the file. - exists: Indicates whether the file exists. - numerous_file_tags: An optional list of tags associated with the file. + tags: An optional list of tags associated with the file. """ self.key: str = key self.file_id: str = file_id self._client: Client = client - self._exists: bool = exists - self._tags: dict[str, str] = {} - - if numerous_file_tags is not None: - dict_of_tags = {tag.key: tag.value for tag in numerous_file_tags} - self._tags = dict_of_tags if dict_of_tags else {} @property def exists(self) -> bool: @@ -65,7 +55,7 @@ def exists(self) -> bool: True if the file exists; False otherwise. """ - return self._exists + return self._client.file_exists(self.file_id) @property def tags(self) -> dict[str, str]: @@ -76,7 +66,10 @@ def tags(self) -> dict[str, str]: A dictionary of tag key-value pairs. """ - return self._tags + tags = self._client.collection_file_tags(self.file_id) + if tags is None: + raise ValueError(NO_FILE_ERROR_MSG) + return tags def read_text(self) -> str: """ @@ -85,13 +78,7 @@ def read_text(self) -> str: Returns: The text content of the file. - Raises: - ValueError: If the file does not exist. - OSError: If an error occurs while reading the file. - """ - if not self.exists: - raise ValueError(_NO_FILE_MSG_) return self._client.read_text(self.file_id) def read_bytes(self) -> bytes: @@ -101,13 +88,7 @@ def read_bytes(self) -> bytes: Returns: The byte content of the file. - Raises: - ValueError: If the file does not exist. - OSError: If an error occurs while reading the file. - """ - if not self.exists: - raise ValueError(_NO_FILE_MSG_) return self._client.read_bytes(self.file_id) def open(self) -> BinaryIO: @@ -117,13 +98,7 @@ def open(self) -> BinaryIO: Returns: A binary file-like object for reading the file. - Raises: - ValueError: If the file does not exist. - OSError: If an error occurs while opening the file. - """ - if not self.exists: - raise ValueError(_NO_FILE_MSG_) return self._client.open_file(self.file_id) def save(self, data: bytes | str) -> None: @@ -133,11 +108,8 @@ def save(self, data: bytes | str) -> None: Args: data: The content to save to the file, either as bytes or string. - Raises: - HTTPError: If an error occurs during the upload. - """ - self._client.save_data_file(self.file_id, data) + self._client.save_file(self.file_id, data) def save_file(self, data: TextIOWrapper) -> None: """ @@ -146,28 +118,12 @@ def save_file(self, data: TextIOWrapper) -> None: Args: data: A file-like object containing the text content to upload. - Raises: - HTTPError: If an error occurs during the upload. - """ - self._client.save_file(self.file_id, data) + self._client.save_file(self.file_id, data.read()) def delete(self) -> None: - """ - Delete the file from the server. - - Raises: - ValueError: If the file does not exist or deletion failed. - - """ - deleted_file = self._client.delete_collection_file(self.file_id) - if deleted_file is None: - msg = "Failed to delete the file." - raise ValueError(msg) - - self.key = "" - self._tags = {} - self._exists = False + """Delete the file from the server.""" + self._client.delete_collection_file(self.file_id) def tag(self, key: str, value: str) -> None: """ @@ -177,21 +133,11 @@ def tag(self, key: str, value: str) -> None: key: The tag key. value: The tag value. - Raises: - ValueError: If the file does not exist. - """ - if not self.exists: - msg = "Cannot tag a non-existent file." - raise ValueError(msg) - - tagged_file = self._client.add_collection_file_tag( + self._client.add_collection_file_tag( self.file_id, TagInput(key=key, value=value) ) - if tagged_file is not None: - self._tags = tagged_file.tags - def tag_delete(self, tag_key: str) -> None: """ Delete a tag from the file. @@ -203,11 +149,4 @@ def tag_delete(self, tag_key: str) -> None: ValueError: If the file does not exist. """ - if not self.exists: - msg = "Cannot delete tag from a non-existent file." - raise ValueError(msg) - - tagged_file = self._client.delete_collection_file_tag(self.file_id, tag_key) - - if tagged_file is not None: - self._tags = tagged_file.tags + self._client.delete_collection_file_tag(self.file_id, tag_key) diff --git a/python/src/numerous/generated/graphql/__init__.py b/python/src/numerous/generated/graphql/__init__.py index c5e4dd03..4683f358 100644 --- a/python/src/numerous/generated/graphql/__init__.py +++ b/python/src/numerous/generated/graphql/__init__.py @@ -70,8 +70,13 @@ ) from .collection_file import ( CollectionFile, - CollectionFileCollectionFileCreateCollectionFile, - CollectionFileCollectionFileCreateCollectionNotFound, + CollectionFileCollectionFileCollectionFile, + CollectionFileCollectionFileCollectionFileNotFound, +) +from .collection_file_create import ( + CollectionFileCreate, + CollectionFileCreateCollectionFileCreateCollectionFile, + CollectionFileCreateCollectionFileCreateCollectionNotFound, ) from .collection_file_delete import ( CollectionFileDelete, @@ -90,12 +95,12 @@ ) from .collection_files import ( CollectionFiles, - CollectionFilesCollectionCreateCollection, - CollectionFilesCollectionCreateCollectionFiles, - CollectionFilesCollectionCreateCollectionFilesEdges, - CollectionFilesCollectionCreateCollectionFilesEdgesNode, - CollectionFilesCollectionCreateCollectionFilesPageInfo, - CollectionFilesCollectionCreateCollectionNotFound, + CollectionFilesCollectionCollection, + CollectionFilesCollectionCollectionFiles, + CollectionFilesCollectionCollectionFilesEdges, + CollectionFilesCollectionCollectionFilesEdgesNode, + CollectionFilesCollectionCollectionFilesPageInfo, + CollectionFilesCollectionCollectionNotFound, ) from .enums import ( AppDeploymentStatus, @@ -117,6 +122,7 @@ ButtonValue, CollectionDocumentReference, CollectionDocumentReferenceTags, + CollectionFileNotFound, CollectionFileReference, CollectionFileReferenceTags, CollectionNotFound, @@ -245,11 +251,15 @@ "CollectionDocumentsCollectionCollectionDocumentsPageInfo", "CollectionDocumentsCollectionCollectionNotFound", "CollectionFile", - "CollectionFileCollectionFileCreateCollectionFile", - "CollectionFileCollectionFileCreateCollectionNotFound", + "CollectionFileCollectionFileCollectionFile", + "CollectionFileCollectionFileCollectionFileNotFound", + "CollectionFileCreate", + "CollectionFileCreateCollectionFileCreateCollectionFile", + "CollectionFileCreateCollectionFileCreateCollectionNotFound", "CollectionFileDelete", "CollectionFileDeleteCollectionFileDeleteCollectionFile", "CollectionFileDeleteCollectionFileDeleteCollectionFileNotFound", + "CollectionFileNotFound", "CollectionFileReference", "CollectionFileReferenceTags", "CollectionFileTagAdd", @@ -259,12 +269,12 @@ "CollectionFileTagDeleteCollectionFileTagDeleteCollectionFile", "CollectionFileTagDeleteCollectionFileTagDeleteCollectionFileNotFound", "CollectionFiles", - "CollectionFilesCollectionCreateCollection", - "CollectionFilesCollectionCreateCollectionFiles", - "CollectionFilesCollectionCreateCollectionFilesEdges", - "CollectionFilesCollectionCreateCollectionFilesEdgesNode", - "CollectionFilesCollectionCreateCollectionFilesPageInfo", - "CollectionFilesCollectionCreateCollectionNotFound", + "CollectionFilesCollectionCollection", + "CollectionFilesCollectionCollectionFiles", + "CollectionFilesCollectionCollectionFilesEdges", + "CollectionFilesCollectionCollectionFilesEdgesNode", + "CollectionFilesCollectionCollectionFilesPageInfo", + "CollectionFilesCollectionCollectionNotFound", "CollectionNotFound", "CollectionReference", "ElementInput", diff --git a/python/src/numerous/generated/graphql/client.py b/python/src/numerous/generated/graphql/client.py index 948e19d6..fab4a5ea 100644 --- a/python/src/numerous/generated/graphql/client.py +++ b/python/src/numerous/generated/graphql/client.py @@ -15,6 +15,7 @@ from .collection_document_tag_delete import CollectionDocumentTagDelete from .collection_documents import CollectionDocuments from .collection_file import CollectionFile +from .collection_file_create import CollectionFileCreate from .collection_file_delete import CollectionFileDelete from .collection_file_tag_add import CollectionFileTagAdd from .collection_file_tag_delete import CollectionFileTagDelete @@ -540,12 +541,12 @@ async def collection_documents( data = self.get_data(response) return CollectionDocuments.model_validate(data) - async def collection_file( + async def collection_file_create( self, collection_id: str, key: str, **kwargs: Any - ) -> CollectionFile: + ) -> CollectionFileCreate: query = gql( """ - mutation CollectionFile($collectionID: ID!, $key: ID!) { + mutation CollectionFileCreate($collectionID: ID!, $key: ID!) { collectionFileCreate(collectionID: $collectionID, key: $key) { __typename ... on CollectionFile { @@ -568,25 +569,35 @@ async def collection_file( ) variables: Dict[str, object] = {"collectionID": collection_id, "key": key} response = await self.execute( - query=query, operation_name="CollectionFile", variables=variables, **kwargs + query=query, + operation_name="CollectionFileCreate", + variables=variables, + **kwargs ) data = self.get_data(response) - return CollectionFile.model_validate(data) + return CollectionFileCreate.model_validate(data) async def collection_file_delete( self, id: str, **kwargs: Any ) -> CollectionFileDelete: query = gql( """ - mutation collectionFileDelete($id: ID!) { + mutation CollectionFileDelete($id: ID!) { collectionFileDelete(id: $id) { __typename ... on CollectionFile { ...CollectionFileReference } + ... on CollectionFileNotFound { + ...CollectionFileNotFound + } } } + fragment CollectionFileNotFound on CollectionFileNotFound { + id + } + fragment CollectionFileReference on CollectionFile { id key @@ -602,7 +613,7 @@ async def collection_file_delete( variables: Dict[str, object] = {"id": id} response = await self.execute( query=query, - operation_name="collectionFileDelete", + operation_name="CollectionFileDelete", variables=variables, **kwargs ) @@ -611,8 +622,7 @@ async def collection_file_delete( async def collection_files( self, - organization_id: str, - key: str, + collection_id: str, tag: Union[Optional[TagInput], UnsetType] = UNSET, after: Union[Optional[str], UnsetType] = UNSET, first: Union[Optional[int], UnsetType] = UNSET, @@ -620,8 +630,8 @@ async def collection_files( ) -> CollectionFiles: query = gql( """ - mutation collectionFiles($organizationID: ID!, $key: ID!, $tag: TagInput, $after: ID, $first: Int) { - collectionCreate(organizationID: $organizationID, key: $key) { + query CollectionFiles($collectionID: ID!, $tag: TagInput, $after: ID, $first: Int) { + collection(id: $collectionID) { __typename ... on Collection { id @@ -657,14 +667,13 @@ async def collection_files( """ ) variables: Dict[str, object] = { - "organizationID": organization_id, - "key": key, + "collectionID": collection_id, "tag": tag, "after": after, "first": first, } response = await self.execute( - query=query, operation_name="collectionFiles", variables=variables, **kwargs + query=query, operation_name="CollectionFiles", variables=variables, **kwargs ) data = self.get_data(response) return CollectionFiles.model_validate(data) @@ -674,7 +683,7 @@ async def collection_file_tag_add( ) -> CollectionFileTagAdd: query = gql( """ - mutation collectionFileTagAdd($id: ID!, $tag: TagInput!) { + mutation CollectionFileTagAdd($id: ID!, $tag: TagInput!) { collectionFileTagAdd(id: $id, tag: $tag) { __typename ... on CollectionFile { @@ -698,7 +707,7 @@ async def collection_file_tag_add( variables: Dict[str, object] = {"id": id, "tag": tag} response = await self.execute( query=query, - operation_name="collectionFileTagAdd", + operation_name="CollectionFileTagAdd", variables=variables, **kwargs ) @@ -710,7 +719,7 @@ async def collection_file_tag_delete( ) -> CollectionFileTagDelete: query = gql( """ - mutation collectionFileTagDelete($id: ID!, $tag_key: String!) { + mutation CollectionFileTagDelete($id: ID!, $tag_key: String!) { collectionFileTagDelete(id: $id, key: $tag_key) { __typename ... on CollectionFile { @@ -734,9 +743,40 @@ async def collection_file_tag_delete( variables: Dict[str, object] = {"id": id, "tag_key": tag_key} response = await self.execute( query=query, - operation_name="collectionFileTagDelete", + operation_name="CollectionFileTagDelete", variables=variables, **kwargs ) data = self.get_data(response) return CollectionFileTagDelete.model_validate(data) + + async def collection_file(self, id: str, **kwargs: Any) -> CollectionFile: + query = gql( + """ + query CollectionFile($id: ID!) { + collectionFile(id: $id) { + __typename + ... on CollectionFile { + ...CollectionFileReference + } + } + } + + fragment CollectionFileReference on CollectionFile { + id + key + downloadURL + uploadURL + tags { + key + value + } + } + """ + ) + variables: Dict[str, object] = {"id": id} + response = await self.execute( + query=query, operation_name="CollectionFile", variables=variables, **kwargs + ) + data = self.get_data(response) + return CollectionFile.model_validate(data) diff --git a/python/src/numerous/generated/graphql/collection_file.py b/python/src/numerous/generated/graphql/collection_file.py index 37fd85d6..cc5d6cc6 100644 --- a/python/src/numerous/generated/graphql/collection_file.py +++ b/python/src/numerous/generated/graphql/collection_file.py @@ -1,7 +1,7 @@ # Generated by ariadne-codegen # Source: queries.gql -from typing import Literal, Union +from typing import Annotated, Literal, Optional, Union from pydantic import Field @@ -10,18 +10,23 @@ class CollectionFile(BaseModel): - collection_file_create: Union[ - "CollectionFileCollectionFileCreateCollectionFile", - "CollectionFileCollectionFileCreateCollectionNotFound", - ] = Field(alias="collectionFileCreate", discriminator="typename__") - - -class CollectionFileCollectionFileCreateCollectionFile(CollectionFileReference): + collection_file: Optional[ + Annotated[ + Union[ + "CollectionFileCollectionFileCollectionFile", + "CollectionFileCollectionFileCollectionFileNotFound", + ], + Field(discriminator="typename__"), + ] + ] = Field(alias="collectionFile") + + +class CollectionFileCollectionFileCollectionFile(CollectionFileReference): typename__: Literal["CollectionFile"] = Field(alias="__typename") -class CollectionFileCollectionFileCreateCollectionNotFound(BaseModel): - typename__: Literal["CollectionNotFound"] = Field(alias="__typename") +class CollectionFileCollectionFileCollectionFileNotFound(BaseModel): + typename__: Literal["CollectionFileNotFound"] = Field(alias="__typename") CollectionFile.model_rebuild() diff --git a/python/src/numerous/generated/graphql/collection_file_delete.py b/python/src/numerous/generated/graphql/collection_file_delete.py index 38e8e286..7eb4d3c5 100644 --- a/python/src/numerous/generated/graphql/collection_file_delete.py +++ b/python/src/numerous/generated/graphql/collection_file_delete.py @@ -6,7 +6,7 @@ from pydantic import Field from .base_model import BaseModel -from .fragments import CollectionFileReference +from .fragments import CollectionFileNotFound, CollectionFileReference class CollectionFileDelete(BaseModel): @@ -20,7 +20,9 @@ class CollectionFileDeleteCollectionFileDeleteCollectionFile(CollectionFileRefer typename__: Literal["CollectionFile"] = Field(alias="__typename") -class CollectionFileDeleteCollectionFileDeleteCollectionFileNotFound(BaseModel): +class CollectionFileDeleteCollectionFileDeleteCollectionFileNotFound( + CollectionFileNotFound +): typename__: Literal["CollectionFileNotFound"] = Field(alias="__typename") diff --git a/python/src/numerous/generated/graphql/collection_files.py b/python/src/numerous/generated/graphql/collection_files.py index c75144e5..036b73e4 100644 --- a/python/src/numerous/generated/graphql/collection_files.py +++ b/python/src/numerous/generated/graphql/collection_files.py @@ -1,7 +1,7 @@ # Generated by ariadne-codegen # Source: queries.gql -from typing import List, Literal, Optional, Union +from typing import Annotated, List, Literal, Optional, Union from pydantic import Field @@ -10,44 +10,49 @@ class CollectionFiles(BaseModel): - collection_create: Union[ - "CollectionFilesCollectionCreateCollection", - "CollectionFilesCollectionCreateCollectionNotFound", - ] = Field(alias="collectionCreate", discriminator="typename__") - - -class CollectionFilesCollectionCreateCollection(BaseModel): + collection: Optional[ + Annotated[ + Union[ + "CollectionFilesCollectionCollection", + "CollectionFilesCollectionCollectionNotFound", + ], + Field(discriminator="typename__"), + ] + ] + + +class CollectionFilesCollectionCollection(BaseModel): typename__: Literal["Collection"] = Field(alias="__typename") id: str key: str - files: "CollectionFilesCollectionCreateCollectionFiles" + files: "CollectionFilesCollectionCollectionFiles" -class CollectionFilesCollectionCreateCollectionFiles(BaseModel): - edges: List["CollectionFilesCollectionCreateCollectionFilesEdges"] - page_info: "CollectionFilesCollectionCreateCollectionFilesPageInfo" = Field( +class CollectionFilesCollectionCollectionFiles(BaseModel): + edges: List["CollectionFilesCollectionCollectionFilesEdges"] + page_info: "CollectionFilesCollectionCollectionFilesPageInfo" = Field( alias="pageInfo" ) -class CollectionFilesCollectionCreateCollectionFilesEdges(BaseModel): - node: "CollectionFilesCollectionCreateCollectionFilesEdgesNode" +class CollectionFilesCollectionCollectionFilesEdges(BaseModel): + node: "CollectionFilesCollectionCollectionFilesEdgesNode" -class CollectionFilesCollectionCreateCollectionFilesEdgesNode(CollectionFileReference): +class CollectionFilesCollectionCollectionFilesEdgesNode(CollectionFileReference): typename__: Literal["CollectionFile"] = Field(alias="__typename") -class CollectionFilesCollectionCreateCollectionFilesPageInfo(BaseModel): +class CollectionFilesCollectionCollectionFilesPageInfo(BaseModel): has_next_page: bool = Field(alias="hasNextPage") end_cursor: Optional[str] = Field(alias="endCursor") -class CollectionFilesCollectionCreateCollectionNotFound(BaseModel): +class CollectionFilesCollectionCollectionNotFound(BaseModel): typename__: Literal["CollectionNotFound"] = Field(alias="__typename") CollectionFiles.model_rebuild() -CollectionFilesCollectionCreateCollection.model_rebuild() -CollectionFilesCollectionCreateCollectionFiles.model_rebuild() -CollectionFilesCollectionCreateCollectionFilesEdges.model_rebuild() +CollectionFilesCollectionCollection.model_rebuild() +CollectionFilesCollectionCollectionFiles.model_rebuild() +CollectionFilesCollectionCollectionFilesEdges.model_rebuild() diff --git a/python/src/numerous/generated/graphql/fragments.py b/python/src/numerous/generated/graphql/fragments.py index bd768251..3940941b 100644 --- a/python/src/numerous/generated/graphql/fragments.py +++ b/python/src/numerous/generated/graphql/fragments.py @@ -24,6 +24,10 @@ class CollectionDocumentReferenceTags(BaseModel): value: str +class CollectionFileNotFound(BaseModel): + id: str + + class CollectionFileReference(BaseModel): id: str key: str @@ -109,6 +113,7 @@ class TextFieldValue(BaseModel): ButtonValue.model_rebuild() CollectionDocumentReference.model_rebuild() +CollectionFileNotFound.model_rebuild() CollectionFileReference.model_rebuild() CollectionNotFound.model_rebuild() CollectionReference.model_rebuild() diff --git a/python/src/numerous/user.py b/python/src/numerous/user.py index 2d4c7bde..642e8414 100644 --- a/python/src/numerous/user.py +++ b/python/src/numerous/user.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from typing import Any, Optional -from numerous.collection import NumerousCollection, collection +from numerous.collection import CollectionReference, collection from numerous.collection._client import Client @@ -23,7 +23,7 @@ class User: _client: Optional[Client] = None @property - def collection(self) -> NumerousCollection: + def collection(self) -> CollectionReference: """ A user's collection. diff --git a/python/tests/test_collections_documents.py b/python/tests/test_collection_documents.py similarity index 98% rename from python/tests/test_collections_documents.py rename to python/tests/test_collection_documents.py index 10bbb532..37215925 100644 --- a/python/tests/test_collections_documents.py +++ b/python/tests/test_collection_documents.py @@ -4,7 +4,7 @@ from numerous import collection from numerous._client._graphql_client import COLLECTED_OBJECTS_NUMBER, GraphQLClient -from numerous.collection.numerous_document import NumerousDocument +from numerous.collection.document_reference import DocumentReference from numerous.generated.graphql.client import Client as GQLClient from numerous.generated.graphql.collection_collections import CollectionCollections from numerous.generated.graphql.collection_create import CollectionCreate @@ -221,7 +221,7 @@ def test_collection_document_returns_new_document() -> None: COLLECTION_DOCUMENT_KEY, **HEADERS_WITH_AUTHORIZATION, ) - assert isinstance(document, NumerousDocument) + assert isinstance(document, DocumentReference) assert document.exists is False @@ -243,7 +243,7 @@ def test_collection_document_returns_existing_document() -> None: COLLECTION_DOCUMENT_KEY, **HEADERS_WITH_AUTHORIZATION, ) - assert isinstance(document, NumerousDocument) + assert isinstance(document, DocumentReference) assert document.exists @@ -258,7 +258,7 @@ def test_collection_document_set_data_uploads_document() -> None: ) test_collection = collection(COLLECTION_NAME, _client) document = test_collection.document(COLLECTION_DOCUMENT_KEY) - assert isinstance(document, NumerousDocument) + assert isinstance(document, DocumentReference) assert document.exists is False document.set({"test": "test"}) @@ -286,7 +286,7 @@ def test_collection_document_get_returns_dict() -> None: data = document.get() - assert isinstance(document, NumerousDocument) + assert isinstance(document, DocumentReference) gql.collection_document.assert_has_calls( [ call( diff --git a/python/tests/test_collection_files.py b/python/tests/test_collection_files.py new file mode 100644 index 00000000..d9138a20 --- /dev/null +++ b/python/tests/test_collection_files.py @@ -0,0 +1,566 @@ +from pathlib import Path +from typing import Any, Generator +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from numerous import collection +from numerous._client._graphql_client import COLLECTED_OBJECTS_NUMBER, GraphQLClient +from numerous.collection.file_reference import FileReference +from numerous.generated.graphql.client import Client as GQLClient +from numerous.generated.graphql.collection_create import CollectionCreate +from numerous.generated.graphql.collection_file import CollectionFile +from numerous.generated.graphql.collection_file_create import CollectionFileCreate +from numerous.generated.graphql.collection_file_delete import CollectionFileDelete +from numerous.generated.graphql.collection_file_tag_add import CollectionFileTagAdd +from numerous.generated.graphql.collection_file_tag_delete import ( + CollectionFileTagDelete, +) +from numerous.generated.graphql.collection_files import CollectionFiles +from numerous.generated.graphql.input_types import TagInput +from numerous.jsonbase64 import dict_to_base64 + + +ORGANIZATION_ID = "test-org-id" +COLLECTION_KEY = "test-collection-key" +NESTED_COLLECTION_ID = "nested_test_collection" +COLLECTION_REFERENCE_KEY = "test_key" +COLLECTION_REFERENCE_ID = "test_id" +NESTED_COLLECTION_REFERENCE_KEY = "nested_test_key" +NESTED_COLLECTION_REFERENCE_ID = "nested_test_id" +COLLECTION_DOCUMENT_KEY = "test_document" +COLLECTION_FILE_KEY = "test-file.txt" +DOCUMENT_DATA = {"test": "test"} +BASE64_DOCUMENT_DATA = dict_to_base64(DOCUMENT_DATA) +TEST_FILE_ID = "ce5aba38-842d-4ee0-877b-4af9d426c848" +HEADERS_WITH_AUTHORIZATION = {"headers": {"Authorization": "Bearer token"}} +_REQUEST_TIMEOUT_SECONDS = 1.5 + + +TEST_DOWNLOAD_URL = "http://127.0.0.1:8082/download/collection_files/" + TEST_FILE_ID +TEST_UPLOAD_URL = "http://127.0.0.1:8082/upload/collection_files/" + TEST_FILE_ID + + +TEST_FILE_TEXT_CONTENT = "File content 1;2;3;4;\n1;2;3;4" +TEST_FILE_BYTES_CONTENT = TEST_FILE_TEXT_CONTENT.encode() + + +def _collection_create_collection_reference(key: str, ref_id: str) -> CollectionCreate: + return CollectionCreate.model_validate( + {"collectionCreate": {"typename__": "Collection", "key": key, "id": ref_id}} + ) + + +def _collection_file_tag_delete_found(file_id: str) -> CollectionFileTagDelete: + return CollectionFileTagDelete.model_validate( + { + "collectionFileTagDelete": _collection_file_data( + file_id, + "t22", + "http://127.0.0.1:8082/download/collection_files/0ac6436b-f044-4616-97c6-2bb5a8dbf7a1", + "http://127.0.0.1:8082/upload/collection_files/0ac6436b-f044-4616-97c6-2bb5a8dbf7a1", + ) + } + ) + + +def _collection_file_tag_add_found(file_id: str) -> CollectionFileTagAdd: + return CollectionFileTagAdd.model_validate( + { + "collectionFileTagAdd": _collection_file_data( + file_id, + "t22", + TEST_DOWNLOAD_URL, + TEST_UPLOAD_URL, + tags={"key": "test"}, + ) + } + ) + + +def _collection_file_delete_found(file_id: str) -> CollectionFileDelete: + return CollectionFileDelete.model_validate( + { + "collectionFileDelete": _collection_file_data( + file_id, + "t21", + TEST_DOWNLOAD_URL, + TEST_UPLOAD_URL, + ) + } + ) + + +def _collection_files_reference() -> CollectionFiles: + return CollectionFiles.model_validate( + { + "collection": { + "__typename": "Collection", + "id": "0d2f82fa-1546-49a4-a034-3392eefc3e4e", + "key": "t1", + "files": { + "edges": [ + { + "node": _collection_file_data( + "0ac6436b-f044-4616-97c6-2bb5a8dbf7a1", + "t22", + TEST_DOWNLOAD_URL, + TEST_UPLOAD_URL, + ) + }, + { + "node": _collection_file_data( + "14ea9afd-41ba-42eb-8a55-314d161e32c6", + "t21", + "http://127.0.0.1:8082/download/collection_files/14ea9afd-41ba-42eb-8a55-314d161e32c6", + "http://127.0.0.1:8082/upload/collection_files/14ea9afd-41ba-42eb-8a55-314d161e32c6", + ), + }, + ], + "pageInfo": { + "hasNextPage": "false", + "endCursor": "14ea9afd-41ba-42eb-8a55-314d161e32c6", + }, + }, + } + } + ) + + +def _collection_file_reference( + key: str, tags: dict[str, str] | None = None +) -> CollectionFile: + return CollectionFile.model_validate( + { + "collectionFile": _collection_file_data( + TEST_FILE_ID, key, TEST_DOWNLOAD_URL, TEST_UPLOAD_URL, tags + ) + } + ) + + +def _collection_file_reference_not_found() -> CollectionFile: + return CollectionFile.model_validate( + {"collectionFile": {"__typename": "CollectionFileNotFound", "id": TEST_FILE_ID}} + ) + + +def _collection_file_create_reference(key: str) -> CollectionFileCreate: + return CollectionFileCreate.model_validate( + { + "collectionFileCreate": _collection_file_data( + TEST_FILE_ID, key, TEST_DOWNLOAD_URL, TEST_UPLOAD_URL + ) + } + ) + + +def _collection_file_reference_no_urls(key: str) -> CollectionFileCreate: + return CollectionFileCreate.model_validate( + {"collectionFileCreate": _collection_file_data(TEST_FILE_ID, key)} + ) + + +def _collection_file_data( + file_id: str, + key: str, + download_url: str | None = None, + upload_url: str | None = None, + tags: dict[str, str] | None = None, +) -> dict[str, Any]: + return { + "__typename": "CollectionFile", + "id": file_id, + "key": key, + "downloadURL": download_url or "", + "uploadURL": upload_url or "", + "tags": [{"key": key, "value": value} for key, value in tags.items()] + if tags + else [], + } + + +@pytest.fixture +def mock_get() -> Generator[MagicMock, None, None]: + with patch("requests.get") as m: + yield m + + +@pytest.fixture +def mock_put() -> Generator[MagicMock, None, None]: + with patch("requests.put") as m: + yield m + + +@pytest.fixture(autouse=True) +def _set_env_vars(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("NUMEROUS_API_URL", "url_value") + monkeypatch.setenv("NUMEROUS_ORGANIZATION_ID", ORGANIZATION_ID) + monkeypatch.setenv("NUMEROUS_API_ACCESS_TOKEN", "token") + + +@pytest.fixture +def base_path(tmp_path: Path) -> Path: + return tmp_path + + +def test_exists_is_true_when_file_exists_and_has_download_url() -> None: + gql = Mock(GQLClient) + client = GraphQLClient(gql) + gql.collection_create.return_value = _collection_create_collection_reference( + COLLECTION_REFERENCE_KEY, COLLECTION_REFERENCE_ID + ) + gql.collection_file_create.return_value = _collection_file_reference_no_urls( + COLLECTION_FILE_KEY + ) + gql.collection_file.return_value = _collection_file_reference(COLLECTION_FILE_KEY) + + col = collection(COLLECTION_KEY, client) + file = col.file(COLLECTION_FILE_KEY) + + gql.collection_file_create.assert_called_once_with( + COLLECTION_REFERENCE_ID, + COLLECTION_FILE_KEY, + **HEADERS_WITH_AUTHORIZATION, + ) + assert file.exists is True + + +def test_file_returns_file_exists_after_load(mock_get: MagicMock) -> None: + gql = Mock(GQLClient) + client = GraphQLClient(gql) + gql.collection_create.return_value = _collection_create_collection_reference( + COLLECTION_REFERENCE_KEY, COLLECTION_REFERENCE_ID + ) + gql.collection_file_create.return_value = _collection_file_create_reference( + COLLECTION_FILE_KEY + ) + gql.collection_file.return_value = _collection_file_reference(COLLECTION_FILE_KEY) + mock_get.return_value.status_code = 200 + mock_get.return_value.content = TEST_FILE_BYTES_CONTENT + col = collection(COLLECTION_KEY, client) + file = col.file(COLLECTION_FILE_KEY) + + gql.collection_file_create.assert_called_once_with( + COLLECTION_REFERENCE_ID, + COLLECTION_FILE_KEY, + **HEADERS_WITH_AUTHORIZATION, + ) + assert file.exists is True + + +def test_read_file_returns_expected_text( + mock_get: MagicMock, +) -> None: + gql = Mock(GQLClient) + client = GraphQLClient(gql) + gql.collection_create.return_value = _collection_create_collection_reference( + COLLECTION_REFERENCE_KEY, COLLECTION_REFERENCE_ID + ) + gql.collection_file_create.return_value = _collection_file_create_reference( + COLLECTION_FILE_KEY + ) + gql.collection_file.return_value = _collection_file_reference(COLLECTION_FILE_KEY) + mock_get.return_value.status_code = 200 + mock_get.return_value.text = TEST_FILE_TEXT_CONTENT + + col = collection(COLLECTION_KEY, client) + + file = col.file(COLLECTION_FILE_KEY) + text = file.read_text() + + mock_get.assert_called_once_with( + TEST_DOWNLOAD_URL, timeout=_REQUEST_TIMEOUT_SECONDS + ) + gql.collection_file_create.assert_called_once_with( + COLLECTION_REFERENCE_ID, + COLLECTION_FILE_KEY, + **HEADERS_WITH_AUTHORIZATION, + ) + assert text == TEST_FILE_TEXT_CONTENT + + +def test_read_bytes_returns_expected_bytes(mock_get: MagicMock) -> None: + gql = Mock(GQLClient) + client = GraphQLClient(gql) + gql.collection_create.return_value = _collection_create_collection_reference( + COLLECTION_REFERENCE_KEY, COLLECTION_REFERENCE_ID + ) + gql.collection_file_create.return_value = _collection_file_create_reference( + COLLECTION_FILE_KEY + ) + gql.collection_file.return_value = _collection_file_reference(COLLECTION_FILE_KEY) + mock_get.return_value.status_code = 200 + mock_get.return_value.content = TEST_FILE_BYTES_CONTENT + + col = collection(COLLECTION_KEY, client) + file = col.file(COLLECTION_FILE_KEY) + bytes_data = file.read_bytes() + + mock_get.assert_called_once_with( + TEST_DOWNLOAD_URL, timeout=_REQUEST_TIMEOUT_SECONDS + ) + gql.collection_file_create.assert_called_once_with( + COLLECTION_REFERENCE_ID, + COLLECTION_FILE_KEY, + **HEADERS_WITH_AUTHORIZATION, + ) + + assert bytes_data == TEST_FILE_BYTES_CONTENT + + +def test_open_read_returns_expected_file_content( + mock_get: MagicMock, +) -> None: + gql = Mock(GQLClient) + client = GraphQLClient(gql) + gql.collection_create.return_value = _collection_create_collection_reference( + COLLECTION_REFERENCE_KEY, COLLECTION_REFERENCE_ID + ) + gql.collection_file_create.return_value = _collection_file_create_reference( + COLLECTION_FILE_KEY + ) + gql.collection_file.return_value = _collection_file_reference(COLLECTION_FILE_KEY) + mock_get.return_value.status_code = 200 + mock_get.return_value.content = TEST_FILE_BYTES_CONTENT + + col = collection(COLLECTION_KEY, client) + file = col.file(COLLECTION_FILE_KEY) + with file.open() as fd: + bytes_data = fd.read() + + mock_get.assert_called_once_with( + TEST_DOWNLOAD_URL, timeout=_REQUEST_TIMEOUT_SECONDS + ) + gql.collection_file_create.assert_called_once_with( + COLLECTION_REFERENCE_ID, + COLLECTION_FILE_KEY, + **HEADERS_WITH_AUTHORIZATION, + ) + + assert bytes_data == TEST_FILE_BYTES_CONTENT + + +def test_save_with_bytes_makes_put_request(mock_put: MagicMock) -> None: + gql = Mock(GQLClient) + client = GraphQLClient(gql) + gql.collection_create.return_value = _collection_create_collection_reference( + COLLECTION_REFERENCE_KEY, COLLECTION_REFERENCE_ID + ) + gql.collection_file_create.return_value = _collection_file_create_reference( + COLLECTION_FILE_KEY + ) + gql.collection_file.return_value = _collection_file_reference(COLLECTION_FILE_KEY) + mock_put.return_value.status_code = 200 + + col = collection(COLLECTION_KEY, client) + file = col.file(COLLECTION_FILE_KEY) + file.save(TEST_FILE_BYTES_CONTENT) + + mock_put.assert_called_once_with( + TEST_UPLOAD_URL, + files={"file": TEST_FILE_BYTES_CONTENT}, + timeout=_REQUEST_TIMEOUT_SECONDS, + ) + gql.collection_file_create.assert_called_once_with( + COLLECTION_REFERENCE_ID, + COLLECTION_FILE_KEY, + **HEADERS_WITH_AUTHORIZATION, + ) + + assert isinstance(file, FileReference) + + +def test_save_makes_expected_put_request( + mock_get: MagicMock, mock_put: MagicMock +) -> None: + gql = Mock(GQLClient) + client = GraphQLClient(gql) + gql.collection_create.return_value = _collection_create_collection_reference( + COLLECTION_REFERENCE_KEY, COLLECTION_REFERENCE_ID + ) + gql.collection_file_create.return_value = _collection_file_create_reference( + COLLECTION_FILE_KEY + ) + gql.collection_file.return_value = _collection_file_reference(COLLECTION_FILE_KEY) + + mock_put.return_value.status_code = 200 + mock_get.return_value.status_code = 200 + mock_get.return_value.content = TEST_FILE_TEXT_CONTENT + + col = collection(COLLECTION_KEY, client) + + file = col.file(COLLECTION_FILE_KEY) + file.save(TEST_FILE_TEXT_CONTENT) + + mock_put.assert_called_once_with( + TEST_UPLOAD_URL, + files={"file": TEST_FILE_BYTES_CONTENT}, + timeout=_REQUEST_TIMEOUT_SECONDS, + ) + gql.collection_file_create.assert_called_once_with( + COLLECTION_REFERENCE_ID, + COLLECTION_FILE_KEY, + **HEADERS_WITH_AUTHORIZATION, + ) + + +def test_save_file_makes_expected_put_request(mock_put: MagicMock) -> None: + gql = Mock(GQLClient) + client = GraphQLClient(gql) + gql.collection_create.return_value = _collection_create_collection_reference( + COLLECTION_REFERENCE_KEY, COLLECTION_REFERENCE_ID + ) + gql.collection_file_create.return_value = _collection_file_create_reference( + COLLECTION_FILE_KEY + ) + gql.collection_file.return_value = _collection_file_reference(COLLECTION_FILE_KEY) + mock_put.return_value.status_code = 200 + + col = collection(COLLECTION_KEY, client) + col.save_file(COLLECTION_FILE_KEY, TEST_FILE_TEXT_CONTENT) + + mock_put.assert_called_once_with( + TEST_UPLOAD_URL, + files={"file": TEST_FILE_BYTES_CONTENT}, + timeout=_REQUEST_TIMEOUT_SECONDS, + ) + gql.collection_file_create.assert_called_once_with( + COLLECTION_REFERENCE_ID, + COLLECTION_FILE_KEY, + **HEADERS_WITH_AUTHORIZATION, + ) + + +def test_delete_calls_expected_mutation() -> None: + gql = Mock(GQLClient) + client = GraphQLClient(gql) + gql.collection_create.return_value = _collection_create_collection_reference( + COLLECTION_REFERENCE_KEY, COLLECTION_REFERENCE_ID + ) + gql.collection_file_create.return_value = _collection_file_create_reference( + COLLECTION_FILE_KEY + ) + gql.collection_file_delete.return_value = _collection_file_delete_found( + TEST_FILE_ID + ) + + col = collection(COLLECTION_KEY, client) + file = col.file(COLLECTION_FILE_KEY) + file.delete() + + gql.collection_file_delete.assert_called_once_with( + TEST_FILE_ID, **HEADERS_WITH_AUTHORIZATION + ) + + +def test_collection_files_makes_expected_query_and_returns_expected_file_count() -> ( + None +): + gql = Mock(GQLClient) + client = GraphQLClient(gql) + gql.collection_create.return_value = _collection_create_collection_reference( + COLLECTION_REFERENCE_KEY, COLLECTION_REFERENCE_ID + ) + gql.collection_files.return_value = _collection_files_reference() + + col = collection(COLLECTION_KEY, client) + result = list(col.files()) + + expected_number_of_files = 2 + assert len(result) == expected_number_of_files + gql.collection_files.assert_called_once_with( + COLLECTION_REFERENCE_ID, + None, + after="", + first=COLLECTED_OBJECTS_NUMBER, + **HEADERS_WITH_AUTHORIZATION, + ) + + +def test_tag_add_makes_expected_mutation() -> None: + gql = Mock(GQLClient) + client = GraphQLClient(gql) + gql.collection_create.return_value = _collection_create_collection_reference( + COLLECTION_REFERENCE_KEY, COLLECTION_REFERENCE_ID + ) + gql.collection_file_create.return_value = _collection_file_create_reference( + COLLECTION_FILE_KEY + ) + gql.collection_file_tag_add.return_value = _collection_file_tag_add_found( + TEST_FILE_ID + ) + + col = collection(COLLECTION_KEY, client) + file = col.file(COLLECTION_FILE_KEY) + file.tag("key", "test") + + gql.collection_file_tag_add.assert_called_once_with( + TEST_FILE_ID, TagInput(key="key", value="test"), **HEADERS_WITH_AUTHORIZATION + ) + + +def test_tag_delete_makes_expected_mutation() -> None: + gql = Mock(GQLClient) + client = GraphQLClient(gql) + gql.collection_create.return_value = _collection_create_collection_reference( + COLLECTION_REFERENCE_KEY, COLLECTION_REFERENCE_ID + ) + gql.collection_file_create.return_value = _collection_file_create_reference( + COLLECTION_FILE_KEY + ) + gql.collection_file_tag_delete.return_value = _collection_file_tag_delete_found( + TEST_FILE_ID + ) + tag_key = "key" + + col = collection(COLLECTION_KEY, client) + file = col.file(COLLECTION_FILE_KEY) + file.tag_delete(tag_key) + + gql.collection_file_tag_delete.assert_called_once_with( + TEST_FILE_ID, tag_key, **HEADERS_WITH_AUTHORIZATION + ) + + +def test_collection_files_passes_tag_filter_on_to_client() -> None: + gql = Mock(GQLClient) + client = GraphQLClient(gql) + gql.collection_create.return_value = _collection_create_collection_reference( + COLLECTION_REFERENCE_KEY, COLLECTION_REFERENCE_ID + ) + gql.collection_files.return_value = _collection_files_reference() + tag_key = "key" + tag_value = "value" + + col = collection(COLLECTION_KEY, client) + list(col.files(tag_key=tag_key, tag_value=tag_value)) + + gql.collection_files.assert_called_once_with( + COLLECTION_REFERENCE_ID, + TagInput(key=tag_key, value=tag_value), + after="", + first=COLLECTED_OBJECTS_NUMBER, + **HEADERS_WITH_AUTHORIZATION, + ) + + +def test_tags_property_queries_and_returns_expected_tags() -> None: + gql = Mock(GQLClient) + client = GraphQLClient(gql) + gql.collection_create.return_value = _collection_create_collection_reference( + COLLECTION_REFERENCE_KEY, COLLECTION_REFERENCE_ID + ) + gql.collection_file_create.return_value = _collection_file_create_reference( + COLLECTION_FILE_KEY + ) + expected_tags = {"tag_1_key": "tag_1_value", "tag_2_key": "tag_2_value"} + gql.collection_file.return_value = _collection_file_reference( + COLLECTION_FILE_KEY, tags=expected_tags + ) + + col = collection(COLLECTION_KEY, client) + file = col.file(COLLECTION_FILE_KEY) + tags = file.tags + + assert tags == expected_tags diff --git a/python/tests/test_collections_collections.py b/python/tests/test_collections.py similarity index 100% rename from python/tests/test_collections_collections.py rename to python/tests/test_collections.py diff --git a/python/tests/test_collections_files.py b/python/tests/test_collections_files.py deleted file mode 100644 index 29bc0bd5..00000000 --- a/python/tests/test_collections_files.py +++ /dev/null @@ -1,658 +0,0 @@ -from pathlib import Path -from unittest.mock import MagicMock, Mock, patch - -import pytest - -from numerous import collection -from numerous._client._graphql_client import COLLECTED_OBJECTS_NUMBER, GraphQLClient -from numerous.collection.numerous_file import NumerousFile -from numerous.generated.graphql.client import Client as GQLClient -from numerous.generated.graphql.collection_create import CollectionCreate -from numerous.generated.graphql.collection_file import CollectionFile -from numerous.generated.graphql.collection_file_delete import CollectionFileDelete -from numerous.generated.graphql.collection_file_tag_add import CollectionFileTagAdd -from numerous.generated.graphql.collection_file_tag_delete import ( - CollectionFileTagDelete, -) -from numerous.generated.graphql.collection_files import CollectionFiles -from numerous.generated.graphql.input_types import TagInput -from numerous.jsonbase64 import dict_to_base64 - - -ORGANIZATION_ID = "test_org" -COLLECTION_NAME = "test_collection" -NESTED_COLLECTION_ID = "nested_test_collection" -COLLECTION_REFERENCE_KEY = "test_key" -COLLECTION_REFERENCE_ID = "test_id" -NESTED_COLLECTION_REFERENCE_KEY = "nested_test_key" -NESTED_COLLECTION_REFERENCE_ID = "nested_test_id" -COLLECTION_DOCUMENT_KEY = "test_document" -COLLECTION_FILE_KEY = "test-file.txt" -DOCUMENT_DATA = {"test": "test"} -BASE64_DOCUMENT_DATA = dict_to_base64(DOCUMENT_DATA) -FILE_ID = "ce5aba38-842d-4ee0-877b-4af9d426c848" -HEADERS_WITH_AUTHORIZATION = {"headers": {"Authorization": "Bearer token"}} -_REQUEST_TIMEOUT_SECONDS_ = 1.5 - - -_TEST_DOWNLOAD_URL_ = "http://127.0.0.1:8082/download/collection_files/" + FILE_ID -_TEST_UPLOAD_URL_ = "http://127.0.0.1:8082/upload/collection_files/" + FILE_ID - - -_TEST_FILE_CONTENT_TEXT_ = "File content 1;2;3;4;\n1;2;3;4" -_TEST_FILE_CONTENT_TEXT_BYTE_ = _TEST_FILE_CONTENT_TEXT_.encode() - - -def _collection_create_collection_reference(key: str, ref_id: str) -> CollectionCreate: - return CollectionCreate.model_validate( - {"collectionCreate": {"typename__": "Collection", "key": key, "id": ref_id}} - ) - - -def _collection_file_tag_delete_found(_id: str) -> CollectionFileTagDelete: - return CollectionFileTagDelete.model_validate( - { - "collectionFileTagDelete": { - "__typename": "CollectionFile", - "id": "0ac6436b-f044-4616-97c6-2bb5a8dbf7a1", - "key": "t22", - "downloadURL": "http://127.0.0.1:8082/download/collection_files/0ac6436b-f044-4616-97c6-2bb5a8dbf7a1", - "uploadURL": "http://127.0.0.1:8082/upload/collection_files/0ac6436b-f044-4616-97c6-2bb5a8dbf7a1", - "tags": [], - } - } - ) - - -def _collection_file_tag_add_found(_id: str) -> CollectionFileTagAdd: - return CollectionFileTagAdd.model_validate( - { - "collectionFileTagAdd": { - "__typename": "CollectionFile", - "id": "0ac6436b-f044-4616-97c6-2bb5a8dbf7a1", - "key": "t22", - "downloadURL": _TEST_DOWNLOAD_URL_, - "uploadURL": _TEST_UPLOAD_URL_, - "tags": [{"key": "key", "value": "test"}], - } - } - ) - - -def _collection_file_delete_found(_id: str) -> CollectionFileDelete: - return CollectionFileDelete.model_validate( - { - "collectionFileDelete": { - "__typename": "CollectionFile", - "id": _id, - "key": "t21", - "downloadURL": _TEST_DOWNLOAD_URL_, - "uploadURL": _TEST_UPLOAD_URL_, - "tags": [], - } - } - ) - - -def _collection_files_reference() -> CollectionFiles: - return CollectionFiles.model_validate( - { - "collectionCreate": { - "__typename": "Collection", - "id": "0d2f82fa-1546-49a4-a034-3392eefc3e4e", - "key": "t1", - "files": { - "edges": [ - { - "node": { - "__typename": "CollectionFile", - "id": "0ac6436b-f044-4616-97c6-2bb5a8dbf7a1", - "key": "t22", - "downloadURL": _TEST_DOWNLOAD_URL_, - "uploadURL": _TEST_UPLOAD_URL_, - "tags": [], - } - }, - { - "node": { - "__typename": "CollectionFile", - "id": "14ea9afd-41ba-42eb-8a55-314d161e32c6", - "key": "t21", - "downloadURL": "http://127.0.0.1:8082/download/collection_files/14ea9afd-41ba-42eb-8a55-314d161e32c6", - "uploadURL": "http://127.0.0.1:8082/upload/collection_files/14ea9afd-41ba-42eb-8a55-314d161e32c6", - "tags": [], - } - }, - ], - "pageInfo": { - "hasNextPage": "false", - "endCursor": "14ea9afd-41ba-42eb-8a55-314d161e32c6", - }, - }, - } - } - ) - - -def _collection_file_reference(key: str) -> CollectionFile: - return CollectionFile.model_validate( - { - "collectionFileCreate": { - "__typename": "CollectionFile", - "id": FILE_ID, - "key": key, - "downloadURL": _TEST_DOWNLOAD_URL_, - "uploadURL": _TEST_UPLOAD_URL_, - "tags": [], - } - } - ) - - -def _collection_file_reference_no_urls(key: str) -> CollectionFile: - return CollectionFile.model_validate( - { - "collectionFileCreate": { - "__typename": "CollectionFile", - "id": FILE_ID, - "key": key, - "downloadURL": "", - "uploadURL": "", - "tags": [], - } - } - ) - - -@pytest.fixture(autouse=True) -def _set_env_vars(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("NUMEROUS_API_URL", "url_value") - monkeypatch.setenv("NUMEROUS_ORGANIZATION_ID", ORGANIZATION_ID) - monkeypatch.setenv("NUMEROUS_API_ACCESS_TOKEN", "token") - - -@pytest.fixture -def base_path(tmp_path: Path) -> Path: - return tmp_path - - -@patch("requests.get") -def test_collection_file_new_file_returns_exists_false(mock_get: MagicMock) -> None: - gql = Mock(GQLClient) - _client = GraphQLClient(gql) - gql.collection_create.return_value = _collection_create_collection_reference( - COLLECTION_REFERENCE_KEY, COLLECTION_REFERENCE_ID - ) - - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.content = "" - mock_get.return_value = mock_response - - gql.collection_file.return_value = _collection_file_reference_no_urls( - COLLECTION_FILE_KEY - ) - - test_collection = collection(COLLECTION_NAME, _client) - - fileref = test_collection.file(COLLECTION_FILE_KEY) - - gql.collection_file.assert_called_once_with( - COLLECTION_REFERENCE_ID, - COLLECTION_FILE_KEY, - **HEADERS_WITH_AUTHORIZATION, - ) - assert isinstance(fileref, NumerousFile) - assert fileref.exists is False - - -@patch("requests.get") -def test_collection_file_returns_file_exists_after_load(mock_get: MagicMock) -> None: - gql = Mock(GQLClient) - _client = GraphQLClient(gql) - gql.collection_create.return_value = _collection_create_collection_reference( - COLLECTION_REFERENCE_KEY, COLLECTION_REFERENCE_ID - ) - - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.content = _TEST_FILE_CONTENT_TEXT_BYTE_ - mock_get.return_value = mock_response - - gql.collection_file.return_value = _collection_file_reference(COLLECTION_FILE_KEY) - - test_collection = collection(COLLECTION_NAME, _client) - - fileref = test_collection.file(COLLECTION_FILE_KEY) - - gql.collection_file.assert_called_once_with( - COLLECTION_REFERENCE_ID, - COLLECTION_FILE_KEY, - **HEADERS_WITH_AUTHORIZATION, - ) - - assert isinstance(fileref, NumerousFile) - assert fileref.exists is True - - -@patch("requests.get") -def test_collection_file_returns_file_text_content_after_load( - mock_get: MagicMock, -) -> None: - gql = Mock(GQLClient) - _client = GraphQLClient(gql) - gql.collection_create.return_value = _collection_create_collection_reference( - COLLECTION_REFERENCE_KEY, COLLECTION_REFERENCE_ID - ) - gql.collection_file.return_value = _collection_file_reference(COLLECTION_FILE_KEY) - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.content = _TEST_FILE_CONTENT_TEXT_BYTE_ - mock_response.text = _TEST_FILE_CONTENT_TEXT_ - mock_get.return_value = mock_response - - test_collection = collection(COLLECTION_NAME, _client) - - fileref = test_collection.file(COLLECTION_FILE_KEY) - text = fileref.read_text() - - mock_get.assert_called_once_with( - _TEST_DOWNLOAD_URL_, timeout=_REQUEST_TIMEOUT_SECONDS_ - ) - gql.collection_file.assert_called_once_with( - COLLECTION_REFERENCE_ID, - COLLECTION_FILE_KEY, - **HEADERS_WITH_AUTHORIZATION, - ) - - assert "".join(text) == _TEST_FILE_CONTENT_TEXT_ - assert isinstance(fileref, NumerousFile) - assert fileref.exists is True - - -@patch("requests.get") -def test_collection_file_returns_file_byte_content_after_load( - mock_get: MagicMock, -) -> None: - gql = Mock(GQLClient) - _client = GraphQLClient(gql) - gql.collection_create.return_value = _collection_create_collection_reference( - COLLECTION_REFERENCE_KEY, COLLECTION_REFERENCE_ID - ) - gql.collection_file.return_value = _collection_file_reference(COLLECTION_FILE_KEY) - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.content = _TEST_FILE_CONTENT_TEXT_BYTE_ - mock_get.return_value = mock_response - - test_collection = collection(COLLECTION_NAME, _client) - - fileref = test_collection.file(COLLECTION_FILE_KEY) - bytes_data = fileref.read_bytes() - - mock_get.assert_called_once_with( - _TEST_DOWNLOAD_URL_, timeout=_REQUEST_TIMEOUT_SECONDS_ - ) - gql.collection_file.assert_called_once_with( - COLLECTION_REFERENCE_ID, - COLLECTION_FILE_KEY, - **HEADERS_WITH_AUTHORIZATION, - ) - - assert bytes_data == _TEST_FILE_CONTENT_TEXT_BYTE_ - assert isinstance(fileref, NumerousFile) - assert fileref.exists is True - - -@patch("requests.get") -def test_collection_file_returns_file_can_be_opened_after_load( - mock_get: MagicMock, -) -> None: - gql = Mock(GQLClient) - _client = GraphQLClient(gql) - gql.collection_create.return_value = _collection_create_collection_reference( - COLLECTION_REFERENCE_KEY, COLLECTION_REFERENCE_ID - ) - gql.collection_file.return_value = _collection_file_reference(COLLECTION_FILE_KEY) - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.content = _TEST_FILE_CONTENT_TEXT_BYTE_ - mock_get.return_value = mock_response - - test_collection = collection(COLLECTION_NAME, _client) - - fileref = test_collection.file(COLLECTION_FILE_KEY) - - with fileref.open() as file: - bytes_data = file.read() - - mock_get.assert_called_once_with( - _TEST_DOWNLOAD_URL_, timeout=_REQUEST_TIMEOUT_SECONDS_ - ) - gql.collection_file.assert_called_once_with( - COLLECTION_REFERENCE_ID, - COLLECTION_FILE_KEY, - **HEADERS_WITH_AUTHORIZATION, - ) - - assert bytes_data == _TEST_FILE_CONTENT_TEXT_BYTE_ - assert isinstance(fileref, NumerousFile) - assert fileref.exists is True - - -@patch("requests.put") -@patch("requests.get") -def test_collection_bytefile_can_be_uploaded_on_save( - mock_get: MagicMock, mock_put: MagicMock -) -> None: - gql = Mock(GQLClient) - _client = GraphQLClient(gql) - gql.collection_create.return_value = _collection_create_collection_reference( - COLLECTION_REFERENCE_KEY, COLLECTION_REFERENCE_ID - ) - gql.collection_file.return_value = _collection_file_reference(COLLECTION_FILE_KEY) - mock_response = MagicMock() - mock_response.status_code = 200 - mock_put.return_value = mock_response - - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.content = _TEST_FILE_CONTENT_TEXT_BYTE_ - mock_get.return_value = mock_response - - test_collection = collection(COLLECTION_NAME, _client) - - fileref = test_collection.file(COLLECTION_FILE_KEY) - fileref.save(_TEST_FILE_CONTENT_TEXT_BYTE_) - - mock_put.assert_called_once_with( - _TEST_UPLOAD_URL_, - files={"file": _TEST_FILE_CONTENT_TEXT_BYTE_}, - timeout=_REQUEST_TIMEOUT_SECONDS_, - ) - gql.collection_file.assert_called_once_with( - COLLECTION_REFERENCE_ID, - COLLECTION_FILE_KEY, - **HEADERS_WITH_AUTHORIZATION, - ) - - assert isinstance(fileref, NumerousFile) - assert fileref.exists is True - - -@patch("requests.put") -@patch("requests.get") -def test_collection_textfile_can_be_uploaded_on_save( - mock_get: MagicMock, mock_put: MagicMock -) -> None: - gql = Mock(GQLClient) - _client = GraphQLClient(gql) - gql.collection_create.return_value = _collection_create_collection_reference( - COLLECTION_REFERENCE_KEY, COLLECTION_REFERENCE_ID - ) - gql.collection_file.return_value = _collection_file_reference(COLLECTION_FILE_KEY) - mock_response = MagicMock() - mock_response.status_code = 200 - mock_put.return_value = mock_response - - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.content = _TEST_FILE_CONTENT_TEXT_ - mock_get.return_value = mock_response - - test_collection = collection(COLLECTION_NAME, _client) - - fileref = test_collection.file(COLLECTION_FILE_KEY) - fileref.save(_TEST_FILE_CONTENT_TEXT_) - - mock_put.assert_called_once_with( - _TEST_UPLOAD_URL_, - files={"file": _TEST_FILE_CONTENT_TEXT_BYTE_}, - timeout=_REQUEST_TIMEOUT_SECONDS_, - ) - gql.collection_file.assert_called_once_with( - COLLECTION_REFERENCE_ID, - COLLECTION_FILE_KEY, - **HEADERS_WITH_AUTHORIZATION, - ) - - assert isinstance(fileref, NumerousFile) - assert fileref.exists is True - - -@patch("requests.put") -@patch("requests.get") -def test_collection_file_can_be_save_from_collection( - mock_get: MagicMock, mock_put: MagicMock -) -> None: - gql = Mock(GQLClient) - _client = GraphQLClient(gql) - gql.collection_create.return_value = _collection_create_collection_reference( - COLLECTION_REFERENCE_KEY, COLLECTION_REFERENCE_ID - ) - gql.collection_file.return_value = _collection_file_reference(COLLECTION_FILE_KEY) - mock_response = MagicMock() - mock_response.status_code = 200 - mock_put.return_value = mock_response - - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.content = _TEST_FILE_CONTENT_TEXT_ - mock_get.return_value = mock_response - - test_collection = collection(COLLECTION_NAME, _client) - test_collection.save_file(COLLECTION_FILE_KEY, _TEST_FILE_CONTENT_TEXT_) - - mock_put.assert_called_once_with( - _TEST_UPLOAD_URL_, - files={"file": _TEST_FILE_CONTENT_TEXT_BYTE_}, - timeout=_REQUEST_TIMEOUT_SECONDS_, - ) - gql.collection_file.assert_called_once_with( - COLLECTION_REFERENCE_ID, - COLLECTION_FILE_KEY, - **HEADERS_WITH_AUTHORIZATION, - ) - - -@patch("requests.put") -@patch("requests.get") -def test_collection_file_can_be_uploaded_on_save_open( - mock_get: MagicMock, mock_put: MagicMock, base_path: Path -) -> None: - gql = Mock(GQLClient) - _client = GraphQLClient(gql) - gql.collection_create.return_value = _collection_create_collection_reference( - COLLECTION_REFERENCE_KEY, COLLECTION_REFERENCE_ID - ) - gql.collection_file.return_value = _collection_file_reference(COLLECTION_FILE_KEY) - mock_response = MagicMock() - mock_response.status_code = 200 - mock_put.return_value = mock_response - - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.content = _TEST_FILE_CONTENT_TEXT_BYTE_ - mock_get.return_value = mock_response - - test_collection = collection(COLLECTION_NAME, _client) - fileref = test_collection.file(COLLECTION_FILE_KEY) - file_name = "file_name" - _create_test_file_system_file(base_path, file_name, _TEST_FILE_CONTENT_TEXT_BYTE_) - - with Path.open(base_path / f"{file_name}") as f: - fileref.save_file(f) - - mock_put.assert_called_once_with( - _TEST_UPLOAD_URL_, - files={"file": _TEST_FILE_CONTENT_TEXT_BYTE_}, - timeout=_REQUEST_TIMEOUT_SECONDS_, - ) - gql.collection_file.assert_called_once_with( - COLLECTION_REFERENCE_ID, - COLLECTION_FILE_KEY, - **HEADERS_WITH_AUTHORIZATION, - ) - - assert isinstance(fileref, NumerousFile) - assert fileref.exists is True - - -@patch("requests.get") -def test_collection_file_delete_marks_file_exists_false(mock_get: MagicMock) -> None: - gql = Mock(GQLClient) - _client = GraphQLClient(gql) - gql.collection_create.return_value = _collection_create_collection_reference( - COLLECTION_REFERENCE_KEY, COLLECTION_REFERENCE_ID - ) - gql.collection_file.return_value = _collection_file_reference(COLLECTION_FILE_KEY) - - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.content = _TEST_FILE_CONTENT_TEXT_BYTE_ - mock_get.return_value = mock_response - - test_collection = collection(COLLECTION_NAME, _client) - fileref = test_collection.file(COLLECTION_FILE_KEY) - assert fileref.exists is True - gql.collection_file_delete.return_value = _collection_file_delete_found(FILE_ID) - - fileref.delete() - - gql.collection_file_delete.assert_called_once_with( - FILE_ID, **HEADERS_WITH_AUTHORIZATION - ) - assert fileref.exists is False - - -def _create_test_file_system_file( - directory_path: Path, file_name: str, data: bytes -) -> None: - directory_path.mkdir(exist_ok=True, parents=True) - path = directory_path / f"{file_name}" - path.write_bytes(data) - - -@patch("requests.get") -def test_collection_files_return_more_than_one(mock_get: MagicMock) -> None: - gql = Mock(GQLClient) - _client = GraphQLClient(gql) - gql.collection_create.return_value = _collection_create_collection_reference( - COLLECTION_REFERENCE_KEY, COLLECTION_REFERENCE_ID - ) - gql.collection_files.return_value = _collection_files_reference() - test_collection = collection(COLLECTION_NAME, _client) - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.content = _TEST_FILE_CONTENT_TEXT_BYTE_ - mock_get.return_value = mock_response - - result = [] - expected_number_of_filess = 2 - for file in test_collection.files(): - assert file.exists - result.append(file) - - assert len(result) == expected_number_of_filess - gql.collection_files.assert_called_once_with( - ORGANIZATION_ID, - COLLECTION_REFERENCE_KEY, - None, - after="", - first=COLLECTED_OBJECTS_NUMBER, - **HEADERS_WITH_AUTHORIZATION, - ) - - -@patch("requests.get") -def test_collection_document_tag_add(mock_get: MagicMock) -> None: - gql = Mock(GQLClient) - _client = GraphQLClient(gql) - gql.collection_create.return_value = _collection_create_collection_reference( - COLLECTION_REFERENCE_KEY, COLLECTION_REFERENCE_ID - ) - - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.content = _TEST_FILE_CONTENT_TEXT_ - mock_get.return_value = mock_response - - gql.collection_file.return_value = _collection_file_reference(COLLECTION_FILE_KEY) - - test_collection = collection(COLLECTION_NAME, _client) - - fileref = test_collection.file(COLLECTION_FILE_KEY) - - gql.collection_file_tag_add.return_value = _collection_file_tag_add_found(FILE_ID) - assert fileref.exists - - fileref.tag("key", "test") - - gql.collection_file_tag_add.assert_called_once_with( - FILE_ID, TagInput(key="key", value="test"), **HEADERS_WITH_AUTHORIZATION - ) - assert fileref.tags == {"key": "test"} - - -@patch("requests.get") -def test_collection_document_tag_delete(mock_get: MagicMock) -> None: - gql = Mock(GQLClient) - _client = GraphQLClient(gql) - gql.collection_create.return_value = _collection_create_collection_reference( - COLLECTION_REFERENCE_KEY, COLLECTION_REFERENCE_ID - ) - - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.content = _TEST_FILE_CONTENT_TEXT_ - mock_get.return_value = mock_response - - gql.collection_file.return_value = _collection_file_reference(COLLECTION_FILE_KEY) - - test_collection = collection(COLLECTION_NAME, _client) - - fileref = test_collection.file(COLLECTION_FILE_KEY) - - gql.collection_file_tag_add.return_value = _collection_file_tag_add_found(FILE_ID) - gql.collection_file_tag_delete.return_value = _collection_file_tag_delete_found( - FILE_ID - ) - assert fileref.exists - fileref.tag("key", "test") - assert fileref.tags == {"key": "test"} - - fileref.tag_delete("key") - - assert fileref.tags == {} - gql.collection_file_tag_delete.assert_called_once_with( - FILE_ID, "key", **HEADERS_WITH_AUTHORIZATION - ) - - -@patch("requests.get") -def test_collection_files_query_tag_specific_file(mock_get: MagicMock) -> None: - gql = Mock(GQLClient) - _client = GraphQLClient(gql) - gql.collection_create.return_value = _collection_create_collection_reference( - COLLECTION_REFERENCE_KEY, COLLECTION_REFERENCE_ID - ) - gql.collection_files.return_value = _collection_files_reference() - test_collection = collection(COLLECTION_NAME, _client) - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.content = _TEST_FILE_CONTENT_TEXT_BYTE_ - mock_get.return_value = mock_response - - tag_key = "key" - tag_value = "value" - for document in test_collection.files(tag_key=tag_key, tag_value=tag_value): - assert document.exists - - gql.collection_files.assert_called_once_with( - ORGANIZATION_ID, - COLLECTION_REFERENCE_KEY, - TagInput(key=tag_key, value=tag_value), - after="", - first=COLLECTED_OBJECTS_NUMBER, - **HEADERS_WITH_AUTHORIZATION, - ) diff --git a/python/tests/test_fs_client.py b/python/tests/test_fs_client.py index 1bfa697e..71eb960d 100644 --- a/python/tests/test_fs_client.py +++ b/python/tests/test_fs_client.py @@ -1,5 +1,4 @@ import json -import os from pathlib import Path from typing import Any @@ -16,7 +15,7 @@ _TEST_COLLECTION_KEY = "collection_key" -_TEST_COLLECTION_ID = _TEST_COLLECTION_KEY +TEST_COLLECTION_ID = _TEST_COLLECTION_KEY _TEST_NESTED_COLLECTION_KEY = "nested_collection_key" _TEST_NESTED_COLLECTION_ID = str( @@ -34,7 +33,8 @@ _TEST_DOCUMENT_KEY = "document_key" _TEST_ANOTHER_DOCUMENT_KEY = "another_document_key" -_TEST_FILE_KEY = "file_key" +TEST_FILE_KEY = "file_key" +TEST_FILE_ID = "file_id" @pytest.fixture @@ -55,11 +55,11 @@ def test_get_document_returns_expected_existing_document_reference( {"key": "tag-1-key", "value": "tag-1-value"}, {"key": "tag-2-key", "value": "tag-2-value"}, ] - _create_test_file_system_document( + _create_test_document( base_path / _TEST_COLLECTION_KEY, _TEST_DOCUMENT_KEY, data=data, tags=tags ) - doc = client.get_collection_document(_TEST_COLLECTION_ID, _TEST_DOCUMENT_KEY) + doc = client.get_collection_document(TEST_COLLECTION_ID, _TEST_DOCUMENT_KEY) assert doc == CollectionDocumentReference( id=str(Path(_TEST_COLLECTION_KEY) / _TEST_DOCUMENT_KEY), @@ -80,7 +80,7 @@ def test_get_document_returns_expected_nested_existing_document_reference( {"key": "tag-1-key", "value": "tag-1-value"}, {"key": "tag-2-key", "value": "tag-2-value"}, ] - _create_test_file_system_document( + _create_test_document( base_path / _TEST_COLLECTION_KEY / _TEST_NESTED_COLLECTION_KEY, _TEST_DOCUMENT_KEY, data=data, @@ -105,7 +105,7 @@ def test_get_document_returns_expected_none_for_nonexisting_document( ) -> None: (base_path / _TEST_COLLECTION_KEY).mkdir() - doc = client.get_collection_document(_TEST_COLLECTION_ID, _TEST_DOCUMENT_KEY) + doc = client.get_collection_document(TEST_COLLECTION_ID, _TEST_DOCUMENT_KEY) assert doc is None @@ -118,12 +118,14 @@ def test_set_document_creates_expected_file( encoded_data = dict_to_base64(data) doc = client.set_collection_document( - _TEST_COLLECTION_ID, _TEST_DOCUMENT_KEY, encoded_data + TEST_COLLECTION_ID, _TEST_DOCUMENT_KEY, encoded_data ) assert doc is not None assert doc.data == dict_to_base64(data) - stored_doc_path = base_path / _TEST_COLLECTION_KEY / f"{_TEST_DOCUMENT_KEY}.json" + stored_doc_path = ( + base_path / _TEST_COLLECTION_KEY / f"{_TEST_DOCUMENT_KEY}.doc.json" + ) assert stored_doc_path.exists() is True assert stored_doc_path.read_text() == json.dumps({"data": data, "tags": []}) @@ -132,14 +134,14 @@ def test_delete_collection_document_removes_expected_file( client: FileSystemClient, base_path: Path ) -> None: data = {"field1": 123, "field2": "text"} - _create_test_file_system_document( + _create_test_document( base_path / _TEST_COLLECTION_KEY, _TEST_DOCUMENT_KEY, data=data, tags=[] ) - doc_id = str(Path(_TEST_COLLECTION_ID) / _TEST_DOCUMENT_KEY) + doc_id = str(Path(TEST_COLLECTION_ID) / _TEST_DOCUMENT_KEY) doc = client.delete_collection_document(doc_id) - doc_path = base_path / _TEST_COLLECTION_KEY / f"{_TEST_DOCUMENT_KEY}.json" + doc_path = base_path / _TEST_COLLECTION_KEY / f"{_TEST_DOCUMENT_KEY}.doc.json" assert doc_path.exists() is False assert doc == CollectionDocumentReference( id=doc_id, @@ -152,7 +154,7 @@ def test_delete_collection_document_removes_expected_file( def test_delete_collection_document_for_nonexisting_returns_none( client: FileSystemClient, ) -> None: - doc_id = str(Path(_TEST_COLLECTION_ID) / _TEST_DOCUMENT_KEY) + doc_id = str(Path(TEST_COLLECTION_ID) / _TEST_DOCUMENT_KEY) doc = client.delete_collection_document(doc_id) assert doc is None @@ -162,19 +164,19 @@ def test_add_collection_document_tag_adds_expected_tag( base_path: Path, client: FileSystemClient ) -> None: data = {"field1": 123, "field2": "text"} - _create_test_file_system_document( + _create_test_document( base_path / _TEST_COLLECTION_KEY, _TEST_DOCUMENT_KEY, data=data, tags=[{"key": "pre-existing-tag-key", "value": "pre-existing-tag-value"}], ) - doc_id = str(Path(_TEST_COLLECTION_ID) / _TEST_DOCUMENT_KEY) + doc_id = str(Path(TEST_COLLECTION_ID) / _TEST_DOCUMENT_KEY) client.add_collection_document_tag( doc_id, TagInput(key="added-tag-key", value="added-tag-value") ) - doc_path = base_path / _TEST_COLLECTION_KEY / f"{_TEST_DOCUMENT_KEY}.json" + doc_path = base_path / _TEST_COLLECTION_KEY / f"{_TEST_DOCUMENT_KEY}.doc.json" assert json.loads(doc_path.read_text())["tags"] == [ {"key": "pre-existing-tag-key", "value": "pre-existing-tag-value"}, {"key": "added-tag-key", "value": "added-tag-value"}, @@ -185,7 +187,7 @@ def test_delete_collection_document_tag_deletes_expected_tag( base_path: Path, client: FileSystemClient ) -> None: data = {"field1": 123, "field2": "text"} - _create_test_file_system_document( + _create_test_document( base_path / _TEST_COLLECTION_KEY, _TEST_DOCUMENT_KEY, data=data, @@ -195,10 +197,10 @@ def test_delete_collection_document_tag_deletes_expected_tag( ], ) - doc_id = str(Path(_TEST_COLLECTION_ID) / _TEST_DOCUMENT_KEY) + doc_id = str(Path(TEST_COLLECTION_ID) / _TEST_DOCUMENT_KEY) client.delete_collection_document_tag(doc_id, "tag-to-be-deleted-key") - doc_path = base_path / _TEST_COLLECTION_KEY / f"{_TEST_DOCUMENT_KEY}.json" + doc_path = base_path / _TEST_COLLECTION_KEY / f"{_TEST_DOCUMENT_KEY}.doc.json" assert json.loads(doc_path.read_text())["tags"] == [ {"key": "tag-key", "value": "tag-value"}, ] @@ -208,11 +210,11 @@ def test_get_collection_documents_returns_all_documents( base_path: Path, client: FileSystemClient ) -> None: test_data = {"name": "test document"} - _create_test_file_system_document( + _create_test_document( base_path / _TEST_COLLECTION_KEY, _TEST_DOCUMENT_KEY, data=test_data, tags=[] ) test_another_data = {"name": "another test document"} - _create_test_file_system_document( + _create_test_document( base_path / _TEST_COLLECTION_KEY, _TEST_ANOTHER_DOCUMENT_KEY, data=test_another_data, @@ -225,13 +227,13 @@ def test_get_collection_documents_returns_all_documents( assert result == [ CollectionDocumentReference( - id=str(Path(_TEST_COLLECTION_ID) / _TEST_ANOTHER_DOCUMENT_KEY), + id=str(Path(TEST_COLLECTION_ID) / _TEST_ANOTHER_DOCUMENT_KEY), key=_TEST_ANOTHER_DOCUMENT_KEY, data=dict_to_base64(test_another_data), tags=[], ), CollectionDocumentReference( - id=str(Path(_TEST_COLLECTION_ID) / _TEST_DOCUMENT_KEY), + id=str(Path(TEST_COLLECTION_ID) / _TEST_DOCUMENT_KEY), key=_TEST_DOCUMENT_KEY, data=dict_to_base64(test_data), tags=[], @@ -245,14 +247,14 @@ def test_get_collection_documents_returns_documents_with_tag( base_path: Path, client: FileSystemClient ) -> None: test_tagged_data = {"name": "test document"} - _create_test_file_system_document( + _create_test_document( base_path / _TEST_COLLECTION_KEY, _TEST_DOCUMENT_KEY, data=test_tagged_data, tags=[{"key": "tag-key", "value": "tag-value"}], ) test_untagged_data = {"name": "another test document"} - _create_test_file_system_document( + _create_test_document( base_path / _TEST_COLLECTION_KEY, _TEST_ANOTHER_DOCUMENT_KEY, data=test_untagged_data, @@ -267,7 +269,7 @@ def test_get_collection_documents_returns_documents_with_tag( assert result == [ CollectionDocumentReference( - id=str(Path(_TEST_COLLECTION_ID) / _TEST_DOCUMENT_KEY), + id=str(Path(TEST_COLLECTION_ID) / _TEST_DOCUMENT_KEY), key=_TEST_DOCUMENT_KEY, data=dict_to_base64(test_tagged_data), tags=[CollectionDocumentReferenceTags(key="tag-key", value="tag-value")], @@ -280,7 +282,7 @@ def test_get_collection_documents_returns_documents_with_tag( def test_get_collection_collections_returns_expected_collections( base_path: Path, client: FileSystemClient ) -> None: - (base_path / _TEST_COLLECTION_ID).mkdir() + (base_path / TEST_COLLECTION_ID).mkdir() (base_path / _TEST_NESTED_COLLECTION_ID).mkdir() (base_path / _TEST_ANOTHER_NESTED_COLLECTION_ID).mkdir() @@ -301,7 +303,7 @@ def test_get_collection_collections_returns_expected_collections( ] == collections -def test_get_file_returns_expected_existing_file_reference( +def test_get_collection_file_returns_expected_existing_file_reference( client: FileSystemClient, base_path: Path ) -> None: data = "File content 1;2;3;4;\n1;2;3;4" @@ -309,61 +311,49 @@ def test_get_file_returns_expected_existing_file_reference( {"key": "tag-1-key", "value": "tag-1-value"}, {"key": "tag-2-key", "value": "tag-2-value"}, ] - _create_test_file_system_file( - base_path / _TEST_COLLECTION_KEY, _TEST_FILE_KEY, data=data, tags=tags - ) + _create_test_file(base_path, data=data, tags=tags) - file = client.get_collection_file(_TEST_COLLECTION_ID, _TEST_FILE_KEY) - assert file - assert file.file_id == str(Path(_TEST_COLLECTION_KEY) / f"file_{_TEST_FILE_KEY}") - assert file.key == f"file_{_TEST_FILE_KEY}" + file = client.create_collection_file_reference(TEST_COLLECTION_ID, TEST_FILE_KEY) + + assert file is not None + assert file.file_id == TEST_FILE_ID + assert file.key == TEST_FILE_KEY assert file.exists is True assert file.tags == {"tag-1-key": "tag-1-value", "tag-2-key": "tag-2-value"} +def test_get_collection_file_returns_expected_nonexisting_file_reference( + client: FileSystemClient, base_path: Path +) -> None: + (base_path / TEST_COLLECTION_ID).mkdir(parents=True) + + file = client.create_collection_file_reference(TEST_COLLECTION_ID, TEST_FILE_KEY) + + assert file is not None + assert file.key == TEST_FILE_KEY + assert file.exists is False + assert file.tags == {} + + def test_get_collection_files_returns_all_files( base_path: Path, client: FileSystemClient ) -> None: - test_files = [ - {"data": "File content 1;2;3;4;\n1;2;3;4", "file_key": _TEST_FILE_KEY}, - {"data": "File content 4;5;6;7;\n4;5;6;7", "file_key": _TEST_FILE_KEY + "1"}, - ] - - for test_file in test_files: - _create_test_file_system_file( - base_path / _TEST_COLLECTION_KEY, - test_file["file_key"], - data=test_file["data"], - tags=[], - ) + test_files = { + TEST_FILE_ID + "_1": (TEST_FILE_KEY + "_1", "File content 1;2;3;4;\n1;2;3;4"), + TEST_FILE_ID + "_2": (TEST_FILE_KEY + "_2", "File content 4;5;6;7;\n4;5;6;7"), + } + for file_id, (file_key, data) in test_files.items(): + _create_test_file(base_path, TEST_COLLECTION_ID, file_key, file_id, data) result, has_next_page, end_cursor = client.get_collection_files( - _TEST_COLLECTION_KEY, "", None + TEST_COLLECTION_ID, "", None ) - assert result - assert len(result) == len(test_files) - - expected_files = { - str(Path(_TEST_COLLECTION_KEY) / f"file_{test_file['file_key']}"): { - "file_id": str( - Path(_TEST_COLLECTION_KEY) / f"file_{test_file['file_key']}" - ), - "key": f"file_{test_file['file_key']}", - "exists": True, - "tags": {}, - } - for test_file in test_files - } - - result_files = {file.file_id: file for file in result if file} - - for file_id, expected in expected_files.items(): - assert file_id in result_files - file = result_files[file_id] - assert file.key == expected["key"] - assert file.exists == expected["exists"] - assert file.tags == expected["tags"] + assert result is not None + result_files = { + file.file_id: (file.key, file.read_text()) for file in result if file + } + assert result_files == test_files assert has_next_page is False assert end_cursor == "" @@ -372,41 +362,31 @@ def test_delete_collection_file_removes_expected_file( client: FileSystemClient, base_path: Path ) -> None: data = "File content 1;2;3;4;\n1;2;3;4" - _create_test_file_system_file( - base_path / _TEST_COLLECTION_KEY, _TEST_FILE_KEY, data=data, tags=[] - ) - path = base_path / _TEST_COLLECTION_KEY / f"file_{_TEST_FILE_KEY}" + _create_test_file(base_path, data=data) + data_path = base_path / _TEST_COLLECTION_KEY / f"{TEST_FILE_KEY}.file.data" + meta_path = base_path / _TEST_COLLECTION_KEY / f"{TEST_FILE_KEY}.file.meta.json" - file_id = str(Path(_TEST_COLLECTION_ID) / f"file_{_TEST_FILE_KEY}") - file = client.delete_collection_file(file_id) + client.delete_collection_file(TEST_FILE_ID) - assert path.exists() is False - assert file - assert file.file_id == str(Path(_TEST_COLLECTION_KEY) / f"file_{_TEST_FILE_KEY}") - assert file.key == f"file_{_TEST_FILE_KEY}" - assert file.exists is False + assert meta_path.exists() is False + assert data_path.exists() is False def test_add_collection_file_tag_adds_expected_tag( base_path: Path, client: FileSystemClient ) -> None: data = "File content 1;2;3;4;\n1;2;3;4" + tags = [{"key": "pre-existing-tag-key", "value": "pre-existing-tag-value"}] - _create_test_file_system_file( - base_path / _TEST_COLLECTION_KEY, - _TEST_FILE_KEY, - data=data, - tags=[{"key": "pre-existing-tag-key", "value": "pre-existing-tag-value"}], - ) + _create_test_file(base_path, data=data, tags=tags) - path = base_path / _TEST_COLLECTION_KEY / f"file_{_TEST_FILE_KEY}.json" - file_id = str(Path(_TEST_COLLECTION_ID) / f"file_{_TEST_FILE_KEY}") + meta_path = base_path / _TEST_COLLECTION_KEY / f"{TEST_FILE_KEY}.file.meta.json" client.add_collection_file_tag( - file_id, TagInput(key="added-tag-key", value="added-tag-value") + TEST_FILE_ID, TagInput(key="added-tag-key", value="added-tag-value") ) - assert json.loads(path.read_text())["tags"] == [ + assert json.loads(meta_path.read_text())["tags"] == [ {"key": "pre-existing-tag-key", "value": "pre-existing-tag-value"}, {"key": "added-tag-key", "value": "added-tag-value"}, ] @@ -416,26 +396,68 @@ def test_delete_collection_file_tag_deletes_expected_tag( base_path: Path, client: FileSystemClient ) -> None: data = "File content 1;2;3;4;\n1;2;3;4" - _create_test_file_system_file( - base_path / _TEST_COLLECTION_KEY, - _TEST_FILE_KEY, - data=data, + tags = [ + {"key": "tag-key", "value": "tag-value"}, + {"key": "tag-to-be-deleted-key", "value": "tag-to-be-deleted-value"}, + ] + _create_test_file(base_path, data=data, tags=tags) + + client.delete_collection_file_tag(TEST_FILE_ID, "tag-to-be-deleted-key") + + meta_path = base_path / TEST_COLLECTION_ID / f"{TEST_FILE_KEY}.file.meta.json" + assert json.loads(meta_path.read_text())["tags"] == [ + {"key": "tag-key", "value": "tag-value"}, + ] + + +def test_file_exists_returns_true_for_existing_file( + base_path: Path, client: FileSystemClient +) -> None: + _create_test_file(base_path, file_id=TEST_FILE_ID, data="some data") + + assert client.file_exists(TEST_FILE_ID) is True + + +def test_file_exists_returns_false_for_nonexisting_file( + client: FileSystemClient, +) -> None: + assert client.file_exists(TEST_FILE_ID) is False + + +def test_file_exists_returns_false_for_nonexisting_referenced_file( + base_path: Path, + client: FileSystemClient, +) -> None: + (base_path / TEST_COLLECTION_ID).mkdir(parents=True) + f = client.create_collection_file_reference(TEST_COLLECTION_ID, TEST_FILE_KEY) + + assert f is not None + assert client.file_exists(f.file_id) is False + + +def test_collection_file_tags_returns_expected_tags( + base_path: Path, client: FileSystemClient +) -> None: + _create_test_file( + base_path, tags=[ - {"key": "tag-key", "value": "tag-value"}, - {"key": "tag-to-be-deleted-key", "value": "tag-to-be-deleted-value"}, + {"key": "tag-1", "value": "value-1"}, + {"key": "tag-2", "value": "value-2"}, ], ) - path = base_path / _TEST_COLLECTION_KEY / f"file_{_TEST_FILE_KEY}.json" - file_id = str(Path(_TEST_COLLECTION_ID) / f"file_{_TEST_FILE_KEY}") - client.delete_collection_file_tag(file_id, "tag-to-be-deleted-key") + tags = client.collection_file_tags(TEST_FILE_ID) - assert json.loads(path.read_text())["tags"] == [ - {"key": "tag-key", "value": "tag-value"}, - ] + assert tags == {"tag-1": "value-1", "tag-2": "value-2"} -def _create_test_file_system_document( +def test_collection_file_tags_returns_non_for_nonexisting_file( + client: FileSystemClient, +) -> None: + assert client.collection_file_tags(TEST_FILE_ID) is None + + +def _create_test_document( collection_path: Path, document_key: str, data: dict[str, Any], @@ -443,16 +465,30 @@ def _create_test_file_system_document( ) -> None: collection_path.mkdir(exist_ok=True, parents=True) stored_doc_data = json.dumps({"data": data, "tags": tags}) - doc_path = collection_path / f"{document_key}.json" + doc_path = collection_path / f"{document_key}.doc.json" doc_path.write_text(stored_doc_data) -def _create_test_file_system_file( - collection_path: Path, file_key: str, tags: list[dict[str, str]], data: str +def _create_test_file( # noqa: PLR0913 + base_path: Path, + collection_id: str = TEST_COLLECTION_ID, + file_key: str = TEST_FILE_KEY, + file_id: str = TEST_FILE_ID, + data: str | None = None, + tags: list[dict[str, str]] | None = None, ) -> None: - collection_path.mkdir(exist_ok=True, parents=True) - metadata_path = collection_path / f"file_{file_key}.json" - path = collection_path / f"file_{file_key}" - stored_file_data = json.dumps({"path": os.fspath(path), "tags": tags}) - metadata_path.write_text(stored_file_data) - path.write_text(data) + index_path = base_path / FileSystemClient.FILE_INDEX_DIR + index_path.mkdir(parents=True, exist_ok=True) + index_entry_path = index_path / file_id + index_entry_path.write_text( + json.dumps({"file_key": file_key, "collection_id": collection_id}) + ) + collection_path = base_path / collection_id + collection_path.mkdir(parents=True, exist_ok=True) + meta_path = collection_path / f"{file_key}.file.meta.json" + meta_path.write_text( + json.dumps({"file_id": file_id, "file_key": file_key, "tags": tags or []}) + ) + data_path = collection_path / f"{file_key}.file.data" + if data: + data_path.write_text(data) diff --git a/python/tests/test_numerous_client.py b/python/tests/test_get_client.py similarity index 100% rename from python/tests/test_numerous_client.py rename to python/tests/test_get_client.py diff --git a/shared/schema.gql b/shared/schema.gql index 7bd4c094..2037eb2d 100644 --- a/shared/schema.gql +++ b/shared/schema.gql @@ -1160,6 +1160,10 @@ union CollectionFileResult = CollectionFile | CollectionFileNotFound union CollectionFileCreateResult = CollectionFile | CollectionNotFound union CollectionFileDeleteResult = CollectionFile | CollectionFileNotFound +extend type Query { + collectionFile(id: ID!): CollectionFileResult @canAccessCollectionFile +} + extend type Mutation { # idempotent collectionFileCreate(