morphik 1.2.1__tar.gz → 1.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {morphik-1.2.1 → morphik-1.2.2}/.gitignore +2 -1
- {morphik-1.2.1 → morphik-1.2.2}/PKG-INFO +11 -1
- {morphik-1.2.1 → morphik-1.2.2}/README.md +10 -0
- {morphik-1.2.1 → morphik-1.2.2}/morphik/__init__.py +1 -1
- {morphik-1.2.1 → morphik-1.2.2}/morphik/_internal.py +22 -3
- {morphik-1.2.1 → morphik-1.2.2}/morphik/_shared.py +38 -0
- {morphik-1.2.1 → morphik-1.2.2}/morphik/async_.py +74 -0
- {morphik-1.2.1 → morphik-1.2.2}/morphik/sync.py +74 -0
- {morphik-1.2.1 → morphik-1.2.2}/morphik/tests/test_async.py +76 -2
- {morphik-1.2.1 → morphik-1.2.2}/morphik/tests/test_scoped_ops_unit.py +462 -0
- {morphik-1.2.1 → morphik-1.2.2}/morphik/tests/test_sync.py +69 -2
- {morphik-1.2.1 → morphik-1.2.2}/pyproject.toml +1 -1
- {morphik-1.2.1 → morphik-1.2.2}/morphik/_scoped_ops.py +0 -0
- {morphik-1.2.1 → morphik-1.2.2}/morphik/exceptions.py +0 -0
- {morphik-1.2.1 → morphik-1.2.2}/morphik/models.py +0 -0
- {morphik-1.2.1 → morphik-1.2.2}/morphik/tests/README.md +0 -0
- {morphik-1.2.1 → morphik-1.2.2}/morphik/tests/__init__.py +0 -0
- {morphik-1.2.1 → morphik-1.2.2}/morphik/tests/example_usage.py +0 -0
- {morphik-1.2.1 → morphik-1.2.2}/morphik/tests/test_app_ops.py +0 -0
- {morphik-1.2.1 → morphik-1.2.2}/morphik/tests/test_docs/sample1.txt +0 -0
- {morphik-1.2.1 → morphik-1.2.2}/morphik/tests/test_docs/sample2.txt +0 -0
- {morphik-1.2.1 → morphik-1.2.2}/morphik/tests/test_docs/sample3.txt +0 -0
- {morphik-1.2.1 → morphik-1.2.2}/morphik/tests/test_shared_helpers.py +0 -0
- {morphik-1.2.1 → morphik-1.2.2}/morphik/tests/test_update_document_metadata_rename.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: morphik
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.2
|
|
4
4
|
Summary: Morphik Python Client
|
|
5
5
|
Author-email: Morphik <founders@morphik.ai>
|
|
6
6
|
Requires-Python: >=3.8
|
|
@@ -52,6 +52,9 @@ from morphik import Morphik
|
|
|
52
52
|
# Initialize client - connects to localhost:8000 by default
|
|
53
53
|
db = Morphik()
|
|
54
54
|
|
|
55
|
+
# You can also use a direct HTTP(S) base URL for self-hosted deployments
|
|
56
|
+
# db = Morphik("http://morphik:8000")
|
|
57
|
+
|
|
55
58
|
# Or with authentication URI (for production)
|
|
56
59
|
# db = Morphik("morphik://owner_id:token@api.morphik.ai")
|
|
57
60
|
|
|
@@ -96,6 +99,10 @@ print(response.completion)
|
|
|
96
99
|
# Create a nested folder (parents are auto-created server-side)
|
|
97
100
|
folder = db.create_folder(full_path="/projects/alpha/specs", description="Specs folder")
|
|
98
101
|
|
|
102
|
+
# Move or rename folder paths
|
|
103
|
+
moved = db.move_folder("/projects/alpha/specs", "/projects/archive/specs")
|
|
104
|
+
renamed = moved.rename("specs-v2")
|
|
105
|
+
|
|
99
106
|
# Scope queries to a path and include descendants with folder_depth=-1
|
|
100
107
|
chunks = folder.retrieve_chunks(query="design notes", folder_depth=-1)
|
|
101
108
|
docs = db.list_documents(folder_name="/projects/alpha", folder_depth=-1)
|
|
@@ -113,6 +120,9 @@ async def main():
|
|
|
113
120
|
# Initialize async client - connects to localhost:8000 by default
|
|
114
121
|
async with AsyncMorphik() as db:
|
|
115
122
|
|
|
123
|
+
# You can also use a direct HTTP(S) base URL for self-hosted deployments
|
|
124
|
+
# async with AsyncMorphik("http://morphik:8000") as db:
|
|
125
|
+
|
|
116
126
|
# Or with authentication URI (for production)
|
|
117
127
|
# async with AsyncMorphik("morphik://owner_id:token@api.morphik.ai") as db:
|
|
118
128
|
# Ingest a text document
|
|
@@ -39,6 +39,9 @@ from morphik import Morphik
|
|
|
39
39
|
# Initialize client - connects to localhost:8000 by default
|
|
40
40
|
db = Morphik()
|
|
41
41
|
|
|
42
|
+
# You can also use a direct HTTP(S) base URL for self-hosted deployments
|
|
43
|
+
# db = Morphik("http://morphik:8000")
|
|
44
|
+
|
|
42
45
|
# Or with authentication URI (for production)
|
|
43
46
|
# db = Morphik("morphik://owner_id:token@api.morphik.ai")
|
|
44
47
|
|
|
@@ -83,6 +86,10 @@ print(response.completion)
|
|
|
83
86
|
# Create a nested folder (parents are auto-created server-side)
|
|
84
87
|
folder = db.create_folder(full_path="/projects/alpha/specs", description="Specs folder")
|
|
85
88
|
|
|
89
|
+
# Move or rename folder paths
|
|
90
|
+
moved = db.move_folder("/projects/alpha/specs", "/projects/archive/specs")
|
|
91
|
+
renamed = moved.rename("specs-v2")
|
|
92
|
+
|
|
86
93
|
# Scope queries to a path and include descendants with folder_depth=-1
|
|
87
94
|
chunks = folder.retrieve_chunks(query="design notes", folder_depth=-1)
|
|
88
95
|
docs = db.list_documents(folder_name="/projects/alpha", folder_depth=-1)
|
|
@@ -100,6 +107,9 @@ async def main():
|
|
|
100
107
|
# Initialize async client - connects to localhost:8000 by default
|
|
101
108
|
async with AsyncMorphik() as db:
|
|
102
109
|
|
|
110
|
+
# You can also use a direct HTTP(S) base URL for self-hosted deployments
|
|
111
|
+
# async with AsyncMorphik("http://morphik:8000") as db:
|
|
112
|
+
|
|
103
113
|
# Or with authentication URI (for production)
|
|
104
114
|
# async with AsyncMorphik("morphik://owner_id:token@api.morphik.ai") as db:
|
|
105
115
|
# Ingest a text document
|
|
@@ -64,12 +64,31 @@ class _MorphikClientLogic:
|
|
|
64
64
|
if not parsed.netloc:
|
|
65
65
|
raise ValueError("Invalid URI format")
|
|
66
66
|
|
|
67
|
+
# Allow direct HTTP(S) endpoints without auth token for self-hosted usage.
|
|
68
|
+
if parsed.scheme in {"http", "https"}:
|
|
69
|
+
path_prefix = (parsed.path or "").rstrip("/")
|
|
70
|
+
self._base_url = f"{parsed.scheme}://{parsed.netloc}{path_prefix}".rstrip("/")
|
|
71
|
+
self._auth_token = None
|
|
72
|
+
if parsed.hostname in {"localhost", "127.0.0.1", "0.0.0.0", "host.docker.internal"}:
|
|
73
|
+
self._is_local = True
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
if parsed.scheme != "morphik":
|
|
77
|
+
raise ValueError("Invalid URI format")
|
|
78
|
+
if "@" not in parsed.netloc:
|
|
79
|
+
raise ValueError("Invalid Morphik URI format. Expected morphik://<owner_id>:<token>@<host>")
|
|
80
|
+
|
|
67
81
|
# Split host and auth parts
|
|
68
|
-
auth, host = parsed.netloc.
|
|
69
|
-
|
|
82
|
+
auth, host = parsed.netloc.rsplit("@", 1)
|
|
83
|
+
if ":" not in auth:
|
|
84
|
+
raise ValueError("Invalid Morphik URI auth format. Expected <owner_id>:<token>")
|
|
85
|
+
_, self._auth_token = auth.split(":", 1)
|
|
86
|
+
|
|
87
|
+
if parsed.hostname in {"localhost", "127.0.0.1", "0.0.0.0", "host.docker.internal"}:
|
|
88
|
+
self._is_local = True
|
|
70
89
|
|
|
71
90
|
# Set base URL
|
|
72
|
-
self._base_url = f"{'http' if self._is_local else 'https'}://{host}"
|
|
91
|
+
self._base_url = f"{'http' if self._is_local else 'https'}://{host}".rstrip("/")
|
|
73
92
|
|
|
74
93
|
# Basic token validation
|
|
75
94
|
jwt.decode(self._auth_token, options={"verify_signature": False})
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import json
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
from typing import Any, Dict, Iterable, List, Optional, Union
|
|
6
|
+
from urllib.parse import quote
|
|
6
7
|
|
|
7
8
|
from pydantic import BaseModel
|
|
8
9
|
|
|
@@ -146,6 +147,43 @@ def build_document_by_filename_params(
|
|
|
146
147
|
return params
|
|
147
148
|
|
|
148
149
|
|
|
150
|
+
def normalize_folder_identifier(folder_id_or_name: str) -> str:
|
|
151
|
+
if not folder_id_or_name:
|
|
152
|
+
raise ValueError("folder_id_or_name is required")
|
|
153
|
+
normalized = folder_id_or_name.lstrip("/")
|
|
154
|
+
if not normalized:
|
|
155
|
+
raise ValueError("folder_id_or_name is required")
|
|
156
|
+
return normalized
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def build_folder_endpoint_identifier(folder_id_or_name: str) -> str:
|
|
160
|
+
normalized = normalize_folder_identifier(folder_id_or_name)
|
|
161
|
+
return quote(normalized, safe="/")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def build_folder_move_payload(*, new_path: str) -> Dict[str, str]:
|
|
165
|
+
normalized_path = "/" + (new_path or "").strip("/")
|
|
166
|
+
if normalized_path == "/":
|
|
167
|
+
raise ValueError("new_path must include at least one non-root segment")
|
|
168
|
+
return {"new_path": normalized_path}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def build_folder_rename_path(*, current_path: str, new_name: str) -> str:
|
|
172
|
+
normalized_name = (new_name or "").strip().strip("/")
|
|
173
|
+
if not normalized_name:
|
|
174
|
+
raise ValueError("new_name is required")
|
|
175
|
+
if "/" in normalized_name:
|
|
176
|
+
raise ValueError("new_name must be a single folder segment without '/'")
|
|
177
|
+
|
|
178
|
+
normalized_current = "/" + (current_path or "").strip("/")
|
|
179
|
+
segments = [segment for segment in normalized_current.split("/") if segment]
|
|
180
|
+
if not segments:
|
|
181
|
+
raise ValueError("current_path must include at least one non-root segment")
|
|
182
|
+
|
|
183
|
+
segments[-1] = normalized_name
|
|
184
|
+
return "/" + "/".join(segments)
|
|
185
|
+
|
|
186
|
+
|
|
149
187
|
def normalize_additional_folders(
|
|
150
188
|
additional_folders: Optional[List[str]],
|
|
151
189
|
folder_name: Optional[Union[str, List[str]]],
|
|
@@ -12,6 +12,9 @@ from pydantic import BaseModel
|
|
|
12
12
|
from ._internal import FinalChunkResult, _MorphikClientLogic
|
|
13
13
|
from ._scoped_ops import _ScopedOperationsMixin
|
|
14
14
|
from ._shared import (
|
|
15
|
+
build_folder_endpoint_identifier,
|
|
16
|
+
build_folder_move_payload,
|
|
17
|
+
build_folder_rename_path,
|
|
15
18
|
build_create_app_payload,
|
|
16
19
|
build_document_by_filename_params,
|
|
17
20
|
build_list_apps_params,
|
|
@@ -416,6 +419,15 @@ class AsyncFolder(_AsyncScopedClientOps):
|
|
|
416
419
|
"""Returns the folder ID if available."""
|
|
417
420
|
return self._id
|
|
418
421
|
|
|
422
|
+
def _sync_from_folder(self, folder: "AsyncFolder") -> None:
|
|
423
|
+
self._id = folder.id or self._id
|
|
424
|
+
self._name = folder.name
|
|
425
|
+
self._full_path = folder.full_path
|
|
426
|
+
self._parent_id = folder.parent_id
|
|
427
|
+
self._depth = folder.depth
|
|
428
|
+
self._child_count = folder.child_count
|
|
429
|
+
self._description = folder.description
|
|
430
|
+
|
|
419
431
|
async def get_info(self) -> Dict[str, Any]:
|
|
420
432
|
"""
|
|
421
433
|
Get detailed information about this folder.
|
|
@@ -446,6 +458,24 @@ class AsyncFolder(_AsyncScopedClientOps):
|
|
|
446
458
|
self._description = info.description or self._description
|
|
447
459
|
return info
|
|
448
460
|
|
|
461
|
+
async def move(self, new_path: str) -> "AsyncFolder":
|
|
462
|
+
"""Move this folder to a new canonical path and refresh local metadata."""
|
|
463
|
+
identifier = self._id or self.full_path
|
|
464
|
+
if not identifier:
|
|
465
|
+
raise ValueError("Folder identifier is missing")
|
|
466
|
+
moved = await self._client.move_folder(identifier, new_path)
|
|
467
|
+
self._sync_from_folder(moved)
|
|
468
|
+
return moved
|
|
469
|
+
|
|
470
|
+
async def rename(self, new_name: str) -> "AsyncFolder":
|
|
471
|
+
"""Rename this folder (leaf segment only) and refresh local metadata."""
|
|
472
|
+
identifier = self._id or self.full_path
|
|
473
|
+
if not identifier:
|
|
474
|
+
raise ValueError("Folder identifier is missing")
|
|
475
|
+
renamed = await self._client.rename_folder(identifier, new_name)
|
|
476
|
+
self._sync_from_folder(renamed)
|
|
477
|
+
return renamed
|
|
478
|
+
|
|
449
479
|
async def get_summary(self) -> Summary:
|
|
450
480
|
"""Retrieve the latest summary for this folder."""
|
|
451
481
|
identifier = self._id or self.full_path
|
|
@@ -698,6 +728,50 @@ class AsyncMorphik(_ScopedOperationsMixin):
|
|
|
698
728
|
response = await self._request("DELETE", f"folders/{folder_id_or_name}")
|
|
699
729
|
return response
|
|
700
730
|
|
|
731
|
+
async def move_folder(self, folder_id_or_name: str, new_path: str) -> AsyncFolder:
|
|
732
|
+
"""
|
|
733
|
+
Move and/or rename a folder by setting its new canonical path.
|
|
734
|
+
|
|
735
|
+
Args:
|
|
736
|
+
folder_id_or_name: Folder ID, name, or full path
|
|
737
|
+
new_path: Target full path (e.g. "/projects/archive/specs")
|
|
738
|
+
|
|
739
|
+
Returns:
|
|
740
|
+
AsyncFolder: Updated folder metadata
|
|
741
|
+
"""
|
|
742
|
+
folder_param = build_folder_endpoint_identifier(folder_id_or_name)
|
|
743
|
+
payload = build_folder_move_payload(new_path=new_path)
|
|
744
|
+
response = await self._request("POST", f"folders/{folder_param}/move", data=payload)
|
|
745
|
+
info = FolderInfo(**response)
|
|
746
|
+
return AsyncFolder(
|
|
747
|
+
self,
|
|
748
|
+
info.name,
|
|
749
|
+
folder_id=info.id,
|
|
750
|
+
full_path=info.full_path,
|
|
751
|
+
parent_id=info.parent_id,
|
|
752
|
+
depth=info.depth,
|
|
753
|
+
child_count=info.child_count,
|
|
754
|
+
description=info.description,
|
|
755
|
+
)
|
|
756
|
+
|
|
757
|
+
async def rename_folder(self, folder_id_or_name: str, new_name: str) -> AsyncFolder:
|
|
758
|
+
"""
|
|
759
|
+
Rename a folder by changing only its leaf segment.
|
|
760
|
+
|
|
761
|
+
Args:
|
|
762
|
+
folder_id_or_name: Folder ID, name, or full path
|
|
763
|
+
new_name: New folder name (single segment, no '/')
|
|
764
|
+
|
|
765
|
+
Returns:
|
|
766
|
+
AsyncFolder: Updated folder metadata
|
|
767
|
+
"""
|
|
768
|
+
folder_param = build_folder_endpoint_identifier(folder_id_or_name)
|
|
769
|
+
current_info = FolderInfo(**(await self._request("GET", f"folders/{folder_param}")))
|
|
770
|
+
current_path = current_info.full_path or current_info.name
|
|
771
|
+
new_path = build_folder_rename_path(current_path=current_path, new_name=new_name)
|
|
772
|
+
move_identifier = current_info.id or folder_param
|
|
773
|
+
return await self.move_folder(move_identifier, new_path)
|
|
774
|
+
|
|
701
775
|
async def get_folder_summary(self, folder_id_or_path: str) -> Summary:
|
|
702
776
|
"""Get the persisted summary for a folder."""
|
|
703
777
|
folder_param = folder_id_or_path.lstrip("/") if folder_id_or_path else folder_id_or_path
|
|
@@ -12,6 +12,9 @@ from pydantic import BaseModel
|
|
|
12
12
|
from ._internal import FinalChunkResult, _MorphikClientLogic
|
|
13
13
|
from ._scoped_ops import _ScopedOperationsMixin
|
|
14
14
|
from ._shared import (
|
|
15
|
+
build_folder_endpoint_identifier,
|
|
16
|
+
build_folder_move_payload,
|
|
17
|
+
build_folder_rename_path,
|
|
15
18
|
build_create_app_payload,
|
|
16
19
|
build_document_by_filename_params,
|
|
17
20
|
build_list_apps_params,
|
|
@@ -442,6 +445,15 @@ class Folder(_ScopedClientOps):
|
|
|
442
445
|
"""Returns the folder ID if available."""
|
|
443
446
|
return self._id
|
|
444
447
|
|
|
448
|
+
def _sync_from_folder(self, folder: "Folder") -> None:
|
|
449
|
+
self._id = folder.id or self._id
|
|
450
|
+
self._name = folder.name
|
|
451
|
+
self._full_path = folder.full_path
|
|
452
|
+
self._parent_id = folder.parent_id
|
|
453
|
+
self._depth = folder.depth
|
|
454
|
+
self._child_count = folder.child_count
|
|
455
|
+
self._description = folder.description
|
|
456
|
+
|
|
445
457
|
def get_info(self) -> Dict[str, Any]:
|
|
446
458
|
"""
|
|
447
459
|
Get detailed information about this folder.
|
|
@@ -473,6 +485,24 @@ class Folder(_ScopedClientOps):
|
|
|
473
485
|
self._description = info.description or self._description
|
|
474
486
|
return info
|
|
475
487
|
|
|
488
|
+
def move(self, new_path: str) -> "Folder":
|
|
489
|
+
"""Move this folder to a new canonical path and refresh local metadata."""
|
|
490
|
+
identifier = self._id or self.full_path
|
|
491
|
+
if not identifier:
|
|
492
|
+
raise ValueError("Folder identifier is missing")
|
|
493
|
+
moved = self._client.move_folder(identifier, new_path)
|
|
494
|
+
self._sync_from_folder(moved)
|
|
495
|
+
return moved
|
|
496
|
+
|
|
497
|
+
def rename(self, new_name: str) -> "Folder":
|
|
498
|
+
"""Rename this folder (leaf segment only) and refresh local metadata."""
|
|
499
|
+
identifier = self._id or self.full_path
|
|
500
|
+
if not identifier:
|
|
501
|
+
raise ValueError("Folder identifier is missing")
|
|
502
|
+
renamed = self._client.rename_folder(identifier, new_name)
|
|
503
|
+
self._sync_from_folder(renamed)
|
|
504
|
+
return renamed
|
|
505
|
+
|
|
476
506
|
def get_summary(self) -> Summary:
|
|
477
507
|
"""Retrieve the latest summary for this folder."""
|
|
478
508
|
identifier = self._id or self.full_path
|
|
@@ -729,6 +759,50 @@ class Morphik(_ScopedOperationsMixin):
|
|
|
729
759
|
response = self._request("DELETE", f"folders/{folder_id_or_name}")
|
|
730
760
|
return response
|
|
731
761
|
|
|
762
|
+
def move_folder(self, folder_id_or_name: str, new_path: str) -> Folder:
|
|
763
|
+
"""
|
|
764
|
+
Move and/or rename a folder by setting its new canonical path.
|
|
765
|
+
|
|
766
|
+
Args:
|
|
767
|
+
folder_id_or_name: Folder ID, name, or full path
|
|
768
|
+
new_path: Target full path (e.g. "/projects/archive/specs")
|
|
769
|
+
|
|
770
|
+
Returns:
|
|
771
|
+
Folder: Updated folder metadata
|
|
772
|
+
"""
|
|
773
|
+
folder_param = build_folder_endpoint_identifier(folder_id_or_name)
|
|
774
|
+
payload = build_folder_move_payload(new_path=new_path)
|
|
775
|
+
response = self._request("POST", f"folders/{folder_param}/move", data=payload)
|
|
776
|
+
info = FolderInfo(**response)
|
|
777
|
+
return Folder(
|
|
778
|
+
self,
|
|
779
|
+
info.name,
|
|
780
|
+
folder_id=info.id,
|
|
781
|
+
full_path=info.full_path,
|
|
782
|
+
parent_id=info.parent_id,
|
|
783
|
+
depth=info.depth,
|
|
784
|
+
child_count=info.child_count,
|
|
785
|
+
description=info.description,
|
|
786
|
+
)
|
|
787
|
+
|
|
788
|
+
def rename_folder(self, folder_id_or_name: str, new_name: str) -> Folder:
|
|
789
|
+
"""
|
|
790
|
+
Rename a folder by changing only its leaf segment.
|
|
791
|
+
|
|
792
|
+
Args:
|
|
793
|
+
folder_id_or_name: Folder ID, name, or full path
|
|
794
|
+
new_name: New folder name (single segment, no '/')
|
|
795
|
+
|
|
796
|
+
Returns:
|
|
797
|
+
Folder: Updated folder metadata
|
|
798
|
+
"""
|
|
799
|
+
folder_param = build_folder_endpoint_identifier(folder_id_or_name)
|
|
800
|
+
current_info = FolderInfo(**self._request("GET", f"folders/{folder_param}"))
|
|
801
|
+
current_path = current_info.full_path or current_info.name
|
|
802
|
+
new_path = build_folder_rename_path(current_path=current_path, new_name=new_name)
|
|
803
|
+
move_identifier = current_info.id or folder_param
|
|
804
|
+
return self.move_folder(move_identifier, new_path)
|
|
805
|
+
|
|
732
806
|
def get_folder_summary(self, folder_id_or_path: str) -> Summary:
|
|
733
807
|
"""Get the persisted summary for a folder."""
|
|
734
808
|
folder_param = folder_id_or_path.lstrip("/") if folder_id_or_path else folder_id_or_path
|
|
@@ -36,8 +36,9 @@ class TestAsyncMorphik:
|
|
|
36
36
|
@pytest.fixture
|
|
37
37
|
async def db(self):
|
|
38
38
|
"""Create an AsyncMorphik client for testing"""
|
|
39
|
-
#
|
|
40
|
-
|
|
39
|
+
# Use dedicated test URI when provided; otherwise default localhost behavior
|
|
40
|
+
uri = os.environ.get("MORPHIK_TEST_URI")
|
|
41
|
+
client = AsyncMorphik(uri=uri, timeout=120) if uri else AsyncMorphik(timeout=120)
|
|
41
42
|
yield client
|
|
42
43
|
await client.close()
|
|
43
44
|
|
|
@@ -150,6 +151,79 @@ class TestAsyncMorphik:
|
|
|
150
151
|
|
|
151
152
|
# TODO: Add folder deletion when API supports it
|
|
152
153
|
|
|
154
|
+
@pytest.mark.asyncio
|
|
155
|
+
async def test_direct_http_uri_ping(self):
|
|
156
|
+
"""Test direct HTTP base URL initialization and connectivity (async)."""
|
|
157
|
+
uri = os.environ.get("MORPHIK_TEST_URI", "http://localhost:8000")
|
|
158
|
+
client = AsyncMorphik(uri, timeout=30)
|
|
159
|
+
try:
|
|
160
|
+
response = await client.ping()
|
|
161
|
+
assert response.get("status") == "ok"
|
|
162
|
+
finally:
|
|
163
|
+
await client.close()
|
|
164
|
+
|
|
165
|
+
@pytest.mark.asyncio
|
|
166
|
+
async def test_move_and_rename_folder(self, db):
|
|
167
|
+
"""Test moving and renaming folders via async client-level APIs."""
|
|
168
|
+
suffix = uuid.uuid4().hex[:8]
|
|
169
|
+
original_path = f"/it_async_{suffix}/leaf"
|
|
170
|
+
moved_path = f"/it_async_moved_{suffix}/leaf"
|
|
171
|
+
renamed_leaf = f"leaf_renamed_{suffix}"
|
|
172
|
+
expected_renamed_path = f"/it_async_moved_{suffix}/{renamed_leaf}"
|
|
173
|
+
|
|
174
|
+
folder = await db.create_folder(
|
|
175
|
+
name=f"leaf_{suffix}",
|
|
176
|
+
full_path=original_path,
|
|
177
|
+
description="integration move test",
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
moved = None
|
|
181
|
+
renamed = None
|
|
182
|
+
try:
|
|
183
|
+
moved = await db.move_folder(folder.id or original_path, moved_path)
|
|
184
|
+
assert moved.id == folder.id
|
|
185
|
+
assert moved.full_path == moved_path
|
|
186
|
+
assert moved.name == "leaf"
|
|
187
|
+
|
|
188
|
+
fetched_moved = await db.get_folder(moved.id or moved_path)
|
|
189
|
+
assert fetched_moved.full_path == moved_path
|
|
190
|
+
|
|
191
|
+
renamed = await db.rename_folder(moved.id or moved_path, renamed_leaf)
|
|
192
|
+
assert renamed.id == folder.id
|
|
193
|
+
assert renamed.name == renamed_leaf
|
|
194
|
+
assert renamed.full_path == expected_renamed_path
|
|
195
|
+
|
|
196
|
+
fetched_renamed = await db.get_folder(renamed.id or expected_renamed_path)
|
|
197
|
+
assert fetched_renamed.full_path == expected_renamed_path
|
|
198
|
+
finally:
|
|
199
|
+
target = renamed or moved or folder
|
|
200
|
+
if target and target.id:
|
|
201
|
+
await db.delete_folder(target.id)
|
|
202
|
+
|
|
203
|
+
@pytest.mark.asyncio
|
|
204
|
+
async def test_folder_object_move_and_rename(self, db):
|
|
205
|
+
"""Test AsyncFolder convenience move/rename methods keep local metadata in sync."""
|
|
206
|
+
suffix = uuid.uuid4().hex[:8]
|
|
207
|
+
original_path = f"/it_async_obj_{suffix}/leaf"
|
|
208
|
+
moved_path = f"/it_async_obj_moved_{suffix}/leaf"
|
|
209
|
+
renamed_leaf = f"leaf_obj_renamed_{suffix}"
|
|
210
|
+
expected_renamed_path = f"/it_async_obj_moved_{suffix}/{renamed_leaf}"
|
|
211
|
+
|
|
212
|
+
folder = await db.create_folder(name=f"leaf_obj_{suffix}", full_path=original_path)
|
|
213
|
+
try:
|
|
214
|
+
moved = await folder.move(moved_path)
|
|
215
|
+
assert moved.full_path == moved_path
|
|
216
|
+
assert folder.full_path == moved_path
|
|
217
|
+
|
|
218
|
+
renamed = await folder.rename(renamed_leaf)
|
|
219
|
+
assert renamed.full_path == expected_renamed_path
|
|
220
|
+
assert renamed.name == renamed_leaf
|
|
221
|
+
assert folder.full_path == expected_renamed_path
|
|
222
|
+
assert folder.name == renamed_leaf
|
|
223
|
+
finally:
|
|
224
|
+
if folder.id:
|
|
225
|
+
await db.delete_folder(folder.id)
|
|
226
|
+
|
|
153
227
|
@pytest.mark.asyncio
|
|
154
228
|
async def test_user_scope(self, db):
|
|
155
229
|
"""Test user scoped operations"""
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import httpx
|
|
2
|
+
import jwt
|
|
2
3
|
import pytest
|
|
3
4
|
from morphik.async_ import AsyncMorphik
|
|
4
5
|
from morphik.sync import Folder, Morphik
|
|
@@ -160,6 +161,56 @@ def test_sync_client_http2_toggle(monkeypatch):
|
|
|
160
161
|
assert captured[-1] is False
|
|
161
162
|
|
|
162
163
|
|
|
164
|
+
def test_sync_client_accepts_plain_http_uri_without_auth():
|
|
165
|
+
client = Morphik("http://0.0.0.0:8000")
|
|
166
|
+
try:
|
|
167
|
+
assert client._logic._base_url == "http://0.0.0.0:8000"
|
|
168
|
+
assert client._logic._auth_token is None
|
|
169
|
+
assert client._logic._is_local is True
|
|
170
|
+
finally:
|
|
171
|
+
client.close()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_sync_client_preserves_http_path_prefix():
|
|
175
|
+
client = Morphik("https://api.example.com/morphik/v1/")
|
|
176
|
+
try:
|
|
177
|
+
assert client._logic._base_url == "https://api.example.com/morphik/v1"
|
|
178
|
+
assert client._logic._get_url("ping") == "https://api.example.com/morphik/v1/ping"
|
|
179
|
+
finally:
|
|
180
|
+
client.close()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_sync_client_parses_morphik_uri_with_token():
|
|
184
|
+
token = jwt.encode({"sub": "test-user"}, "test-secret", algorithm="HS256")
|
|
185
|
+
client = Morphik(f"morphik://owner:{token}@api.morphik.ai")
|
|
186
|
+
try:
|
|
187
|
+
assert client._logic._base_url == "https://api.morphik.ai"
|
|
188
|
+
assert client._logic._auth_token == token
|
|
189
|
+
finally:
|
|
190
|
+
client.close()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@pytest.mark.asyncio
|
|
194
|
+
async def test_async_client_accepts_plain_http_uri_without_auth():
|
|
195
|
+
client = AsyncMorphik("http://0.0.0.0:8000")
|
|
196
|
+
try:
|
|
197
|
+
assert client._logic._base_url == "http://0.0.0.0:8000"
|
|
198
|
+
assert client._logic._auth_token is None
|
|
199
|
+
assert client._logic._is_local is True
|
|
200
|
+
finally:
|
|
201
|
+
await client.close()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@pytest.mark.asyncio
|
|
205
|
+
async def test_async_client_preserves_http_path_prefix():
|
|
206
|
+
client = AsyncMorphik("https://api.example.com/morphik/v1/")
|
|
207
|
+
try:
|
|
208
|
+
assert client._logic._base_url == "https://api.example.com/morphik/v1"
|
|
209
|
+
assert client._logic._get_url("ping") == "https://api.example.com/morphik/v1/ping"
|
|
210
|
+
finally:
|
|
211
|
+
await client.close()
|
|
212
|
+
|
|
213
|
+
|
|
163
214
|
@pytest.mark.asyncio
|
|
164
215
|
async def test_async_http2_fallback_on_remote_protocol_error(monkeypatch):
|
|
165
216
|
created_http2 = []
|
|
@@ -492,6 +543,208 @@ def test_folder_hierarchy_properties():
|
|
|
492
543
|
client.close()
|
|
493
544
|
|
|
494
545
|
|
|
546
|
+
def test_sync_move_folder_calls_move_endpoint():
|
|
547
|
+
client, calls = _make_sync_client()
|
|
548
|
+
try:
|
|
549
|
+
original_request = client._request
|
|
550
|
+
|
|
551
|
+
def mock_request(method, endpoint, data=None, files=None, params=None):
|
|
552
|
+
calls.append({"method": method, "endpoint": endpoint, "data": data, "params": params})
|
|
553
|
+
if endpoint == "folders/folder-123/move":
|
|
554
|
+
return {
|
|
555
|
+
"id": "folder-123",
|
|
556
|
+
"name": "archived",
|
|
557
|
+
"full_path": "/projects/archived",
|
|
558
|
+
"parent_id": "parent-1",
|
|
559
|
+
"depth": 2,
|
|
560
|
+
"child_count": 0,
|
|
561
|
+
"description": "moved folder",
|
|
562
|
+
}
|
|
563
|
+
return original_request(method, endpoint, data, files, params)
|
|
564
|
+
|
|
565
|
+
client._request = mock_request
|
|
566
|
+
|
|
567
|
+
moved = client.move_folder("folder-123", "/projects/archived")
|
|
568
|
+
move_call = calls.pop()
|
|
569
|
+
assert move_call["method"] == "POST"
|
|
570
|
+
assert move_call["endpoint"] == "folders/folder-123/move"
|
|
571
|
+
assert move_call["data"] == {"new_path": "/projects/archived"}
|
|
572
|
+
assert moved.id == "folder-123"
|
|
573
|
+
assert moved.full_path == "/projects/archived"
|
|
574
|
+
assert moved.name == "archived"
|
|
575
|
+
finally:
|
|
576
|
+
client.close()
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def test_sync_move_folder_encodes_special_chars_in_identifier():
|
|
580
|
+
client, calls = _make_sync_client()
|
|
581
|
+
try:
|
|
582
|
+
original_request = client._request
|
|
583
|
+
|
|
584
|
+
def mock_request(method, endpoint, data=None, files=None, params=None):
|
|
585
|
+
calls.append({"method": method, "endpoint": endpoint, "data": data, "params": params})
|
|
586
|
+
if endpoint == "folders/team/a%231/move":
|
|
587
|
+
return {
|
|
588
|
+
"id": "folder-123",
|
|
589
|
+
"name": "a1",
|
|
590
|
+
"full_path": "/team/a1",
|
|
591
|
+
"parent_id": "parent-1",
|
|
592
|
+
"depth": 2,
|
|
593
|
+
"child_count": 0,
|
|
594
|
+
"description": "moved folder",
|
|
595
|
+
}
|
|
596
|
+
return original_request(method, endpoint, data, files, params)
|
|
597
|
+
|
|
598
|
+
client._request = mock_request
|
|
599
|
+
|
|
600
|
+
moved = client.move_folder("/team/a#1", "/team/a1")
|
|
601
|
+
move_call = calls.pop()
|
|
602
|
+
assert move_call["method"] == "POST"
|
|
603
|
+
assert move_call["endpoint"] == "folders/team/a%231/move"
|
|
604
|
+
assert move_call["data"] == {"new_path": "/team/a1"}
|
|
605
|
+
assert moved.full_path == "/team/a1"
|
|
606
|
+
finally:
|
|
607
|
+
client.close()
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def test_sync_rename_folder_derives_target_path_from_existing_folder():
|
|
611
|
+
client, calls = _make_sync_client()
|
|
612
|
+
try:
|
|
613
|
+
original_request = client._request
|
|
614
|
+
|
|
615
|
+
def mock_request(method, endpoint, data=None, files=None, params=None):
|
|
616
|
+
calls.append({"method": method, "endpoint": endpoint, "data": data, "params": params})
|
|
617
|
+
if method == "GET" and endpoint == "folders/folder-123":
|
|
618
|
+
return {
|
|
619
|
+
"id": "folder-123",
|
|
620
|
+
"name": "old-name",
|
|
621
|
+
"full_path": "/team/old-name",
|
|
622
|
+
"parent_id": "parent-1",
|
|
623
|
+
"depth": 2,
|
|
624
|
+
"child_count": 0,
|
|
625
|
+
"description": "original",
|
|
626
|
+
}
|
|
627
|
+
if method == "POST" and endpoint == "folders/folder-123/move":
|
|
628
|
+
return {
|
|
629
|
+
"id": "folder-123",
|
|
630
|
+
"name": "new-name",
|
|
631
|
+
"full_path": "/team/new-name",
|
|
632
|
+
"parent_id": "parent-1",
|
|
633
|
+
"depth": 2,
|
|
634
|
+
"child_count": 0,
|
|
635
|
+
"description": "renamed",
|
|
636
|
+
}
|
|
637
|
+
return original_request(method, endpoint, data, files, params)
|
|
638
|
+
|
|
639
|
+
client._request = mock_request
|
|
640
|
+
|
|
641
|
+
renamed = client.rename_folder("folder-123", "new-name")
|
|
642
|
+
|
|
643
|
+
get_call = calls[-2]
|
|
644
|
+
move_call = calls[-1]
|
|
645
|
+
assert get_call["method"] == "GET"
|
|
646
|
+
assert get_call["endpoint"] == "folders/folder-123"
|
|
647
|
+
assert move_call["method"] == "POST"
|
|
648
|
+
assert move_call["endpoint"] == "folders/folder-123/move"
|
|
649
|
+
assert move_call["data"] == {"new_path": "/team/new-name"}
|
|
650
|
+
assert renamed.name == "new-name"
|
|
651
|
+
assert renamed.full_path == "/team/new-name"
|
|
652
|
+
finally:
|
|
653
|
+
client.close()
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def test_sync_rename_folder_encodes_identifier_for_get_and_move():
|
|
657
|
+
client, calls = _make_sync_client()
|
|
658
|
+
try:
|
|
659
|
+
original_request = client._request
|
|
660
|
+
|
|
661
|
+
def mock_request(method, endpoint, data=None, files=None, params=None):
|
|
662
|
+
calls.append({"method": method, "endpoint": endpoint, "data": data, "params": params})
|
|
663
|
+
if method == "GET" and endpoint == "folders/team/a%231":
|
|
664
|
+
return {
|
|
665
|
+
"id": "folder#123",
|
|
666
|
+
"name": "a#1",
|
|
667
|
+
"full_path": "/team/a#1",
|
|
668
|
+
"parent_id": "parent-1",
|
|
669
|
+
"depth": 2,
|
|
670
|
+
"child_count": 0,
|
|
671
|
+
"description": "original",
|
|
672
|
+
}
|
|
673
|
+
if method == "POST" and endpoint == "folders/folder%23123/move":
|
|
674
|
+
return {
|
|
675
|
+
"id": "folder#123",
|
|
676
|
+
"name": "a-new",
|
|
677
|
+
"full_path": "/team/a-new",
|
|
678
|
+
"parent_id": "parent-1",
|
|
679
|
+
"depth": 2,
|
|
680
|
+
"child_count": 0,
|
|
681
|
+
"description": "renamed",
|
|
682
|
+
}
|
|
683
|
+
return original_request(method, endpoint, data, files, params)
|
|
684
|
+
|
|
685
|
+
client._request = mock_request
|
|
686
|
+
|
|
687
|
+
renamed = client.rename_folder("/team/a#1", "a-new")
|
|
688
|
+
|
|
689
|
+
get_call = calls[-2]
|
|
690
|
+
move_call = calls[-1]
|
|
691
|
+
assert get_call["method"] == "GET"
|
|
692
|
+
assert get_call["endpoint"] == "folders/team/a%231"
|
|
693
|
+
assert move_call["method"] == "POST"
|
|
694
|
+
assert move_call["endpoint"] == "folders/folder%23123/move"
|
|
695
|
+
assert move_call["data"] == {"new_path": "/team/a-new"}
|
|
696
|
+
assert renamed.full_path == "/team/a-new"
|
|
697
|
+
finally:
|
|
698
|
+
client.close()
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def test_sync_folder_move_and_rename_refresh_local_metadata():
|
|
702
|
+
client, _ = _make_sync_client()
|
|
703
|
+
try:
|
|
704
|
+
folder = Folder(client, "docs", folder_id="folder-123", full_path="/team/docs")
|
|
705
|
+
|
|
706
|
+
def mock_move(folder_id_or_name, new_path):
|
|
707
|
+
if new_path == "/archive/docs":
|
|
708
|
+
return Folder(
|
|
709
|
+
client,
|
|
710
|
+
"docs",
|
|
711
|
+
folder_id="folder-123",
|
|
712
|
+
full_path="/archive/docs",
|
|
713
|
+
parent_id="parent-arch",
|
|
714
|
+
depth=2,
|
|
715
|
+
child_count=0,
|
|
716
|
+
description="moved",
|
|
717
|
+
)
|
|
718
|
+
return Folder(
|
|
719
|
+
client,
|
|
720
|
+
"docs-v2",
|
|
721
|
+
folder_id="folder-123",
|
|
722
|
+
full_path="/archive/docs-v2",
|
|
723
|
+
parent_id="parent-arch",
|
|
724
|
+
depth=2,
|
|
725
|
+
child_count=0,
|
|
726
|
+
description="renamed",
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
client.move_folder = mock_move # type: ignore[method-assign]
|
|
730
|
+
client.rename_folder = lambda folder_id_or_name, new_name: mock_move( # type: ignore[method-assign]
|
|
731
|
+
folder_id_or_name, "/archive/docs-v2"
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
moved = folder.move("/archive/docs")
|
|
735
|
+
assert moved.full_path == "/archive/docs"
|
|
736
|
+
assert folder.full_path == "/archive/docs"
|
|
737
|
+
assert folder.parent_id == "parent-arch"
|
|
738
|
+
|
|
739
|
+
renamed = folder.rename("docs-v2")
|
|
740
|
+
assert renamed.name == "docs-v2"
|
|
741
|
+
assert renamed.full_path == "/archive/docs-v2"
|
|
742
|
+
assert folder.name == "docs-v2"
|
|
743
|
+
assert folder.full_path == "/archive/docs-v2"
|
|
744
|
+
finally:
|
|
745
|
+
client.close()
|
|
746
|
+
|
|
747
|
+
|
|
495
748
|
def test_folder_uses_full_path_for_operations():
|
|
496
749
|
"""Test that Folder operations use full_path instead of name."""
|
|
497
750
|
client, calls = _make_sync_client()
|
|
@@ -680,6 +933,215 @@ async def test_async_folder_hierarchy_properties():
|
|
|
680
933
|
await client.close()
|
|
681
934
|
|
|
682
935
|
|
|
936
|
+
@pytest.mark.asyncio
|
|
937
|
+
async def test_async_move_folder_calls_move_endpoint():
|
|
938
|
+
client, calls = await _make_async_client()
|
|
939
|
+
try:
|
|
940
|
+
original_request = client._request
|
|
941
|
+
|
|
942
|
+
async def mock_request(method, endpoint, data=None, files=None, params=None):
|
|
943
|
+
calls.append({"method": method, "endpoint": endpoint, "data": data, "params": params})
|
|
944
|
+
if endpoint == "folders/folder-123/move":
|
|
945
|
+
return {
|
|
946
|
+
"id": "folder-123",
|
|
947
|
+
"name": "archived",
|
|
948
|
+
"full_path": "/projects/archived",
|
|
949
|
+
"parent_id": "parent-1",
|
|
950
|
+
"depth": 2,
|
|
951
|
+
"child_count": 0,
|
|
952
|
+
"description": "moved folder",
|
|
953
|
+
}
|
|
954
|
+
return await original_request(method, endpoint, data, files, params)
|
|
955
|
+
|
|
956
|
+
client._request = mock_request
|
|
957
|
+
|
|
958
|
+
moved = await client.move_folder("folder-123", "/projects/archived")
|
|
959
|
+
move_call = calls.pop()
|
|
960
|
+
assert move_call["method"] == "POST"
|
|
961
|
+
assert move_call["endpoint"] == "folders/folder-123/move"
|
|
962
|
+
assert move_call["data"] == {"new_path": "/projects/archived"}
|
|
963
|
+
assert moved.id == "folder-123"
|
|
964
|
+
assert moved.full_path == "/projects/archived"
|
|
965
|
+
assert moved.name == "archived"
|
|
966
|
+
finally:
|
|
967
|
+
await client.close()
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
@pytest.mark.asyncio
|
|
971
|
+
async def test_async_move_folder_encodes_special_chars_in_identifier():
|
|
972
|
+
client, calls = await _make_async_client()
|
|
973
|
+
try:
|
|
974
|
+
original_request = client._request
|
|
975
|
+
|
|
976
|
+
async def mock_request(method, endpoint, data=None, files=None, params=None):
|
|
977
|
+
calls.append({"method": method, "endpoint": endpoint, "data": data, "params": params})
|
|
978
|
+
if endpoint == "folders/team/a%231/move":
|
|
979
|
+
return {
|
|
980
|
+
"id": "folder-123",
|
|
981
|
+
"name": "a1",
|
|
982
|
+
"full_path": "/team/a1",
|
|
983
|
+
"parent_id": "parent-1",
|
|
984
|
+
"depth": 2,
|
|
985
|
+
"child_count": 0,
|
|
986
|
+
"description": "moved folder",
|
|
987
|
+
}
|
|
988
|
+
return await original_request(method, endpoint, data, files, params)
|
|
989
|
+
|
|
990
|
+
client._request = mock_request
|
|
991
|
+
|
|
992
|
+
moved = await client.move_folder("/team/a#1", "/team/a1")
|
|
993
|
+
move_call = calls.pop()
|
|
994
|
+
assert move_call["method"] == "POST"
|
|
995
|
+
assert move_call["endpoint"] == "folders/team/a%231/move"
|
|
996
|
+
assert move_call["data"] == {"new_path": "/team/a1"}
|
|
997
|
+
assert moved.full_path == "/team/a1"
|
|
998
|
+
finally:
|
|
999
|
+
await client.close()
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
@pytest.mark.asyncio
|
|
1003
|
+
async def test_async_rename_folder_derives_target_path_from_existing_folder():
|
|
1004
|
+
client, calls = await _make_async_client()
|
|
1005
|
+
try:
|
|
1006
|
+
original_request = client._request
|
|
1007
|
+
|
|
1008
|
+
async def mock_request(method, endpoint, data=None, files=None, params=None):
|
|
1009
|
+
calls.append({"method": method, "endpoint": endpoint, "data": data, "params": params})
|
|
1010
|
+
if method == "GET" and endpoint == "folders/folder-123":
|
|
1011
|
+
return {
|
|
1012
|
+
"id": "folder-123",
|
|
1013
|
+
"name": "old-name",
|
|
1014
|
+
"full_path": "/team/old-name",
|
|
1015
|
+
"parent_id": "parent-1",
|
|
1016
|
+
"depth": 2,
|
|
1017
|
+
"child_count": 0,
|
|
1018
|
+
"description": "original",
|
|
1019
|
+
}
|
|
1020
|
+
if method == "POST" and endpoint == "folders/folder-123/move":
|
|
1021
|
+
return {
|
|
1022
|
+
"id": "folder-123",
|
|
1023
|
+
"name": "new-name",
|
|
1024
|
+
"full_path": "/team/new-name",
|
|
1025
|
+
"parent_id": "parent-1",
|
|
1026
|
+
"depth": 2,
|
|
1027
|
+
"child_count": 0,
|
|
1028
|
+
"description": "renamed",
|
|
1029
|
+
}
|
|
1030
|
+
return await original_request(method, endpoint, data, files, params)
|
|
1031
|
+
|
|
1032
|
+
client._request = mock_request
|
|
1033
|
+
|
|
1034
|
+
renamed = await client.rename_folder("folder-123", "new-name")
|
|
1035
|
+
get_call = calls[-2]
|
|
1036
|
+
move_call = calls[-1]
|
|
1037
|
+
assert get_call["method"] == "GET"
|
|
1038
|
+
assert get_call["endpoint"] == "folders/folder-123"
|
|
1039
|
+
assert move_call["method"] == "POST"
|
|
1040
|
+
assert move_call["endpoint"] == "folders/folder-123/move"
|
|
1041
|
+
assert move_call["data"] == {"new_path": "/team/new-name"}
|
|
1042
|
+
assert renamed.name == "new-name"
|
|
1043
|
+
assert renamed.full_path == "/team/new-name"
|
|
1044
|
+
finally:
|
|
1045
|
+
await client.close()
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
@pytest.mark.asyncio
|
|
1049
|
+
async def test_async_rename_folder_encodes_identifier_for_get_and_move():
|
|
1050
|
+
client, calls = await _make_async_client()
|
|
1051
|
+
try:
|
|
1052
|
+
original_request = client._request
|
|
1053
|
+
|
|
1054
|
+
async def mock_request(method, endpoint, data=None, files=None, params=None):
|
|
1055
|
+
calls.append({"method": method, "endpoint": endpoint, "data": data, "params": params})
|
|
1056
|
+
if method == "GET" and endpoint == "folders/team/a%231":
|
|
1057
|
+
return {
|
|
1058
|
+
"id": "folder#123",
|
|
1059
|
+
"name": "a#1",
|
|
1060
|
+
"full_path": "/team/a#1",
|
|
1061
|
+
"parent_id": "parent-1",
|
|
1062
|
+
"depth": 2,
|
|
1063
|
+
"child_count": 0,
|
|
1064
|
+
"description": "original",
|
|
1065
|
+
}
|
|
1066
|
+
if method == "POST" and endpoint == "folders/folder%23123/move":
|
|
1067
|
+
return {
|
|
1068
|
+
"id": "folder#123",
|
|
1069
|
+
"name": "a-new",
|
|
1070
|
+
"full_path": "/team/a-new",
|
|
1071
|
+
"parent_id": "parent-1",
|
|
1072
|
+
"depth": 2,
|
|
1073
|
+
"child_count": 0,
|
|
1074
|
+
"description": "renamed",
|
|
1075
|
+
}
|
|
1076
|
+
return await original_request(method, endpoint, data, files, params)
|
|
1077
|
+
|
|
1078
|
+
client._request = mock_request
|
|
1079
|
+
|
|
1080
|
+
renamed = await client.rename_folder("/team/a#1", "a-new")
|
|
1081
|
+
get_call = calls[-2]
|
|
1082
|
+
move_call = calls[-1]
|
|
1083
|
+
assert get_call["method"] == "GET"
|
|
1084
|
+
assert get_call["endpoint"] == "folders/team/a%231"
|
|
1085
|
+
assert move_call["method"] == "POST"
|
|
1086
|
+
assert move_call["endpoint"] == "folders/folder%23123/move"
|
|
1087
|
+
assert move_call["data"] == {"new_path": "/team/a-new"}
|
|
1088
|
+
assert renamed.full_path == "/team/a-new"
|
|
1089
|
+
finally:
|
|
1090
|
+
await client.close()
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
@pytest.mark.asyncio
|
|
1094
|
+
async def test_async_folder_move_and_rename_refresh_local_metadata():
|
|
1095
|
+
from morphik.async_ import AsyncFolder
|
|
1096
|
+
|
|
1097
|
+
client, _ = await _make_async_client()
|
|
1098
|
+
try:
|
|
1099
|
+
folder = AsyncFolder(client, "docs", folder_id="folder-123", full_path="/team/docs")
|
|
1100
|
+
|
|
1101
|
+
async def mock_move(folder_id_or_name, new_path):
|
|
1102
|
+
if new_path == "/archive/docs":
|
|
1103
|
+
return AsyncFolder(
|
|
1104
|
+
client,
|
|
1105
|
+
"docs",
|
|
1106
|
+
folder_id="folder-123",
|
|
1107
|
+
full_path="/archive/docs",
|
|
1108
|
+
parent_id="parent-arch",
|
|
1109
|
+
depth=2,
|
|
1110
|
+
child_count=0,
|
|
1111
|
+
description="moved",
|
|
1112
|
+
)
|
|
1113
|
+
return AsyncFolder(
|
|
1114
|
+
client,
|
|
1115
|
+
"docs-v2",
|
|
1116
|
+
folder_id="folder-123",
|
|
1117
|
+
full_path="/archive/docs-v2",
|
|
1118
|
+
parent_id="parent-arch",
|
|
1119
|
+
depth=2,
|
|
1120
|
+
child_count=0,
|
|
1121
|
+
description="renamed",
|
|
1122
|
+
)
|
|
1123
|
+
|
|
1124
|
+
client.move_folder = mock_move # type: ignore[method-assign]
|
|
1125
|
+
|
|
1126
|
+
async def mock_rename(folder_id_or_name, new_name):
|
|
1127
|
+
return await mock_move(folder_id_or_name, "/archive/docs-v2")
|
|
1128
|
+
|
|
1129
|
+
client.rename_folder = mock_rename # type: ignore[method-assign]
|
|
1130
|
+
|
|
1131
|
+
moved = await folder.move("/archive/docs")
|
|
1132
|
+
assert moved.full_path == "/archive/docs"
|
|
1133
|
+
assert folder.full_path == "/archive/docs"
|
|
1134
|
+
assert folder.parent_id == "parent-arch"
|
|
1135
|
+
|
|
1136
|
+
renamed = await folder.rename("docs-v2")
|
|
1137
|
+
assert renamed.name == "docs-v2"
|
|
1138
|
+
assert renamed.full_path == "/archive/docs-v2"
|
|
1139
|
+
assert folder.name == "docs-v2"
|
|
1140
|
+
assert folder.full_path == "/archive/docs-v2"
|
|
1141
|
+
finally:
|
|
1142
|
+
await client.close()
|
|
1143
|
+
|
|
1144
|
+
|
|
683
1145
|
@pytest.mark.asyncio
|
|
684
1146
|
async def test_async_folder_uses_full_path():
|
|
685
1147
|
"""Test that AsyncFolder operations use full_path."""
|
|
@@ -36,8 +36,9 @@ class TestMorphik:
|
|
|
36
36
|
@pytest.fixture
|
|
37
37
|
def db(self):
|
|
38
38
|
"""Create a Morphik client for testing"""
|
|
39
|
-
#
|
|
40
|
-
|
|
39
|
+
# Use dedicated test URI when provided; otherwise default localhost behavior
|
|
40
|
+
uri = os.environ.get("MORPHIK_TEST_URI")
|
|
41
|
+
client = Morphik(uri=uri, timeout=120) if uri else Morphik(timeout=120)
|
|
41
42
|
yield client
|
|
42
43
|
client.close()
|
|
43
44
|
|
|
@@ -146,6 +147,72 @@ class TestMorphik:
|
|
|
146
147
|
|
|
147
148
|
# TODO: Add folder deletion when API supports it
|
|
148
149
|
|
|
150
|
+
def test_direct_http_uri_ping(self):
|
|
151
|
+
"""Test direct HTTP base URL initialization and connectivity."""
|
|
152
|
+
uri = os.environ.get("MORPHIK_TEST_URI", "http://localhost:8000")
|
|
153
|
+
client = Morphik(uri, timeout=30)
|
|
154
|
+
try:
|
|
155
|
+
response = client.ping()
|
|
156
|
+
assert response.get("status") == "ok"
|
|
157
|
+
finally:
|
|
158
|
+
client.close()
|
|
159
|
+
|
|
160
|
+
def test_move_and_rename_folder(self, db):
|
|
161
|
+
"""Test moving and renaming folders via client-level APIs."""
|
|
162
|
+
suffix = uuid.uuid4().hex[:8]
|
|
163
|
+
original_path = f"/it_sync_{suffix}/leaf"
|
|
164
|
+
moved_path = f"/it_sync_moved_{suffix}/leaf"
|
|
165
|
+
renamed_leaf = f"leaf_renamed_{suffix}"
|
|
166
|
+
expected_renamed_path = f"/it_sync_moved_{suffix}/{renamed_leaf}"
|
|
167
|
+
|
|
168
|
+
folder = db.create_folder(name=f"leaf_{suffix}", full_path=original_path, description="integration move test")
|
|
169
|
+
|
|
170
|
+
moved = None
|
|
171
|
+
renamed = None
|
|
172
|
+
try:
|
|
173
|
+
moved = db.move_folder(folder.id or original_path, moved_path)
|
|
174
|
+
assert moved.id == folder.id
|
|
175
|
+
assert moved.full_path == moved_path
|
|
176
|
+
assert moved.name == "leaf"
|
|
177
|
+
|
|
178
|
+
fetched_moved = db.get_folder(moved.id or moved_path)
|
|
179
|
+
assert fetched_moved.full_path == moved_path
|
|
180
|
+
|
|
181
|
+
renamed = db.rename_folder(moved.id or moved_path, renamed_leaf)
|
|
182
|
+
assert renamed.id == folder.id
|
|
183
|
+
assert renamed.name == renamed_leaf
|
|
184
|
+
assert renamed.full_path == expected_renamed_path
|
|
185
|
+
|
|
186
|
+
fetched_renamed = db.get_folder(renamed.id or expected_renamed_path)
|
|
187
|
+
assert fetched_renamed.full_path == expected_renamed_path
|
|
188
|
+
finally:
|
|
189
|
+
target = renamed or moved or folder
|
|
190
|
+
if target and target.id:
|
|
191
|
+
db.delete_folder(target.id)
|
|
192
|
+
|
|
193
|
+
def test_folder_object_move_and_rename(self, db):
|
|
194
|
+
"""Test Folder convenience move/rename methods keep local metadata in sync."""
|
|
195
|
+
suffix = uuid.uuid4().hex[:8]
|
|
196
|
+
original_path = f"/it_sync_obj_{suffix}/leaf"
|
|
197
|
+
moved_path = f"/it_sync_obj_moved_{suffix}/leaf"
|
|
198
|
+
renamed_leaf = f"leaf_obj_renamed_{suffix}"
|
|
199
|
+
expected_renamed_path = f"/it_sync_obj_moved_{suffix}/{renamed_leaf}"
|
|
200
|
+
|
|
201
|
+
folder = db.create_folder(name=f"leaf_obj_{suffix}", full_path=original_path)
|
|
202
|
+
try:
|
|
203
|
+
moved = folder.move(moved_path)
|
|
204
|
+
assert moved.full_path == moved_path
|
|
205
|
+
assert folder.full_path == moved_path
|
|
206
|
+
|
|
207
|
+
renamed = folder.rename(renamed_leaf)
|
|
208
|
+
assert renamed.full_path == expected_renamed_path
|
|
209
|
+
assert renamed.name == renamed_leaf
|
|
210
|
+
assert folder.full_path == expected_renamed_path
|
|
211
|
+
assert folder.name == renamed_leaf
|
|
212
|
+
finally:
|
|
213
|
+
if folder.id:
|
|
214
|
+
db.delete_folder(folder.id)
|
|
215
|
+
|
|
149
216
|
def test_user_scope(self, db):
|
|
150
217
|
"""Test user scoped operations"""
|
|
151
218
|
# Create a unique user ID
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|