acontext 0.0.1.dev3__tar.gz → 0.0.1.dev4__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 (28) hide show
  1. {acontext-0.0.1.dev3 → acontext-0.0.1.dev4}/PKG-INFO +1 -1
  2. {acontext-0.0.1.dev3 → acontext-0.0.1.dev4}/pyproject.toml +6 -2
  3. {acontext-0.0.1.dev3 → acontext-0.0.1.dev4}/src/acontext/__init__.py +19 -1
  4. acontext-0.0.1.dev4/src/acontext/async_client.py +206 -0
  5. {acontext-0.0.1.dev3 → acontext-0.0.1.dev4}/src/acontext/client.py +7 -11
  6. acontext-0.0.1.dev4/src/acontext/client_types.py +36 -0
  7. {acontext-0.0.1.dev3 → acontext-0.0.1.dev4}/src/acontext/errors.py +2 -2
  8. {acontext-0.0.1.dev3 → acontext-0.0.1.dev4}/src/acontext/messages.py +2 -10
  9. {acontext-0.0.1.dev3 → acontext-0.0.1.dev4}/src/acontext/resources/__init__.py +9 -0
  10. acontext-0.0.1.dev4/src/acontext/resources/async_blocks.py +163 -0
  11. acontext-0.0.1.dev4/src/acontext/resources/async_disks.py +195 -0
  12. acontext-0.0.1.dev4/src/acontext/resources/async_sessions.py +272 -0
  13. acontext-0.0.1.dev4/src/acontext/resources/async_spaces.py +90 -0
  14. {acontext-0.0.1.dev3 → acontext-0.0.1.dev4}/src/acontext/resources/blocks.py +3 -3
  15. {acontext-0.0.1.dev3 → acontext-0.0.1.dev4}/src/acontext/resources/disks.py +4 -4
  16. {acontext-0.0.1.dev3 → acontext-0.0.1.dev4}/src/acontext/resources/sessions.py +44 -28
  17. {acontext-0.0.1.dev3 → acontext-0.0.1.dev4}/src/acontext/resources/spaces.py +3 -3
  18. {acontext-0.0.1.dev3 → acontext-0.0.1.dev4}/src/acontext/uploads.py +5 -5
  19. acontext-0.0.1.dev3/src/acontext/client_types.py +0 -21
  20. {acontext-0.0.1.dev3 → acontext-0.0.1.dev4}/README.md +0 -0
  21. {acontext-0.0.1.dev3 → acontext-0.0.1.dev4}/src/acontext/_constants.py +0 -0
  22. {acontext-0.0.1.dev3 → acontext-0.0.1.dev4}/src/acontext/_utils.py +0 -0
  23. {acontext-0.0.1.dev3 → acontext-0.0.1.dev4}/src/acontext/py.typed +0 -0
  24. {acontext-0.0.1.dev3 → acontext-0.0.1.dev4}/src/acontext/types/__init__.py +0 -0
  25. {acontext-0.0.1.dev3 → acontext-0.0.1.dev4}/src/acontext/types/block.py +0 -0
  26. {acontext-0.0.1.dev3 → acontext-0.0.1.dev4}/src/acontext/types/disk.py +0 -0
  27. {acontext-0.0.1.dev3 → acontext-0.0.1.dev4}/src/acontext/types/session.py +0 -0
  28. {acontext-0.0.1.dev3 → acontext-0.0.1.dev4}/src/acontext/types/space.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: acontext
3
- Version: 0.0.1.dev3
3
+ Version: 0.0.1.dev4
4
4
  Summary: Python SDK for the Acontext API
5
5
  Keywords: acontext,sdk,client,api
6
6
  Requires-Dist: httpx>=0.28.1
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "acontext"
3
- version = "0.0.1.dev3"
3
+ version = "0.0.1.dev4"
4
4
  description = "Python SDK for the Acontext API"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -18,7 +18,11 @@ Repository = "https://github.com/memodb-io/Acontext"
18
18
  Issues = "https://github.com/memodb-io/Acontext/issues"
19
19
 
20
20
  [dependency-groups]
21
- dev = ["pytest", "ruff"]
21
+ dev = [
22
+ "pytest",
23
+ "ruff",
24
+ "pytest-asyncio"
25
+ ]
22
26
 
23
27
  [build-system]
24
28
  requires = ["uv_build>=0.9.2,<0.10.0"]
@@ -4,12 +4,25 @@ Python SDK for the Acontext API.
4
4
 
5
5
  from importlib import metadata as _metadata
