morphik 1.2.1__tar.gz → 1.2.2__tar.gz

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