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.
Files changed (24) hide show
  1. {morphik-1.2.1 → morphik-1.2.3}/.gitignore +2 -1
  2. {morphik-1.2.1 → morphik-1.2.3}/PKG-INFO +16 -1
  3. {morphik-1.2.1 → morphik-1.2.3}/README.md +15 -0
  4. {morphik-1.2.1 → morphik-1.2.3}/morphik/__init__.py +1 -1
  5. {morphik-1.2.1 → morphik-1.2.3}/morphik/_internal.py +32 -3
  6. {morphik-1.2.1 → morphik-1.2.3}/morphik/_scoped_ops.py +2 -0
  7. {morphik-1.2.1 → morphik-1.2.3}/morphik/_shared.py +38 -0
  8. {morphik-1.2.1 → morphik-1.2.3}/morphik/async_.py +88 -1
  9. {morphik-1.2.1 → morphik-1.2.3}/morphik/sync.py +87 -0
  10. {morphik-1.2.1 → morphik-1.2.3}/morphik/tests/test_async.py +76 -2
  11. {morphik-1.2.1 → morphik-1.2.3}/morphik/tests/test_scoped_ops_unit.py +495 -0
  12. {morphik-1.2.1 → morphik-1.2.3}/morphik/tests/test_sync.py +69 -2
  13. {morphik-1.2.1 → morphik-1.2.3}/pyproject.toml +1 -1
  14. {morphik-1.2.1 → morphik-1.2.3}/morphik/exceptions.py +0 -0
  15. {morphik-1.2.1 → morphik-1.2.3}/morphik/models.py +0 -0
  16. {morphik-1.2.1 → morphik-1.2.3}/morphik/tests/README.md +0 -0
  17. {morphik-1.2.1 → morphik-1.2.3}/morphik/tests/__init__.py +0 -0
  18. {morphik-1.2.1 → morphik-1.2.3}/morphik/tests/example_usage.py +0 -0
  19. {morphik-1.2.1 → morphik-1.2.3}/morphik/tests/test_app_ops.py +0 -0
  20. {morphik-1.2.1 → morphik-1.2.3}/morphik/tests/test_docs/sample1.txt +0 -0
  21. {morphik-1.2.1 → morphik-1.2.3}/morphik/tests/test_docs/sample2.txt +0 -0
  22. {morphik-1.2.1 → morphik-1.2.3}/morphik/tests/test_docs/sample3.txt +0 -0
  23. {morphik-1.2.1 → morphik-1.2.3}/morphik/tests/test_shared_helpers.py +0 -0
  24. {morphik-1.2.1 → morphik-1.2.3}/morphik/tests/test_update_document_metadata_rename.py +0 -0
@@ -59,4 +59,5 @@ multi_vector_embeddings_*.json
59
59
  morphik_rust/target/
60
60
  morphik_rust/Cargo.lock
61
61
  DOCS_BY_FACILITIES/
62
- eval_freshworks/
62
+ eval_freshworks/
63
+ iep_test/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: morphik
3
- Version: 1.2.1
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
@@ -14,4 +14,4 @@ __all__ = [
14
14
  "DocumentQueryResponse",
15
15
  ]
16
16
 
17
- __version__ = "1.2.1"
17
+ __version__ = "1.2.3"
@@ -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.split("@")
69
- _, self._auth_token = auth.split(":")
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
- # Connects to localhost:8000 by default, increase timeout
40
- client = AsyncMorphik(timeout=120)
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
- # Connects to localhost:8000 by default, increase timeout for query tests
40
- client = Morphik(timeout=120)
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "morphik"
7
- version = "1.2.1"
7
+ version = "1.2.3"
8
8
  authors = [
9
9
  { name = "Morphik", email = "founders@morphik.ai" },
10
10
  ]
File without changes
File without changes
File without changes