6
6
 
7
+ from .async_client import AcontextAsyncClient
7
8
  from .client import AcontextClient, FileUpload, MessagePart
8
9
  from .messages import AcontextMessage
9
- from .resources import BlocksAPI, DiskArtifactsAPI, DisksAPI, SessionsAPI, SpacesAPI
10
+ from .resources import (
11
+ AsyncBlocksAPI,
12
+ AsyncDiskArtifactsAPI,
13
+ AsyncDisksAPI,
14
+ AsyncSessionsAPI,
15
+ AsyncSpacesAPI,
16
+ BlocksAPI,
17
+ DiskArtifactsAPI,
18
+ DisksAPI,
19
+ SessionsAPI,
20
+ SpacesAPI,
21
+ )
10
22
 
11
23
  __all__ = [
12
24
  "AcontextClient",
25
+ "AcontextAsyncClient",
13
26
  "FileUpload",
14
27
  "MessagePart",
15
28
  "AcontextMessage",
@@ -18,6 +31,11 @@ __all__ = [
18
31
  "BlocksAPI",
19
32
  "SessionsAPI",
20
33
  "SpacesAPI",
34
+ "AsyncDisksAPI",
35
+ "AsyncDiskArtifactsAPI",
36
+ "AsyncBlocksAPI",
37
+ "AsyncSessionsAPI",
38
+ "AsyncSpacesAPI",
21
39
  "__version__",
22
40
  ]
23
41
 
@@ -0,0 +1,206 @@
1
+ """
2
+ High-level asynchronous client for the Acontext API.
3
+ """
4
+
5
+ import os
6
+ from collections.abc import Mapping
7
+ from typing import Any, BinaryIO
8
+
9
+ import httpx
10
+
11
+ from ._constants import DEFAULT_BASE_URL, DEFAULT_USER_AGENT
12
+ from .errors import APIError, TransportError
13
+ from .messages import MessagePart as MessagePart
14
+ from .uploads import FileUpload as FileUpload
15
+ from .resources.async_disks import AsyncDisksAPI as AsyncDisksAPI
16
+ from .resources.async_blocks import AsyncBlocksAPI as AsyncBlocksAPI
17
+ from .resources.async_sessions import AsyncSessionsAPI as AsyncSessionsAPI
18
+ from .resources.async_spaces import AsyncSpacesAPI as AsyncSpacesAPI
19
+
20
+
21
+ class AcontextAsyncClient:
22
+
23
+ def __init__(
24
+ self,
25
+ *,
26
+ api_key: str | None = None,
27
+ base_url: str | None = None,
28
+ timeout: float | httpx.Timeout | None = 10.0,
29
+ user_agent: str | None = None,
30
+ client: httpx.AsyncClient | None = None,
31
+ ) -> None:
32
+ """
33
+ Initialize the Acontext async client.
34
+
35
+ Args:
36
+ api_key: API key for authentication. Can also be set via ACONTEXT_API_KEY env var.
37
+ base_url: Base URL for the API. Defaults to DEFAULT_BASE_URL. Can also be set via ACONTEXT_BASE_URL env var.
38
+ timeout: Request timeout in seconds. Defaults to 10.0. Can also be set via ACONTEXT_TIMEOUT env var.
39
+ Can also be an httpx.Timeout object.
40
+ user_agent: Custom user agent string. Can also be set via ACONTEXT_USER_AGENT env var.
41
+ client: Optional httpx.AsyncClient instance to reuse. If provided, headers and base_url
42
+ will be merged with the client configuration.
43
+ """
44
+ # Priority: explicit parameters > environment variables > defaults
45
+ # Load api_key from parameter or environment variable
46
+ api_key = api_key or os.getenv("ACONTEXT_API_KEY")
47
+ if not api_key or not api_key.strip():
48
+ raise ValueError(
49
+ "api_key is required. Provide it either as a parameter (api_key='...') "
50
+ "or set the ACONTEXT_API_KEY environment variable."
51
+ )
52
+
53
+ # Load other parameters from environment variables if not provided
54
+ if base_url is None:
55
+ base_url = os.getenv("ACONTEXT_BASE_URL", DEFAULT_BASE_URL)
56
+ base_url = base_url.rstrip("/")
57
+
58
+ if user_agent is None:
59
+ user_agent = os.getenv("ACONTEXT_USER_AGENT", DEFAULT_USER_AGENT)
60
+
61
+ # Handle timeout: support both float and httpx.Timeout
62
+ if timeout is None:
63
+ timeout_str = os.getenv("ACONTEXT_TIMEOUT")
64
+ if timeout_str:
65
+ try:
66
+ timeout = float(timeout_str)
67
+ except ValueError:
68
+ timeout = 10.0
69
+ else:
70
+ timeout = 10.0
71
+
72
+ # Determine actual timeout value
73
+ actual_timeout: float | httpx.Timeout
74
+ if isinstance(timeout, httpx.Timeout):
75
+ actual_timeout = timeout
76
+ else:
77
+ actual_timeout = float(timeout)
78
+
79
+ headers = {
80
+ "Authorization": f"Bearer {api_key}",
81
+ "Accept": "application/json",
82
+ "User-Agent": user_agent,
83
+ }
84
+
85
+ if client is not None:
86
+ self._client = client
87
+ self._owns_client = False
88
+ if client.base_url == httpx.URL():
89
+ client.base_url = httpx.URL(base_url)
90
+ for name, value in headers.items():
91
+ if name not in client.headers:
92
+ client.headers[name] = value
93
+ self._base_url = str(client.base_url) or base_url
94
+ else:
95
+ self._client = httpx.AsyncClient(
96
+ base_url=base_url,
97
+ headers=headers,
98
+ timeout=actual_timeout,
99
+ )
100
+ self._owns_client = True
101
+ self._base_url = base_url
102
+
103
+ self._timeout = actual_timeout
104
+
105
+ self.spaces = AsyncSpacesAPI(self)
106
+ self.sessions = AsyncSessionsAPI(self)
107
+ self.disks = AsyncDisksAPI(self)
108
+ self.artifacts = self.disks.artifacts
109
+ self.blocks = AsyncBlocksAPI(self)
110
+
111
+ @property
112
+ def base_url(self) -> str:
113
+ return self._base_url
114
+
115
+ async def aclose(self) -> None:
116
+ """Close the async client."""
117
+ if self._owns_client:
118
+ await self._client.aclose()
119
+
120
+ async def __aenter__(self) -> "AcontextAsyncClient":
121
+ return self
122
+
123
+ async def __aexit__(self, exc_type, exc, tb) -> None: # noqa: D401 - standard context manager protocol
124
+ await self.aclose()
125
+
126
+ # ------------------------------------------------------------------
127
+ # HTTP plumbing shared by resource clients
128
+ # ------------------------------------------------------------------
129
+ async def request(
130
+ self,
131
+ method: str,
132
+ path: str,
133
+ *,
134
+ params: Mapping[str, Any] | None = None,
135
+ json_data: Mapping[str, Any] | None = None,
136
+ data: Mapping[str, Any] | None = None,
137
+ files: Mapping[str, tuple[str, BinaryIO, str | None]] | None = None,
138
+ unwrap: bool = True,
139
+ ) -> Any:
140
+ try:
141
+ response = await self._client.request(
142
+ method=method,
143
+ url=path,
144
+ params=params,
145
+ json=json_data,
146
+ data=data,
147
+ files=files,
148
+ timeout=self._timeout,
149
+ )
150
+ except httpx.HTTPError as exc: # pragma: no cover - passthrough to caller
151
+ raise TransportError(str(exc)) from exc
152
+
153
+ return self._handle_response(response, unwrap=unwrap)
154
+
155
+ @staticmethod
156
+ def _handle_response(response: httpx.Response, *, unwrap: bool) -> Any:
157
+ content_type = response.headers.get("content-type", "")
158
+
159
+ parsed: Mapping[str, Any] | None = None
160
+ if "application/json" in content_type:
161
+ try:
162
+ parsed = response.json()
163
+ except ValueError:
164
+ parsed = None
165
+ else:
166
+ parsed = None
167
+
168
+ if response.status_code >= 400:
169
+ message = response.reason_phrase
170
+ payload: Mapping[str, Any] | None = parsed
171
+ code: int | None = None
172
+ error: str | None = None
173
+ if payload and isinstance(payload, Mapping):
174
+ message = str(payload.get("msg") or payload.get("message") or message)
175
+ error = payload.get("error")
176
+ try:
177
+ code_val = payload.get("code")
178
+ if isinstance(code_val, int):
179
+ code = code_val
180
+ except Exception: # pragma: no cover - defensive
181
+ code = None
182
+ raise APIError(
183
+ status_code=response.status_code,
184
+ code=code,
185
+ message=message,
186
+ error=error,
187
+ payload=payload,
188
+ )
189
+
190
+ if parsed is None:
191
+ if unwrap:
192
+ return response.text
193
+ return {"code": response.status_code, "data": response.text, "msg": response.reason_phrase}
194
+
195
+ app_code = parsed.get("code")
196
+ if isinstance(app_code, int) and app_code >= 400:
197
+ raise APIError(
198
+ status_code=response.status_code,
199
+ code=app_code,
200
+ message=str(parsed.get("msg") or response.reason_phrase),
201
+ error=parsed.get("error"),
202
+ payload=parsed,
203
+ )
204
+
205
+ return parsed.get("data") if unwrap else parsed
206
+
@@ -3,7 +3,7 @@ High-level synchronous client for the Acontext API.
3
3
  """
4
4
 
5
5
  import os
6
- from collections.abc import Mapping, MutableMapping
6
+ from collections.abc import Mapping
7
7
  from typing import Any, BinaryIO
8
8
 
9
9
  import httpx
@@ -70,6 +70,7 @@ class AcontextClient:
70
70
  timeout = 10.0
71
71
 
72
72
  # Determine actual timeout value
73
+ actual_timeout: float | httpx.Timeout
73
74
  if isinstance(timeout, httpx.Timeout):
74
75
  actual_timeout = timeout
75
76
  else:
@@ -130,8 +131,8 @@ class AcontextClient:
130
131
  path: str,
131
132
  *,
132
133
  params: Mapping[str, Any] | None = None,
133
- json_data: Mapping[str, Any] | MutableMapping[str, Any] | None = None,
134
- data: Mapping[str, Any] | MutableMapping[str, Any] | None = None,
134
+ json_data: Mapping[str, Any] | None = None,
135
+ data: Mapping[str, Any] | None = None,
135
136
  files: Mapping[str, tuple[str, BinaryIO, str | None]] | None = None,
136
137
  unwrap: bool = True,
137
138
  ) -> Any:
@@ -154,10 +155,10 @@ class AcontextClient:
154
155
  def _handle_response(response: httpx.Response, *, unwrap: bool) -> Any:
155
156
  content_type = response.headers.get("content-type", "")
156
157
 
157
- parsed: Mapping[str, Any] | MutableMapping[str, Any] | None
158
+ parsed: Mapping[str, Any] | None
158
159
  if "application/json" in content_type:
159
160
  try:
160
- parsed = response.json()
161
+ parsed = response.json() # dict
161
162
  except ValueError:
162
163
  parsed = None
163
164
  else:
@@ -165,7 +166,7 @@ class AcontextClient:
165
166
 
166
167
  if response.status_code >= 400:
167
168
  message = response.reason_phrase
168
- payload: Mapping[str, Any] | MutableMapping[str, Any] | None = parsed
169
+ payload: Mapping[str, Any] | None = parsed
169
170
  code: int | None = None
170
171
  error: str | None = None
171
172
  if payload and isinstance(payload, Mapping):
@@ -190,11 +191,6 @@ class AcontextClient:
190
191
  return response.text
191
192
  return {"code": response.status_code, "data": response.text, "msg": response.reason_phrase}
192
193
 
193
- if not isinstance(parsed, Mapping):
194
- if unwrap:
195
- return parsed
196
- return parsed
197
-
198
194
  app_code = parsed.get("code")
199
195
  if isinstance(app_code, int) and app_code >= 400:
200
196
  raise APIError(
@@ -0,0 +1,36 @@
1
+ """
2
+ Common typing helpers used by resource modules to avoid circular imports.
3
+ """
4
+
5
+ from collections.abc import Awaitable, Mapping
6
+ from typing import Any, BinaryIO, Protocol
7
+
8
+
9
+ class RequesterProtocol(Protocol):
10
+ def request(
11
+ self,
12
+ method: str,
13
+ path: str,
14
+ *,
15
+ params: Mapping[str, Any] | None = None,
16
+ json_data: Mapping[str, Any] | None = None,
17
+ data: Mapping[str, Any] | None = None,
18
+ files: Mapping[str, tuple[str, BinaryIO, str | None]] | None = None,
19
+ unwrap: bool = True,
20
+ ) -> Any:
21
+ ...
22
+
23
+
24
+ class AsyncRequesterProtocol(Protocol):
25
+ def request(
26
+ self,
27
+ method: str,
28
+ path: str,
29
+ *,
30
+ params: Mapping[str, Any] | None = None,
31
+ json_data: Mapping[str, Any] | None = None,
32
+ data: Mapping[str, Any] | None = None,
33
+ files: Mapping[str, tuple[str, BinaryIO, str | None]] | None = None,
34
+ unwrap: bool = True,
35
+ ) -> Awaitable[Any]:
36
+ ...
@@ -2,7 +2,7 @@
2
2
  Custom exceptions raised by the acontext Python client.
3
3
  """
4
4
 
5
- from collections.abc import Mapping, MutableMapping
5
+ from collections.abc import Mapping
6
6
  from typing import Any
7
7
 
8
8
 
@@ -29,7 +29,7 @@ class APIError(AcontextError):
29
29
  code: int | None = None,
30
30
  message: str | None = None,
31
31
  error: str | None = None,
32
- payload: Mapping[str, Any] | MutableMapping[str, Any] | None = None,
32
+ payload: Mapping[str, Any] | None = None,
33
33
  ) -> None:
