morphik 1.2.1__tar.gz → 1.2.3__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.3}/.gitignore +2 -1
- {morphik-1.2.1 → morphik-1.2.3}/PKG-INFO +16 -1
- {morphik-1.2.1 → morphik-1.2.3}/README.md +15 -0
- {morphik-1.2.1 → morphik-1.2.3}/morphik/__init__.py +1 -1
- {morphik-1.2.1 → morphik-1.2.3}/morphik/_internal.py +32 -3
- {morphik-1.2.1 → morphik-1.2.3}/morphik/_scoped_ops.py +2 -0
- {morphik-1.2.1 → morphik-1.2.3}/morphik/_shared.py +38 -0
- {morphik-1.2.1 → morphik-1.2.3}/morphik/async_.py +88 -1
- {morphik-1.2.1 → morphik-1.2.3}/morphik/sync.py +87 -0
- {morphik-1.2.1 → morphik-1.2.3}/morphik/tests/test_async.py +76 -2
- {morphik-1.2.1 → morphik-1.2.3}/morphik/tests/test_scoped_ops_unit.py +495 -0
- {morphik-1.2.1 → morphik-1.2.3}/morphik/tests/test_sync.py +69 -2
- {morphik-1.2.1 → morphik-1.2.3}/pyproject.toml +1 -1
- {morphik-1.2.1 → morphik-1.2.3}/morphik/exceptions.py +0 -0
- {morphik-1.2.1 → morphik-1.2.3}/morphik/models.py +0 -0
- {morphik-1.2.1 → morphik-1.2.3}/morphik/tests/README.md +0 -0
- {morphik-1.2.1 → morphik-1.2.3}/morphik/tests/__init__.py +0 -0
- {morphik-1.2.1 → morphik-1.2.3}/morphik/tests/example_usage.py +0 -0
- {morphik-1.2.1 → morphik-1.2.3}/morphik/tests/test_app_ops.py +0 -0
- {morphik-1.2.1 → morphik-1.2.3}/morphik/tests/test_docs/sample1.txt +0 -0
- {morphik-1.2.1 → morphik-1.2.3}/morphik/tests/test_docs/sample2.txt +0 -0
- {morphik-1.2.1 → morphik-1.2.3}/morphik/tests/test_docs/sample3.txt +0 -0
- {morphik-1.2.1 → morphik-1.2.3}/morphik/tests/test_shared_helpers.py +0 -0
- {morphik-1.2.1 → morphik-1.2.3}/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.3
|
|
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,9 +99,18 @@ 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)
|
|
109
|
+
|
|
110
|
+
# List only the fields you need. The server reads and returns just those columns, so
|
|
111
|
+
# the full document text is never downloaded — fast for large corpora.
|
|
112
|
+
for doc in db.list_documents(fields=["metadata"]).documents:
|
|
113
|
+
print(doc.external_id, doc.metadata)
|
|
102
114
|
```
|
|
103
115
|
|
|
104
116
|
`Folder.full_path` is exposed on folder objects, and `Document.folder_path` mirrors server responses for tracing scope.
|
|
@@ -113,6 +125,9 @@ async def main():
|
|
|
113
125
|
# Initialize async client - connects to localhost:8000 by default
|
|
114
126
|
async with AsyncMorphik() as db:
|
|
115
127
|
|
|
128
|
+
# You can also use a direct HTTP(S) base URL for self-hosted deployments
|
|
129
|
+
# async with AsyncMorphik("http://morphik:8000") as db:
|
|
130
|
+
|
|
116
131
|
# Or with authentication URI (for production)
|
|
117
132
|
# async with AsyncMorphik("morphik://owner_id:token@api.morphik.ai") as db:
|
|
118
133
|
# 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,9 +86,18 @@ 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)
|
|
96
|
+
|
|
97
|
+
# List only the fields you need. The server reads and returns just those columns, so
|
|
98
|
+
# the full document text is never downloaded — fast for large corpora.
|
|
99
|
+
for doc in db.list_documents(fields=["metadata"]).documents:
|
|
100
|
+
print(doc.external_id, doc.metadata)
|
|
89
101
|
```
|
|
90
102
|
|
|
91
103
|
`Folder.full_path` is exposed on folder objects, and `Document.folder_path` mirrors server responses for tracing scope.
|
|
@@ -100,6 +112,9 @@ async def main():
|
|
|
100
112
|
# Initialize async client - connects to localhost:8000 by default
|
|
101
113
|
async with AsyncMorphik() as db:
|
|
102
114
|
|
|
115
|
+
# You can also use a direct HTTP(S) base URL for self-hosted deployments
|
|
116
|
+
# async with AsyncMorphik("http://morphik:8000") as db:
|
|
117
|
+
|
|
103
118
|
# Or with authentication URI (for production)
|
|
104
119
|
# async with AsyncMorphik("morphik://owner_id:token@api.morphik.ai") as db:
|
|
105
120
|
# 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})
|
|
@@ -409,6 +428,7 @@ class _MorphikClientLogic:
|
|
|
409
428
|
completed_only: bool,
|
|
410
429
|
sort_by: Optional[str],
|
|
411
430
|
sort_direction: str,
|
|
431
|
+
fields: Optional[List[str]] = None,
|
|
412
432
|
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
|
413
433
|
"""Prepare request for list_docs endpoint"""
|
|
414
434
|
params = {}
|
|
@@ -431,6 +451,15 @@ class _MorphikClientLogic:
|
|
|
431
451
|
"sort_by": sort_by,
|
|
432
452
|
"sort_direction": sort_direction,
|
|
433
453
|
}
|
|
454
|
+
if fields:
|
|
455
|
+
# Always include the fields required to reconstruct a Document client-side, so
|
|
456
|
+
# projected responses still parse into Document objects. When any metadata field
|
|
457
|
+
# is requested, also pull metadata_types so typed values (datetime/date/decimal)
|
|
458
|
+
# are reconstructed instead of returned as raw strings.
|
|
459
|
+
projected = ["external_id", "content_type", *fields]
|
|
460
|
+
if any(field.split(".", 1)[0] == "metadata" for field in fields):
|
|
461
|
+
projected.append("metadata_types")
|
|
462
|
+
data["fields"] = list(dict.fromkeys(projected))
|
|
434
463
|
return params, data
|
|
435
464
|
|
|
436
465
|
def _prepare_batch_get_documents_request(
|
|
@@ -277,6 +277,7 @@ class _ScopedOperationsMixin:
|
|
|
277
277
|
completed_only: bool,
|
|
278
278
|
sort_by: Optional[str],
|
|
279
279
|
sort_direction: str,
|
|
280
|
+
fields: Optional[List[str]] = None,
|
|
280
281
|
):
|
|
281
282
|
params, data = self._logic._prepare_list_documents_request(
|
|
282
283
|
skip,
|
|
@@ -291,6 +292,7 @@ class _ScopedOperationsMixin:
|
|
|
291
292
|
completed_only,
|
|
292
293
|
sort_by,
|
|
293
294
|
sort_direction,
|
|
295
|
+
fields,
|
|
294
296
|
)
|
|
295
297
|
|
|
296
298
|
return self._execute_scoped_operation(
|
|
@@ -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]]],
|
|
@@ -14,6 +14,9 @@ from ._scoped_ops import _ScopedOperationsMixin
|
|
|
14
14
|
from ._shared import (
|
|
15
15
|
build_create_app_payload,
|
|
16
16
|
build_document_by_filename_params,
|
|
17
|
+
build_folder_endpoint_identifier,
|
|
18
|
+
build_folder_move_payload,
|
|
19
|
+
build_folder_rename_path,
|
|
17
20
|
build_list_apps_params,
|
|
18
21
|
build_logs_params,
|
|
19
22
|
build_rename_app_params,
|
|
@@ -264,8 +267,15 @@ class _AsyncScopedClientOps:
|
|
|
264
267
|
completed_only: bool = False,
|
|
265
268
|
sort_by: Optional[str] = "updated_at",
|
|
266
269
|
sort_direction: str = "desc",
|
|
270
|
+
fields: Optional[List[str]] = None,
|
|
267
271
|
) -> ListDocsResponse:
|
|
268
|
-
"""List documents within this scope (async).
|
|
272
|
+
"""List documents within this scope (async).
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
fields: Optional list of fields to return for each document (e.g. ["metadata"]).
|
|
276
|
+
Only those fields are read and returned, so the full document text is never
|
|
277
|
+
downloaded. external_id and content_type are always included.
|
|
278
|
+
"""
|
|
269
279
|
effective_folder = self._merge_folders(additional_folders)
|
|
270
280
|
return await self._client._scoped_list_documents(
|
|
271
281
|
skip=skip,
|
|
@@ -280,6 +290,7 @@ class _AsyncScopedClientOps:
|
|
|
280
290
|
completed_only=completed_only,
|
|
281
291
|
sort_by=sort_by,
|
|
282
292
|
sort_direction=sort_direction,
|
|
293
|
+
fields=fields,
|
|
283
294
|
)
|
|
284
295
|
|
|
285
296
|
async def batch_get_documents(
|
|
@@ -416,6 +427,15 @@ class AsyncFolder(_AsyncScopedClientOps):
|
|
|
416
427
|
"""Returns the folder ID if available."""
|
|
417
428
|
return self._id
|
|
418
429
|
|
|
430
|
+
def _sync_from_folder(self, folder: "AsyncFolder") -> None:
|
|
431
|
+
self._id = folder.id or self._id
|
|
432
|
+
self._name = folder.name
|
|
433
|
+
self._full_path = folder.full_path
|
|
434
|
+
self._parent_id = folder.parent_id
|
|
435
|
+
self._depth = folder.depth
|
|
436
|
+
self._child_count = folder.child_count
|
|
437
|
+
self._description = folder.description
|
|
438
|
+
|
|
419
439
|
async def get_info(self) -> Dict[str, Any]:
|
|
420
440
|
"""
|
|
421
441
|
Get detailed information about this folder.
|
|
@@ -446,6 +466,24 @@ class AsyncFolder(_AsyncScopedClientOps):
|
|
|
446
466
|
self._description = info.description or self._description
|
|
447
467
|
return info
|
|
448
468
|
|
|
469
|
+
async def move(self, new_path: str) -> "AsyncFolder":
|
|
470
|
+
"""Move this folder to a new canonical path and refresh local metadata."""
|
|
471
|
+
identifier = self._id or self.full_path
|
|
472
|
+
if not identifier:
|
|
473
|
+
raise ValueError("Folder identifier is missing")
|
|
474
|
+
moved = await self._client.move_folder(identifier, new_path)
|
|
475
|
+
self._sync_from_folder(moved)
|
|
476
|
+
return moved
|
|
477
|
+
|
|
478
|
+
async def rename(self, new_name: str) -> "AsyncFolder":
|
|
479
|
+
"""Rename this folder (leaf segment only) and refresh local metadata."""
|
|
480
|
+
identifier = self._id or self.full_path
|
|
481
|
+
if not identifier:
|
|
482
|
+
raise ValueError("Folder identifier is missing")
|
|
483
|
+
renamed = await self._client.rename_folder(identifier, new_name)
|
|
484
|
+
self._sync_from_folder(renamed)
|
|
485
|
+
return renamed
|
|
486
|
+
|
|
449
487
|
async def get_summary(self) -> Summary:
|
|
450
488
|
"""Retrieve the latest summary for this folder."""
|
|
451
489
|
identifier = self._id or self.full_path
|
|
@@ -698,6 +736,50 @@ class AsyncMorphik(_ScopedOperationsMixin):
|
|
|
698
736
|
response = await self._request("DELETE", f"folders/{folder_id_or_name}")
|
|
699
737
|
return response
|
|
700
738
|
|
|
739
|
+
async def move_folder(self, folder_id_or_name: str, new_path: str) -> AsyncFolder:
|
|
740
|
+
"""
|
|
741
|
+
Move and/or rename a folder by setting its new canonical path.
|
|
742
|
+
|
|
743
|
+
Args:
|
|
744
|
+
folder_id_or_name: Folder ID, name, or full path
|
|
745
|
+
new_path: Target full path (e.g. "/projects/archive/specs")
|
|
746
|
+
|
|
747
|
+
Returns:
|
|
748
|
+
AsyncFolder: Updated folder metadata
|
|
749
|
+
"""
|
|
750
|
+
folder_param = build_folder_endpoint_identifier(folder_id_or_name)
|
|
751
|
+
payload = build_folder_move_payload(new_path=new_path)
|
|
752
|
+
response = await self._request("POST", f"folders/{folder_param}/move", data=payload)
|
|
753
|
+
info = FolderInfo(**response)
|
|
754
|
+
return AsyncFolder(
|
|
755
|
+
self,
|
|
756
|
+
info.name,
|
|
757
|
+
folder_id=info.id,
|
|
758
|
+
full_path=info.full_path,
|
|
759
|
+
parent_id=info.parent_id,
|
|
760
|
+
depth=info.depth,
|
|
761
|
+
child_count=info.child_count,
|
|
762
|
+
description=info.description,
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
async def rename_folder(self, folder_id_or_name: str, new_name: str) -> AsyncFolder:
|
|
766
|
+
"""
|
|
767
|
+
Rename a folder by changing only its leaf segment.
|
|
768
|
+
|
|
769
|
+
Args:
|
|
770
|
+
folder_id_or_name: Folder ID, name, or full path
|
|
771
|
+
new_name: New folder name (single segment, no '/')
|
|
772
|
+
|
|
773
|
+
Returns:
|
|
774
|
+
AsyncFolder: Updated folder metadata
|
|
775
|
+
"""
|
|
776
|
+
folder_param = build_folder_endpoint_identifier(folder_id_or_name)
|
|
777
|
+
current_info = FolderInfo(**(await self._request("GET", f"folders/{folder_param}")))
|
|
778
|
+
current_path = current_info.full_path or current_info.name
|
|
779
|
+
new_path = build_folder_rename_path(current_path=current_path, new_name=new_name)
|
|
780
|
+
move_identifier = current_info.id or folder_param
|
|
781
|
+
return await self.move_folder(move_identifier, new_path)
|
|
782
|
+
|
|
701
783
|
async def get_folder_summary(self, folder_id_or_path: str) -> Summary:
|
|
702
784
|
"""Get the persisted summary for a folder."""
|
|
703
785
|
folder_param = folder_id_or_path.lstrip("/") if folder_id_or_path else folder_id_or_path
|
|
@@ -1157,6 +1239,7 @@ class AsyncMorphik(_ScopedOperationsMixin):
|
|
|
1157
1239
|
completed_only: bool = False,
|
|
1158
1240
|
sort_by: Optional[str] = "updated_at",
|
|
1159
1241
|
sort_direction: str = "desc",
|
|
1242
|
+
fields: Optional[List[str]] = None,
|
|
1160
1243
|
) -> ListDocsResponse:
|
|
1161
1244
|
"""
|
|
1162
1245
|
List accessible documents.
|
|
@@ -1173,6 +1256,9 @@ class AsyncMorphik(_ScopedOperationsMixin):
|
|
|
1173
1256
|
completed_only: Only return completed documents
|
|
1174
1257
|
sort_by: Field to sort by (created_at, updated_at, filename, external_id)
|
|
1175
1258
|
sort_direction: Sort direction (asc, desc)
|
|
1259
|
+
fields: Optional list of fields to return for each document (e.g. ["metadata"]).
|
|
1260
|
+
Only those fields are read and returned, so the full document text is never
|
|
1261
|
+
downloaded. external_id and content_type are always included.
|
|
1176
1262
|
Returns:
|
|
1177
1263
|
ListDocsResponse: Response with documents and metadata
|
|
1178
1264
|
|
|
@@ -1190,6 +1276,7 @@ class AsyncMorphik(_ScopedOperationsMixin):
|
|
|
1190
1276
|
completed_only=completed_only,
|
|
1191
1277
|
sort_by=sort_by,
|
|
1192
1278
|
sort_direction=sort_direction,
|
|
1279
|
+
fields=fields,
|
|
1193
1280
|
)
|
|
1194
1281
|
|
|
1195
1282
|
async def get_document(self, document_id: str) -> Document:
|
|
@@ -14,6 +14,9 @@ from ._scoped_ops import _ScopedOperationsMixin
|
|
|
14
14
|
from ._shared import (
|
|
15
15
|
build_create_app_payload,
|
|
16
16
|
build_document_by_filename_params,
|
|
17
|
+
build_folder_endpoint_identifier,
|
|
18
|
+
build_folder_move_payload,
|
|
19
|
+
build_folder_rename_path,
|
|
17
20
|
build_list_apps_params,
|
|
18
21
|
build_logs_params,
|
|
19
22
|
build_rename_app_params,
|
|
@@ -280,9 +283,16 @@ class _ScopedClientOps:
|
|
|
280
283
|
completed_only: bool = False,
|
|
281
284
|
sort_by: Optional[str] = "updated_at",
|
|
282
285
|
sort_direction: str = "desc",
|
|
286
|
+
fields: Optional[List[str]] = None,
|
|
283
287
|
) -> ListDocsResponse:
|
|
284
288
|
"""
|
|
285
289
|
List documents within this scope.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
fields: Optional list of fields to return for each document (e.g.
|
|
293
|
+
["metadata"]). Only those fields are read and returned, so the full
|
|
294
|
+
document text is never downloaded. external_id and content_type are
|
|
295
|
+
always included.
|
|
286
296
|
"""
|
|
287
297
|
effective_folder = self._merge_folders(additional_folders)
|
|
288
298
|
return self._client._scoped_list_documents(
|
|
@@ -298,6 +308,7 @@ class _ScopedClientOps:
|
|
|
298
308
|
completed_only=completed_only,
|
|
299
309
|
sort_by=sort_by,
|
|
300
310
|
sort_direction=sort_direction,
|
|
311
|
+
fields=fields,
|
|
301
312
|
)
|
|
302
313
|
|
|
303
314
|
def batch_get_documents(
|
|
@@ -442,6 +453,15 @@ class Folder(_ScopedClientOps):
|
|
|
442
453
|
"""Returns the folder ID if available."""
|
|
443
454
|
return self._id
|
|
444
455
|
|
|
456
|
+
def _sync_from_folder(self, folder: "Folder") -> None:
|
|
457
|
+
self._id = folder.id or self._id
|
|
458
|
+
self._name = folder.name
|
|
459
|
+
self._full_path = folder.full_path
|
|
460
|
+
self._parent_id = folder.parent_id
|
|
461
|
+
self._depth = folder.depth
|
|
462
|
+
self._child_count = folder.child_count
|
|
463
|
+
self._description = folder.description
|
|
464
|
+
|
|
445
465
|
def get_info(self) -> Dict[str, Any]:
|
|
446
466
|
"""
|
|
447
467
|
Get detailed information about this folder.
|
|
@@ -473,6 +493,24 @@ class Folder(_ScopedClientOps):
|
|
|
473
493
|
self._description = info.description or self._description
|
|
474
494
|
return info
|
|
475
495
|
|
|
496
|
+
def move(self, new_path: str) -> "Folder":
|
|
497
|
+
"""Move this folder to a new canonical path and refresh local metadata."""
|
|
498
|
+
identifier = self._id or self.full_path
|
|
499
|
+
if not identifier:
|
|
500
|
+
raise ValueError("Folder identifier is missing")
|
|
501
|
+
moved = self._client.move_folder(identifier, new_path)
|
|
502
|
+
self._sync_from_folder(moved)
|
|
503
|
+
return moved
|
|
504
|
+
|
|
505
|
+
def rename(self, new_name: str) -> "Folder":
|
|
506
|
+
"""Rename this folder (leaf segment only) and refresh local metadata."""
|
|
507
|
+
identifier = self._id or self.full_path
|
|
508
|
+
if not identifier:
|
|
509
|
+
raise ValueError("Folder identifier is missing")
|
|
510
|
+
renamed = self._client.rename_folder(identifier, new_name)
|
|
511
|
+
self._sync_from_folder(renamed)
|
|
512
|
+
return renamed
|
|
513
|
+
|
|
476
514
|
def get_summary(self) -> Summary:
|
|
477
515
|
"""Retrieve the latest summary for this folder."""
|
|
478
516
|
identifier = self._id or self.full_path
|
|
@@ -729,6 +767,50 @@ class Morphik(_ScopedOperationsMixin):
|
|
|
729
767
|
response = self._request("DELETE", f"folders/{folder_id_or_name}")
|
|
730
768
|
return response
|
|
731
769
|
|
|
770
|
+
def move_folder(self, folder_id_or_name: str, new_path: str) -> Folder:
|
|
771
|
+
"""
|
|
772
|
+
Move and/or rename a folder by setting its new canonical path.
|
|
773
|
+
|
|
774
|
+
Args:
|
|
775
|
+
folder_id_or_name: Folder ID, name, or full path
|
|
776
|
+
new_path: Target full path (e.g. "/projects/archive/specs")
|
|
777
|
+
|
|
778
|
+
Returns:
|
|
779
|
+
Folder: Updated folder metadata
|
|
780
|
+
"""
|
|
781
|
+
folder_param = build_folder_endpoint_identifier(folder_id_or_name)
|
|
782
|
+
payload = build_folder_move_payload(new_path=new_path)
|
|
783
|
+
response = self._request("POST", f"folders/{folder_param}/move", data=payload)
|
|
784
|
+
info = FolderInfo(**response)
|
|
785
|
+
return Folder(
|
|
786
|
+
self,
|
|
787
|
+
info.name,
|
|
788
|
+
folder_id=info.id,
|
|
789
|
+
full_path=info.full_path,
|
|
790
|
+
parent_id=info.parent_id,
|
|
791
|
+
depth=info.depth,
|
|
792
|
+
child_count=info.child_count,
|
|
793
|
+
description=info.description,
|
|
794
|
+
)
|
|
795
|
+
|
|
796
|
+
def rename_folder(self, folder_id_or_name: str, new_name: str) -> Folder:
|
|
797
|
+
"""
|
|
798
|
+
Rename a folder by changing only its leaf segment.
|
|
799
|
+
|
|
800
|
+
Args:
|
|
801
|
+
folder_id_or_name: Folder ID, name, or full path
|
|
802
|
+
new_name: New folder name (single segment, no '/')
|
|
803
|
+
|
|
804
|
+
Returns:
|
|
805
|
+
Folder: Updated folder metadata
|
|
806
|
+
"""
|
|
807
|
+
folder_param = build_folder_endpoint_identifier(folder_id_or_name)
|
|
808
|
+
current_info = FolderInfo(**self._request("GET", f"folders/{folder_param}"))
|
|
809
|
+
current_path = current_info.full_path or current_info.name
|
|
810
|
+
new_path = build_folder_rename_path(current_path=current_path, new_name=new_name)
|
|
811
|
+
move_identifier = current_info.id or folder_param
|
|
812
|
+
return self.move_folder(move_identifier, new_path)
|
|
813
|
+
|
|
732
814
|
def get_folder_summary(self, folder_id_or_path: str) -> Summary:
|
|
733
815
|
"""Get the persisted summary for a folder."""
|
|
734
816
|
folder_param = folder_id_or_path.lstrip("/") if folder_id_or_path else folder_id_or_path
|
|
@@ -1196,6 +1278,7 @@ class Morphik(_ScopedOperationsMixin):
|
|
|
1196
1278
|
completed_only: bool = False,
|
|
1197
1279
|
sort_by: Optional[str] = "updated_at",
|
|
1198
1280
|
sort_direction: str = "desc",
|
|
1281
|
+
fields: Optional[List[str]] = None,
|
|
1199
1282
|
) -> ListDocsResponse:
|
|
1200
1283
|
"""
|
|
1201
1284
|
List accessible documents.
|
|
@@ -1212,6 +1295,9 @@ class Morphik(_ScopedOperationsMixin):
|
|
|
1212
1295
|
completed_only: Only return completed documents
|
|
1213
1296
|
sort_by: Field to sort by (created_at, updated_at, filename, external_id)
|
|
1214
1297
|
sort_direction: Sort direction (asc, desc)
|
|
1298
|
+
fields: Optional list of fields to return for each document (e.g. ["metadata"]).
|
|
1299
|
+
Only those fields are read and returned, so the full document text is never
|
|
1300
|
+
downloaded. external_id and content_type are always included.
|
|
1215
1301
|
Returns:
|
|
1216
1302
|
ListDocsResponse: Response with documents and metadata
|
|
1217
1303
|
|
|
@@ -1229,6 +1315,7 @@ class Morphik(_ScopedOperationsMixin):
|
|
|
1229
1315
|
completed_only=completed_only,
|
|
1230
1316
|
sort_by=sort_by,
|
|
1231
1317
|
sort_direction=sort_direction,
|
|
1318
|
+
fields=fields,
|
|
1232
1319
|
)
|
|
1233
1320
|
|
|
1234
1321
|
def get_document(self, document_id: str) -> Document:
|
|
@@ -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
|
|
@@ -116,6 +117,39 @@ def test_sync_list_documents_payloads_across_scopes():
|
|
|
116
117
|
client.close()
|
|
117
118
|
|
|
118
119
|
|
|
120
|
+
def test_sync_list_documents_fields_projection():
|
|
121
|
+
client, calls = _make_sync_client()
|
|
122
|
+
try:
|
|
123
|
+
# external_id + content_type are always added so the response parses into a Document;
|
|
124
|
+
# metadata_types is added so typed metadata values are reconstructed, not left as strings.
|
|
125
|
+
client.list_documents(fields=["metadata"])
|
|
126
|
+
assert calls.pop()["data"]["fields"] == ["external_id", "content_type", "metadata", "metadata_types"]
|
|
127
|
+
|
|
128
|
+
# Already-included required fields are not duplicated; order is preserved.
|
|
129
|
+
client.list_documents(fields=["external_id", "filename", "metadata"])
|
|
130
|
+
assert calls.pop()["data"]["fields"] == [
|
|
131
|
+
"external_id",
|
|
132
|
+
"content_type",
|
|
133
|
+
"filename",
|
|
134
|
+
"metadata",
|
|
135
|
+
"metadata_types",
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
# Nested metadata paths also trigger metadata_types.
|
|
139
|
+
client.list_documents(fields=["metadata.client"])
|
|
140
|
+
assert calls.pop()["data"]["fields"] == ["external_id", "content_type", "metadata.client", "metadata_types"]
|
|
141
|
+
|
|
142
|
+
# Non-metadata projection does not pull metadata_types.
|
|
143
|
+
client.list_documents(fields=["filename"])
|
|
144
|
+
assert calls.pop()["data"]["fields"] == ["external_id", "content_type", "filename"]
|
|
145
|
+
|
|
146
|
+
# No fields -> no projection requested (full documents).
|
|
147
|
+
client.list_documents()
|
|
148
|
+
assert "fields" not in calls.pop()["data"]
|
|
149
|
+
finally:
|
|
150
|
+
client.close()
|
|
151
|
+
|
|
152
|
+
|
|
119
153
|
def test_async_client_http2_toggle(monkeypatch):
|
|
120
154
|
captured = []
|
|
121
155
|
|
|
@@ -160,6 +194,56 @@ def test_sync_client_http2_toggle(monkeypatch):
|
|
|
160
194
|
assert captured[-1] is False
|
|
161
195
|
|
|
162
196
|
|
|
197
|
+
def test_sync_client_accepts_plain_http_uri_without_auth():
|
|
198
|
+
client = Morphik("http://0.0.0.0:8000")
|
|
199
|
+
try:
|
|
200
|
+
assert client._logic._base_url == "http://0.0.0.0:8000"
|
|
201
|
+
assert client._logic._auth_token is None
|
|
202
|
+
assert client._logic._is_local is True
|
|
203
|
+
finally:
|
|
204
|
+
client.close()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_sync_client_preserves_http_path_prefix():
|
|
208
|
+
client = Morphik("https://api.example.com/morphik/v1/")
|
|
209
|
+
try:
|
|
210
|
+
assert client._logic._base_url == "https://api.example.com/morphik/v1"
|
|
211
|
+
assert client._logic._get_url("ping") == "https://api.example.com/morphik/v1/ping"
|
|
212
|
+
finally:
|
|
213
|
+
client.close()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_sync_client_parses_morphik_uri_with_token():
|
|
217
|
+
token = jwt.encode({"sub": "test-user"}, "test-secret", algorithm="HS256")
|
|
218
|
+
client = Morphik(f"morphik://owner:{token}@api.morphik.ai")
|
|
219
|
+
try:
|
|
220
|
+
assert client._logic._base_url == "https://api.morphik.ai"
|
|
221
|
+
assert client._logic._auth_token == token
|
|
222
|
+
finally:
|
|
223
|
+
client.close()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@pytest.mark.asyncio
|
|
227
|
+
async def test_async_client_accepts_plain_http_uri_without_auth():
|
|
228
|
+
client = AsyncMorphik("http://0.0.0.0:8000")
|
|
229
|
+
try:
|
|
230
|
+
assert client._logic._base_url == "http://0.0.0.0:8000"
|
|
231
|
+
assert client._logic._auth_token is None
|
|
232
|
+
assert client._logic._is_local is True
|
|
233
|
+
finally:
|
|
234
|
+
await client.close()
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@pytest.mark.asyncio
|
|
238
|
+
async def test_async_client_preserves_http_path_prefix():
|
|
239
|
+
client = AsyncMorphik("https://api.example.com/morphik/v1/")
|
|
240
|
+
try:
|
|
241
|
+
assert client._logic._base_url == "https://api.example.com/morphik/v1"
|
|
242
|
+
assert client._logic._get_url("ping") == "https://api.example.com/morphik/v1/ping"
|
|
243
|
+
finally:
|
|
244
|
+
await client.close()
|
|
245
|
+
|
|
246
|
+
|
|
163
247
|
@pytest.mark.asyncio
|
|
164
248
|
async def test_async_http2_fallback_on_remote_protocol_error(monkeypatch):
|
|
165
249
|
created_http2 = []
|
|
@@ -492,6 +576,208 @@ def test_folder_hierarchy_properties():
|
|
|
492
576
|
client.close()
|
|
493
577
|
|
|
494
578
|
|
|
579
|
+
def test_sync_move_folder_calls_move_endpoint():
|
|
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/folder-123/move":
|
|
587
|
+
return {
|
|
588
|
+
"id": "folder-123",
|
|
589
|
+
"name": "archived",
|
|
590
|
+
"full_path": "/projects/archived",
|
|
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("folder-123", "/projects/archived")
|
|
601
|
+
move_call = calls.pop()
|
|
602
|
+
assert move_call["method"] == "POST"
|
|
603
|
+
assert move_call["endpoint"] == "folders/folder-123/move"
|
|
604
|
+
assert move_call["data"] == {"new_path": "/projects/archived"}
|
|
605
|
+
assert moved.id == "folder-123"
|
|
606
|
+
assert moved.full_path == "/projects/archived"
|
|
607
|
+
assert moved.name == "archived"
|
|
608
|
+
finally:
|
|
609
|
+
client.close()
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def test_sync_move_folder_encodes_special_chars_in_identifier():
|
|
613
|
+
client, calls = _make_sync_client()
|
|
614
|
+
try:
|
|
615
|
+
original_request = client._request
|
|
616
|
+
|
|
617
|
+
def mock_request(method, endpoint, data=None, files=None, params=None):
|
|
618
|
+
calls.append({"method": method, "endpoint": endpoint, "data": data, "params": params})
|
|
619
|
+
if endpoint == "folders/team/a%231/move":
|
|
620
|
+
return {
|
|
621
|
+
"id": "folder-123",
|
|
622
|
+
"name": "a1",
|
|
623
|
+
"full_path": "/team/a1",
|
|
624
|
+
"parent_id": "parent-1",
|
|
625
|
+
"depth": 2,
|
|
626
|
+
"child_count": 0,
|
|
627
|
+
"description": "moved folder",
|
|
628
|
+
}
|
|
629
|
+
return original_request(method, endpoint, data, files, params)
|
|
630
|
+
|
|
631
|
+
client._request = mock_request
|
|
632
|
+
|
|
633
|
+
moved = client.move_folder("/team/a#1", "/team/a1")
|
|
634
|
+
move_call = calls.pop()
|
|
635
|
+
assert move_call["method"] == "POST"
|
|
636
|
+
assert move_call["endpoint"] == "folders/team/a%231/move"
|
|
637
|
+
assert move_call["data"] == {"new_path": "/team/a1"}
|
|
638
|
+
assert moved.full_path == "/team/a1"
|
|
639
|
+
finally:
|
|
640
|
+
client.close()
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def test_sync_rename_folder_derives_target_path_from_existing_folder():
|
|
644
|
+
client, calls = _make_sync_client()
|
|
645
|
+
try:
|
|
646
|
+
original_request = client._request
|
|
647
|
+
|
|
648
|
+
def mock_request(method, endpoint, data=None, files=None, params=None):
|
|
649
|
+
calls.append({"method": method, "endpoint": endpoint, "data": data, "params": params})
|
|
650
|
+
if method == "GET" and endpoint == "folders/folder-123":
|
|
651
|
+
return {
|
|
652
|
+
"id": "folder-123",
|
|
653
|
+
"name": "old-name",
|
|
654
|
+
"full_path": "/team/old-name",
|
|
655
|
+
"parent_id": "parent-1",
|
|
656
|
+
"depth": 2,
|
|
657
|
+
"child_count": 0,
|
|
658
|
+
"description": "original",
|
|
659
|
+
}
|
|
660
|
+
if method == "POST" and endpoint == "folders/folder-123/move":
|
|
661
|
+
return {
|
|
662
|
+
"id": "folder-123",
|
|
663
|
+
"name": "new-name",
|
|
664
|
+
"full_path": "/team/new-name",
|
|
665
|
+
"parent_id": "parent-1",
|
|
666
|
+
"depth": 2,
|
|
667
|
+
"child_count": 0,
|
|
668
|
+
"description": "renamed",
|
|
669
|
+
}
|
|
670
|
+
return original_request(method, endpoint, data, files, params)
|
|
671
|
+
|
|
672
|
+
client._request = mock_request
|
|
673
|
+
|
|
674
|
+
renamed = client.rename_folder("folder-123", "new-name")
|
|
675
|
+
|
|
676
|
+
get_call = calls[-2]
|
|
677
|
+
move_call = calls[-1]
|
|
678
|
+
assert get_call["method"] == "GET"
|
|
679
|
+
assert get_call["endpoint"] == "folders/folder-123"
|
|
680
|
+
assert move_call["method"] == "POST"
|
|
681
|
+
assert move_call["endpoint"] == "folders/folder-123/move"
|
|
682
|
+
assert move_call["data"] == {"new_path": "/team/new-name"}
|
|
683
|
+
assert renamed.name == "new-name"
|
|
684
|
+
assert renamed.full_path == "/team/new-name"
|
|
685
|
+
finally:
|
|
686
|
+
client.close()
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def test_sync_rename_folder_encodes_identifier_for_get_and_move():
|
|
690
|
+
client, calls = _make_sync_client()
|
|
691
|
+
try:
|
|
692
|
+
original_request = client._request
|
|
693
|
+
|
|
694
|
+
def mock_request(method, endpoint, data=None, files=None, params=None):
|
|
695
|
+
calls.append({"method": method, "endpoint": endpoint, "data": data, "params": params})
|
|
696
|
+
if method == "GET" and endpoint == "folders/team/a%231":
|
|
697
|
+
return {
|
|
698
|
+
"id": "folder#123",
|
|
699
|
+
"name": "a#1",
|
|
700
|
+
"full_path": "/team/a#1",
|
|
701
|
+
"parent_id": "parent-1",
|
|
702
|
+
"depth": 2,
|
|
703
|
+
"child_count": 0,
|
|
704
|
+
"description": "original",
|
|
705
|
+
}
|
|
706
|
+
if method == "POST" and endpoint == "folders/folder%23123/move":
|
|
707
|
+
return {
|
|
708
|
+
"id": "folder#123",
|
|
709
|
+
"name": "a-new",
|
|
710
|
+
"full_path": "/team/a-new",
|
|
711
|
+
"parent_id": "parent-1",
|
|
712
|
+
"depth": 2,
|
|
713
|
+
"child_count": 0,
|
|
714
|
+
"description": "renamed",
|
|
715
|
+
}
|
|
716
|
+
return original_request(method, endpoint, data, files, params)
|
|
717
|
+
|
|
718
|
+
client._request = mock_request
|
|
719
|
+
|
|
720
|
+
renamed = client.rename_folder("/team/a#1", "a-new")
|
|
721
|
+
|
|
722
|
+
get_call = calls[-2]
|
|
723
|
+
move_call = calls[-1]
|
|
724
|
+
assert get_call["method"] == "GET"
|
|
725
|
+
assert get_call["endpoint"] == "folders/team/a%231"
|
|
726
|
+
assert move_call["method"] == "POST"
|
|
727
|
+
assert move_call["endpoint"] == "folders/folder%23123/move"
|
|
728
|
+
assert move_call["data"] == {"new_path": "/team/a-new"}
|
|
729
|
+
assert renamed.full_path == "/team/a-new"
|
|
730
|
+
finally:
|
|
731
|
+
client.close()
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def test_sync_folder_move_and_rename_refresh_local_metadata():
|
|
735
|
+
client, _ = _make_sync_client()
|
|
736
|
+
try:
|
|
737
|
+
folder = Folder(client, "docs", folder_id="folder-123", full_path="/team/docs")
|
|
738
|
+
|
|
739
|
+
def mock_move(folder_id_or_name, new_path):
|
|
740
|
+
if new_path == "/archive/docs":
|
|
741
|
+
return Folder(
|
|
742
|
+
client,
|
|
743
|
+
"docs",
|
|
744
|
+
folder_id="folder-123",
|
|
745
|
+
full_path="/archive/docs",
|
|
746
|
+
parent_id="parent-arch",
|
|
747
|
+
depth=2,
|
|
748
|
+
child_count=0,
|
|
749
|
+
description="moved",
|
|
750
|
+
)
|
|
751
|
+
return Folder(
|
|
752
|
+
client,
|
|
753
|
+
"docs-v2",
|
|
754
|
+
folder_id="folder-123",
|
|
755
|
+
full_path="/archive/docs-v2",
|
|
756
|
+
parent_id="parent-arch",
|
|
757
|
+
depth=2,
|
|
758
|
+
child_count=0,
|
|
759
|
+
description="renamed",
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
client.move_folder = mock_move # type: ignore[method-assign]
|
|
763
|
+
client.rename_folder = lambda folder_id_or_name, new_name: mock_move( # type: ignore[method-assign]
|
|
764
|
+
folder_id_or_name, "/archive/docs-v2"
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
moved = folder.move("/archive/docs")
|
|
768
|
+
assert moved.full_path == "/archive/docs"
|
|
769
|
+
assert folder.full_path == "/archive/docs"
|
|
770
|
+
assert folder.parent_id == "parent-arch"
|
|
771
|
+
|
|
772
|
+
renamed = folder.rename("docs-v2")
|
|
773
|
+
assert renamed.name == "docs-v2"
|
|
774
|
+
assert renamed.full_path == "/archive/docs-v2"
|
|
775
|
+
assert folder.name == "docs-v2"
|
|
776
|
+
assert folder.full_path == "/archive/docs-v2"
|
|
777
|
+
finally:
|
|
778
|
+
client.close()
|
|
779
|
+
|
|
780
|
+
|
|
495
781
|
def test_folder_uses_full_path_for_operations():
|
|
496
782
|
"""Test that Folder operations use full_path instead of name."""
|
|
497
783
|
client, calls = _make_sync_client()
|
|
@@ -680,6 +966,215 @@ async def test_async_folder_hierarchy_properties():
|
|
|
680
966
|
await client.close()
|
|
681
967
|
|
|
682
968
|
|
|
969
|
+
@pytest.mark.asyncio
|
|
970
|
+
async def test_async_move_folder_calls_move_endpoint():
|
|
971
|
+
client, calls = await _make_async_client()
|
|
972
|
+
try:
|
|
973
|
+
original_request = client._request
|
|
974
|
+
|
|
975
|
+
async def mock_request(method, endpoint, data=None, files=None, params=None):
|
|
976
|
+
calls.append({"method": method, "endpoint": endpoint, "data": data, "params": params})
|
|
977
|
+
if endpoint == "folders/folder-123/move":
|
|
978
|
+
return {
|
|
979
|
+
"id": "folder-123",
|
|
980
|
+
"name": "archived",
|
|
981
|
+
"full_path": "/projects/archived",
|
|
982
|
+
"parent_id": "parent-1",
|
|
983
|
+
"depth": 2,
|
|
984
|
+
"child_count": 0,
|
|
985
|
+
"description": "moved folder",
|
|
986
|
+
}
|
|
987
|
+
return await original_request(method, endpoint, data, files, params)
|
|
988
|
+
|
|
989
|
+
client._request = mock_request
|
|
990
|
+
|
|
991
|
+
moved = await client.move_folder("folder-123", "/projects/archived")
|
|
992
|
+
move_call = calls.pop()
|
|
993
|
+
assert move_call["method"] == "POST"
|
|
994
|
+
assert move_call["endpoint"] == "folders/folder-123/move"
|
|
995
|
+
assert move_call["data"] == {"new_path": "/projects/archived"}
|
|
996
|
+
assert moved.id == "folder-123"
|
|
997
|
+
assert moved.full_path == "/projects/archived"
|
|
998
|
+
assert moved.name == "archived"
|
|
999
|
+
finally:
|
|
1000
|
+
await client.close()
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
@pytest.mark.asyncio
|
|
1004
|
+
async def test_async_move_folder_encodes_special_chars_in_identifier():
|
|
1005
|
+
client, calls = await _make_async_client()
|
|
1006
|
+
try:
|
|
1007
|
+
original_request = client._request
|
|
1008
|
+
|
|
1009
|
+
async def mock_request(method, endpoint, data=None, files=None, params=None):
|
|
1010
|
+
calls.append({"method": method, "endpoint": endpoint, "data": data, "params": params})
|
|
1011
|
+
if endpoint == "folders/team/a%231/move":
|
|
1012
|
+
return {
|
|
1013
|
+
"id": "folder-123",
|
|
1014
|
+
"name": "a1",
|
|
1015
|
+
"full_path": "/team/a1",
|
|
1016
|
+
"parent_id": "parent-1",
|
|
1017
|
+
"depth": 2,
|
|
1018
|
+
"child_count": 0,
|
|
1019
|
+
"description": "moved folder",
|
|
1020
|
+
}
|
|
1021
|
+
return await original_request(method, endpoint, data, files, params)
|
|
1022
|
+
|
|
1023
|
+
client._request = mock_request
|
|
1024
|
+
|
|
1025
|
+
moved = await client.move_folder("/team/a#1", "/team/a1")
|
|
1026
|
+
move_call = calls.pop()
|
|
1027
|
+
assert move_call["method"] == "POST"
|
|
1028
|
+
assert move_call["endpoint"] == "folders/team/a%231/move"
|
|
1029
|
+
assert move_call["data"] == {"new_path": "/team/a1"}
|
|
1030
|
+
assert moved.full_path == "/team/a1"
|
|
1031
|
+
finally:
|
|
1032
|
+
await client.close()
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
@pytest.mark.asyncio
|
|
1036
|
+
async def test_async_rename_folder_derives_target_path_from_existing_folder():
|
|
1037
|
+
client, calls = await _make_async_client()
|
|
1038
|
+
try:
|
|
1039
|
+
original_request = client._request
|
|
1040
|
+
|
|
1041
|
+
async def mock_request(method, endpoint, data=None, files=None, params=None):
|
|
1042
|
+
calls.append({"method": method, "endpoint": endpoint, "data": data, "params": params})
|
|
1043
|
+
if method == "GET" and endpoint == "folders/folder-123":
|
|
1044
|
+
return {
|
|
1045
|
+
"id": "folder-123",
|
|
1046
|
+
"name": "old-name",
|
|
1047
|
+
"full_path": "/team/old-name",
|
|
1048
|
+
"parent_id": "parent-1",
|
|
1049
|
+
"depth": 2,
|
|
1050
|
+
"child_count": 0,
|
|
1051
|
+
"description": "original",
|
|
1052
|
+
}
|
|
1053
|
+
if method == "POST" and endpoint == "folders/folder-123/move":
|
|
1054
|
+
return {
|
|
1055
|
+
"id": "folder-123",
|
|
1056
|
+
"name": "new-name",
|
|
1057
|
+
"full_path": "/team/new-name",
|
|
1058
|
+
"parent_id": "parent-1",
|
|
1059
|
+
"depth": 2,
|
|
1060
|
+
"child_count": 0,
|
|
1061
|
+
"description": "renamed",
|
|
1062
|
+
}
|
|
1063
|
+
return await original_request(method, endpoint, data, files, params)
|
|
1064
|
+
|
|
1065
|
+
client._request = mock_request
|
|
1066
|
+
|
|
1067
|
+
renamed = await client.rename_folder("folder-123", "new-name")
|
|
1068
|
+
get_call = calls[-2]
|
|
1069
|
+
move_call = calls[-1]
|
|
1070
|
+
assert get_call["method"] == "GET"
|
|
1071
|
+
assert get_call["endpoint"] == "folders/folder-123"
|
|
1072
|
+
assert move_call["method"] == "POST"
|
|
1073
|
+
assert move_call["endpoint"] == "folders/folder-123/move"
|
|
1074
|
+
assert move_call["data"] == {"new_path": "/team/new-name"}
|
|
1075
|
+
assert renamed.name == "new-name"
|
|
1076
|
+
assert renamed.full_path == "/team/new-name"
|
|
1077
|
+
finally:
|
|
1078
|
+
await client.close()
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
@pytest.mark.asyncio
|
|
1082
|
+
async def test_async_rename_folder_encodes_identifier_for_get_and_move():
|
|
1083
|
+
client, calls = await _make_async_client()
|
|
1084
|
+
try:
|
|
1085
|
+
original_request = client._request
|
|
1086
|
+
|
|
1087
|
+
async def mock_request(method, endpoint, data=None, files=None, params=None):
|
|
1088
|
+
calls.append({"method": method, "endpoint": endpoint, "data": data, "params": params})
|
|
1089
|
+
if method == "GET" and endpoint == "folders/team/a%231":
|
|
1090
|
+
return {
|
|
1091
|
+
"id": "folder#123",
|
|
1092
|
+
"name": "a#1",
|
|
1093
|
+
"full_path": "/team/a#1",
|
|
1094
|
+
"parent_id": "parent-1",
|
|
1095
|
+
"depth": 2,
|
|
1096
|
+
"child_count": 0,
|
|
1097
|
+
"description": "original",
|
|
1098
|
+
}
|
|
1099
|
+
if method == "POST" and endpoint == "folders/folder%23123/move":
|
|
1100
|
+
return {
|
|
1101
|
+
"id": "folder#123",
|
|
1102
|
+
"name": "a-new",
|
|
1103
|
+
"full_path": "/team/a-new",
|
|
1104
|
+
"parent_id": "parent-1",
|
|
1105
|
+
"depth": 2,
|
|
1106
|
+
"child_count": 0,
|
|
1107
|
+
"description": "renamed",
|
|
1108
|
+
}
|
|
1109
|
+
return await original_request(method, endpoint, data, files, params)
|
|
1110
|
+
|
|
1111
|
+
client._request = mock_request
|
|
1112
|
+
|
|
1113
|
+
renamed = await client.rename_folder("/team/a#1", "a-new")
|
|
1114
|
+
get_call = calls[-2]
|
|
1115
|
+
move_call = calls[-1]
|
|
1116
|
+
assert get_call["method"] == "GET"
|
|
1117
|
+
assert get_call["endpoint"] == "folders/team/a%231"
|
|
1118
|
+
assert move_call["method"] == "POST"
|
|
1119
|
+
assert move_call["endpoint"] == "folders/folder%23123/move"
|
|
1120
|
+
assert move_call["data"] == {"new_path": "/team/a-new"}
|
|
1121
|
+
assert renamed.full_path == "/team/a-new"
|
|
1122
|
+
finally:
|
|
1123
|
+
await client.close()
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
@pytest.mark.asyncio
|
|
1127
|
+
async def test_async_folder_move_and_rename_refresh_local_metadata():
|
|
1128
|
+
from morphik.async_ import AsyncFolder
|
|
1129
|
+
|
|
1130
|
+
client, _ = await _make_async_client()
|
|
1131
|
+
try:
|
|
1132
|
+
folder = AsyncFolder(client, "docs", folder_id="folder-123", full_path="/team/docs")
|
|
1133
|
+
|
|
1134
|
+
async def mock_move(folder_id_or_name, new_path):
|
|
1135
|
+
if new_path == "/archive/docs":
|
|
1136
|
+
return AsyncFolder(
|
|
1137
|
+
client,
|
|
1138
|
+
"docs",
|
|
1139
|
+
folder_id="folder-123",
|
|
1140
|
+
full_path="/archive/docs",
|
|
1141
|
+
parent_id="parent-arch",
|
|
1142
|
+
depth=2,
|
|
1143
|
+
child_count=0,
|
|
1144
|
+
description="moved",
|
|
1145
|
+
)
|
|
1146
|
+
return AsyncFolder(
|
|
1147
|
+
client,
|
|
1148
|
+
"docs-v2",
|
|
1149
|
+
folder_id="folder-123",
|
|
1150
|
+
full_path="/archive/docs-v2",
|
|
1151
|
+
parent_id="parent-arch",
|
|
1152
|
+
depth=2,
|
|
1153
|
+
child_count=0,
|
|
1154
|
+
description="renamed",
|
|
1155
|
+
)
|
|
1156
|
+
|
|
1157
|
+
client.move_folder = mock_move # type: ignore[method-assign]
|
|
1158
|
+
|
|
1159
|
+
async def mock_rename(folder_id_or_name, new_name):
|
|
1160
|
+
return await mock_move(folder_id_or_name, "/archive/docs-v2")
|
|
1161
|
+
|
|
1162
|
+
client.rename_folder = mock_rename # type: ignore[method-assign]
|
|
1163
|
+
|
|
1164
|
+
moved = await folder.move("/archive/docs")
|
|
1165
|
+
assert moved.full_path == "/archive/docs"
|
|
1166
|
+
assert folder.full_path == "/archive/docs"
|
|
1167
|
+
assert folder.parent_id == "parent-arch"
|
|
1168
|
+
|
|
1169
|
+
renamed = await folder.rename("docs-v2")
|
|
1170
|
+
assert renamed.name == "docs-v2"
|
|
1171
|
+
assert renamed.full_path == "/archive/docs-v2"
|
|
1172
|
+
assert folder.name == "docs-v2"
|
|
1173
|
+
assert folder.full_path == "/archive/docs-v2"
|
|
1174
|
+
finally:
|
|
1175
|
+
await client.close()
|
|
1176
|
+
|
|
1177
|
+
|
|
683
1178
|
@pytest.mark.asyncio
|
|
684
1179
|
async def test_async_folder_uses_full_path():
|
|
685
1180
|
"""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
|