34
34
  self.status_code = status_code
35
35
  self.code = code
@@ -2,7 +2,7 @@
2
2
  Support for constructing session messages.
3
3
  """
4
4
 
5
- from collections.abc import Mapping, MutableMapping, Sequence
5
+ from collections.abc import Mapping, Sequence
6
6
  from dataclasses import dataclass
7
7
  from typing import Any, Literal
8
8
 
@@ -25,14 +25,6 @@ class MessagePart:
25
25
  meta: Mapping[str, Any] | None = None
26
26
  file_field: str | None = None
27
27
 
28
- @classmethod
29
- def text_part(cls, text: str, *, meta: Mapping[str, Any] | None = None) -> "MessagePart":
30
- return cls(type="text", text=text, meta=meta)
31
-
32
- @classmethod
33
- def file_field_part(cls, file_field: str, *, meta: Mapping[str, Any] | None = None) -> "MessagePart":
34
- return cls(type="file", file_field=file_field, meta=meta)
35
-
36
28
  @dataclass(slots=True)
37
29
  class AcontextMessage:
38
30
  """
@@ -41,7 +33,7 @@ class AcontextMessage:
41
33
 
42
34
  role: Literal["user", "assistant", "system"]
43
35
  parts: list[MessagePart]
44
- meta: MutableMapping[str, Any] | None = None
36
+ meta: Mapping[str, Any] | None = None
45
37
 
46
38
 
47
39
  def build_acontext_message(
@@ -1,5 +1,9 @@
1
1
  """Resource-specific API helpers for the Acontext client."""
2
2
 
3
+ from .async_blocks import AsyncBlocksAPI
4
+ from .async_disks import AsyncDisksAPI, AsyncDiskArtifactsAPI
5
+ from .async_sessions import AsyncSessionsAPI
6
+ from .async_spaces import AsyncSpacesAPI
3
7
  from .blocks import BlocksAPI
4
8
  from .disks import DisksAPI, DiskArtifactsAPI
5
9
  from .sessions import SessionsAPI
@@ -11,4 +15,9 @@ __all__ = [
11
15
  "BlocksAPI",
12
16
  "SessionsAPI",
13
17
  "SpacesAPI",
18
+ "AsyncDisksAPI",
19
+ "AsyncDiskArtifactsAPI",
20
+ "AsyncBlocksAPI",
21
+ "AsyncSessionsAPI",
22
+ "AsyncSpacesAPI",
14
23
  ]
@@ -0,0 +1,163 @@
1
+ """
2
+ Block endpoints (async).
3
+ """
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+ from ..client_types import AsyncRequesterProtocol
9
+ from ..types.block import Block
10
+
11
+
12
+ class AsyncBlocksAPI:
13
+ def __init__(self, requester: AsyncRequesterProtocol) -> None:
14
+ self._requester = requester
15
+
16
+ async def list(
17
+ self,
18
+ space_id: str,
19
+ *,
20
+ parent_id: str | None = None,
21
+ block_type: str | None = None,
22
+ ) -> list[Block]:
23
+ """List blocks in a space.
24
+
25
+ Args:
26
+ space_id: The UUID of the space.
27
+ parent_id: Filter blocks by parent ID. Defaults to None.
28
+ block_type: Filter blocks by type (e.g., "page", "folder", "text", "sop"). Defaults to None.
29
+
30
+ Returns:
31
+ List of Block objects.
32
+ """
33
+ params: dict[str, Any] = {}
34
+ if parent_id is not None:
35
+ params["parent_id"] = parent_id
36
+ if block_type is not None:
37
+ params["type"] = block_type
38
+ data = await self._requester.request("GET", f"/space/{space_id}/block", params=params or None)
39
+ return [Block.model_validate(item) for item in data]
40
+
41
+ async def create(
42
+ self,
43
+ space_id: str,
44
+ *,
45
+ block_type: str,
46
+ parent_id: str | None = None,
47
+ title: str | None = None,
48
+ props: Mapping[str, Any] | None = None,
49
+ ) -> Block:
50
+ """Create a new block in a space.
51
+
52
+ Args:
53
+ space_id: The UUID of the space.
54
+ block_type: The type of block (e.g., "page", "folder", "text", "sop").
55
+ parent_id: Optional parent block ID. Defaults to None.
56
+ title: Optional block title. Defaults to None.
57
+ props: Optional block properties dictionary. Defaults to None.
58
+
59
+ Returns:
60
+ The created Block object.
61
+ """
62
+ payload: dict[str, Any] = {"type": block_type}
63
+ if parent_id is not None:
64
+ payload["parent_id"] = parent_id
65
+ if title is not None:
66
+ payload["title"] = title
67
+ if props is not None:
68
+ payload["props"] = props
69
+ data = await self._requester.request("POST", f"/space/{space_id}/block", json_data=payload)
70
+ return Block.model_validate(data)
71
+
72
+ async def delete(self, space_id: str, block_id: str) -> None:
73
+ """Delete a block by its ID.
74
+
75
+ Args:
76
+ space_id: The UUID of the space.
77
+ block_id: The UUID of the block to delete.
78
+ """
79
+ await self._requester.request("DELETE", f"/space/{space_id}/block/{block_id}")
80
+
81
+ async def get_properties(self, space_id: str, block_id: str) -> Block:
82
+ """Get block properties.
83
+
84
+ Args:
85
+ space_id: The UUID of the space.
86
+ block_id: The UUID of the block.
87
+
88
+ Returns:
89
+ Block object containing the properties.
90
+ """
91
+ data = await self._requester.request("GET", f"/space/{space_id}/block/{block_id}/properties")
92
+ return Block.model_validate(data)
93
+
94
+ async def update_properties(
95
+ self,
96
+ space_id: str,
97
+ block_id: str,
98
+ *,
99
+ title: str | None = None,
100
+ props: Mapping[str, Any] | None = None,
101
+ ) -> None:
102
+ """Update block properties.
103
+
104
+ Args:
105
+ space_id: The UUID of the space.
106
+ block_id: The UUID of the block.
107
+ title: Optional block title. Defaults to None.
108
+ props: Optional block properties dictionary. Defaults to None.
109
+
110
+ Raises:
111
+ ValueError: If both title and props are None.
112
+ """
113
+ payload: dict[str, Any] = {}
114
+ if title is not None:
115
+ payload["title"] = title
116
+ if props is not None:
117
+ payload["props"] = props
118
+ if not payload:
119
+ raise ValueError("title or props must be provided")
120
+ await self._requester.request("PUT", f"/space/{space_id}/block/{block_id}/properties", json_data=payload)
121
+
122
+ async def move(
123
+ self,
124
+ space_id: str,
125
+ block_id: str,
126
+ *,
127
+ parent_id: str | None = None,
128
+ sort: int | None = None,
129
+ ) -> None:
130
+ """Move a block by updating its parent or sort order.
131
+
132
+ Args:
133
+ space_id: The UUID of the space.
134
+ block_id: The UUID of the block to move.
135
+ parent_id: Optional new parent block ID. Defaults to None.
136
+ sort: Optional new sort order. Defaults to None.
137
+
138
+ Raises:
139
+ ValueError: If both parent_id and sort are None.
140
+ """
141
+ payload: dict[str, Any] = {}
142
+ if parent_id is not None:
143
+ payload["parent_id"] = parent_id
144
+ if sort is not None:
145
+ payload["sort"] = sort
146
+ if not payload:
147
+ raise ValueError("parent_id or sort must be provided")
148
+ await self._requester.request("PUT", f"/space/{space_id}/block/{block_id}/move", json_data=payload)
149
+
150
+ async def update_sort(self, space_id: str, block_id: str, *, sort: int) -> None:
151
+ """Update block sort order.
152
+
153
+ Args:
154
+ space_id: The UUID of the space.
155
+ block_id: The UUID of the block.
156
+ sort: The new sort order.
157
+ """
158
+ await self._requester.request(
159
+ "PUT",
160
+ f"/space/{space_id}/block/{block_id}/sort",
161
+ json_data={"sort": sort},
162
+ )
163
+