nerdstack-ark 1.0.4__tar.gz → 1.0.5__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 (22) hide show
  1. nerdstack_ark-1.0.5/CHANGELOG.md +34 -0
  2. {nerdstack_ark-1.0.4 → nerdstack_ark-1.0.5}/PKG-INFO +1 -1
  3. {nerdstack_ark-1.0.4 → nerdstack_ark-1.0.5}/pyproject.toml +1 -1
  4. {nerdstack_ark-1.0.4 → nerdstack_ark-1.0.5}/src/ark_py/__init__.py +10 -1
  5. {nerdstack_ark-1.0.4 → nerdstack_ark-1.0.5}/src/ark_py/_shared.py +29 -1
  6. {nerdstack_ark-1.0.4 → nerdstack_ark-1.0.5}/src/ark_py/async_client.py +2 -6
  7. {nerdstack_ark-1.0.4 → nerdstack_ark-1.0.5}/src/ark_py/errors.py +17 -0
  8. {nerdstack_ark-1.0.4 → nerdstack_ark-1.0.5}/src/ark_py/sync.py +2 -6
  9. {nerdstack_ark-1.0.4 → nerdstack_ark-1.0.5}/tests/test_async.py +65 -0
  10. {nerdstack_ark-1.0.4 → nerdstack_ark-1.0.5}/tests/test_sync.py +89 -1
  11. {nerdstack_ark-1.0.4 → nerdstack_ark-1.0.5}/.gitignore +0 -0
  12. {nerdstack_ark-1.0.4 → nerdstack_ark-1.0.5}/LICENSE +0 -0
  13. {nerdstack_ark-1.0.4 → nerdstack_ark-1.0.5}/README.md +0 -0
  14. {nerdstack_ark-1.0.4 → nerdstack_ark-1.0.5}/examples/django/apps.py +0 -0
  15. {nerdstack_ark-1.0.4 → nerdstack_ark-1.0.5}/examples/django/views.py +0 -0
  16. {nerdstack_ark-1.0.4 → nerdstack_ark-1.0.5}/examples/fastapi/app.py +0 -0
  17. {nerdstack_ark-1.0.4 → nerdstack_ark-1.0.5}/examples/flask/app.py +0 -0
  18. {nerdstack_ark-1.0.4 → nerdstack_ark-1.0.5}/src/ark_py/models.py +0 -0
  19. {nerdstack_ark-1.0.4 → nerdstack_ark-1.0.5}/src/ark_py/py.typed +0 -0
  20. {nerdstack_ark-1.0.4 → nerdstack_ark-1.0.5}/src/ark_py/s3.py +0 -0
  21. {nerdstack_ark-1.0.4 → nerdstack_ark-1.0.5}/tests/conftest.py +0 -0
  22. {nerdstack_ark-1.0.4 → nerdstack_ark-1.0.5}/tests/test_stream_upload.py +0 -0
@@ -0,0 +1,34 @@
1
+ # Changelog
2
+
3
+ ## 1.0.5
4
+
5
+ ### Fixed
6
+
7
+ - `folders.list()` and `AsyncFolders.list()` returned an empty tuple against
8
+ every real workspace. `GET /api/v2/folders` returns the array under
9
+ `folders`, but the SDK read `data` — the key `/api/v2/files` uses. The
10
+ missing key was silently coerced to an empty list, so callers that resolve a
11
+ folder by name saw "no such folder", called `create()`, and got back
12
+ "A folder with this name already exists here". Nesting was unusable for the
13
+ same reason, since resolving `a/b/c` lists children at each level.
14
+
15
+ Both envelopes are now accepted, so an older deployment keeps working.
16
+
17
+ - A folder-list body the SDK cannot parse now raises `ArkError` with code
18
+ `INVALID_RESPONSE` instead of being reported as an empty list. The silent
19
+ empty result is what made the bug above expensive to diagnose: `list()` and
20
+ `create()` reported opposite things and neither raised.
21
+
22
+ - `ArkError` now exposes `message`. It was documented and passed to
23
+ `Exception.__init__`, but never stored, so `error.message` raised
24
+ `AttributeError` inside callers' own error handlers. `str(error)` was the
25
+ only thing that worked.
26
+
27
+ - `ark_py.__version__` read `1.0.0` while the package was on 1.0.4. It is now
28
+ read from the installed distribution metadata, so it cannot drift again.
29
+
30
+ ### Internal
31
+
32
+ - Folder parsing moved to `_shared.parse_folder_list`. The identical code was
33
+ duplicated in the sync and async clients, which is why one bug needed fixing
34
+ in two places, and why the async client had no folder test at all.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: nerdstack-ark
3
- Version: 1.0.4
3
+ Version: 1.0.5
4
4
  Summary: Official Python SDK for Ark storage, with sync, async, and S3-compatible access.
5
5
  Project-URL: Homepage, https://ark.nerdstackgrp.com
6
6
  Project-URL: Documentation, https://github.com/joshhumphrey02/ark-sdk/tree/master/packages/ark-py#readme
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "nerdstack-ark"
7
- version = "1.0.4"
7
+ version = "1.0.5"
8
8
  description = "Official Python SDK for Ark storage, with sync, async, and S3-compatible access."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,5 +1,8 @@
1
1
  """Official Python SDK for Ark storage."""
2
2
 
3
+ from importlib.metadata import PackageNotFoundError
4
+ from importlib.metadata import version as _version
5
+
3
6
  from .async_client import AsyncArk
4
7
  from .errors import ArkError
5
8
  from .models import (
@@ -38,4 +41,10 @@ __all__ = [
38
41
  "create_s3_client",
39
42
  ]
40
43
 
41
- __version__ = "1.0.0"
44
+ # Read from the installed distribution rather than hard-coded. This said
45
+ # "1.0.0" while pyproject.toml said 1.0.4, so anyone reporting a bug with
46
+ # ark_py.__version__ quoted a version that had not shipped in months.
47
+ try: # pragma: no cover - depends on install state, not on logic
48
+ __version__ = _version("nerdstack-ark")
49
+ except PackageNotFoundError: # running from a source tree, not installed
50
+ __version__ = "0.0.0.dev0"
@@ -9,7 +9,7 @@ from pathlib import Path
9
9
  from typing import Any, BinaryIO
10
10
  from urllib.parse import quote, urlencode
11
11
 
12
- from .errors import invalid_argument
12
+ from .errors import invalid_argument, invalid_response
13
13
  from .models import ClientSession, ImageOptions
14
14
 
15
15
  DEFAULT_BASE_URL = "https://ark.nerdstackgrp.com"
@@ -42,6 +42,34 @@ def image_url(base_url: str, version: str, asset_id: str, options: ImageOptions)
42
42
  return api_url(base_url, version, f"/assets/{segment(asset_id)}/image{suffix}")
43
43
 
44
44
 
45
+ def parse_folder_list(value: Mapping[str, Any]) -> list[Mapping[str, Any]]:
46
+ """Pull the folder array out of a GET /folders body.
47
+
48
+ The two list endpoints do not share an envelope: /files returns
49
+ ``{"data": [...], "nextCursor": ...}`` while /folders returns
50
+ ``{"folders": [...], "pagination": {...}}``. The SDK read ``data`` for
51
+ both, so folders.list() returned nothing against every real workspace --
52
+ silently, because a missing key was coerced to an empty list.
53
+
54
+ Both keys are accepted so an older deployment keeps working, and anything
55
+ else raises rather than reporting an empty folder list that the caller
56
+ cannot distinguish from a real one.
57
+
58
+ Lives here rather than in each client because the identical parsing was
59
+ duplicated in sync.py and async_client.py -- which is why one bug needed
60
+ fixing in two places.
61
+ """
62
+ for key in ("folders", "data"):
63
+ raw = value.get(key)
64
+ if isinstance(raw, list):
65
+ return [item for item in raw if isinstance(item, Mapping)]
66
+
67
+ raise invalid_response(
68
+ "The folder list response was not in a recognised format. "
69
+ f"Expected a 'folders' array, got keys: {sorted(map(str, value.keys())) or 'none'}."
70
+ )
71
+
72
+
45
73
  def parse_client_session(value: Mapping[str, Any]) -> ClientSession:
46
74
  raw_scopes = value.get("scopes")
47
75
  scopes = tuple(str(scope) for scope in raw_scopes) if isinstance(raw_scopes, list) else ()
@@ -21,6 +21,7 @@ from ._shared import (
21
21
  api_url,
22
22
  image_url,
23
23
  parse_client_session,
24
+ parse_folder_list,
24
25
  query_string,
25
26
  read_exact,
26
27
  resolve_upload_source,
@@ -381,12 +382,7 @@ class AsyncFolders:
381
382
  "GET",
382
383
  f"/folders{query_string({'parentId': parent_id})}",
383
384
  )
384
- raw_data = value.get("data")
385
- return tuple(
386
- ArkFolder.from_dict(item)
387
- for item in (raw_data if isinstance(raw_data, list) else [])
388
- if isinstance(item, Mapping)
389
- )
385
+ return tuple(ArkFolder.from_dict(item) for item in parse_folder_list(value))
390
386
 
391
387
  async def create(self, name: str, *, parent_id: str | None = None) -> ArkFolder:
392
388
  payload: dict[str, Any] = {"name": name}
@@ -19,6 +19,11 @@ class ArkError(Exception):
19
19
  ) -> None:
20
20
  super().__init__(message)
21
21
  self.code = code
22
+ # Kept as an attribute, not just handed to Exception. The README
23
+ # documents `error.message`, and only `str(error)` actually worked --
24
+ # so every caller following the docs hit an AttributeError inside
25
+ # their own error handler, which is the worst possible place for one.
26
+ self.message = message
22
27
  self.status = status
23
28
  self.request_id = request_id
24
29
  self.details = details
@@ -83,5 +88,17 @@ def invalid_argument(message: str) -> ArkError:
83
88
  return ArkError("INVALID_ARGUMENT", message)
84
89
 
85
90
 
91
+ def invalid_response(message: str) -> ArkError:
92
+ """A 2xx body the SDK could not make sense of.
93
+
94
+ Coercing an unrecognised shape to an empty list is what turned a one-line
95
+ parsing bug into a workflow that could never succeed: `list()` reported no
96
+ folders, `create()` then refused because the folder was already there, and
97
+ nothing anywhere raised. Failing loudly here means the next such mismatch
98
+ is a stack trace pointing at the response, not a silent wrong answer.
99
+ """
100
+ return ArkError("INVALID_RESPONSE", message)
101
+
102
+
86
103
  def _optional_string(value: object) -> str | None:
87
104
  return value if isinstance(value, str) else None
@@ -21,6 +21,7 @@ from ._shared import (
21
21
  iter_exact,
22
22
  iter_file_range,
23
23
  parse_client_session,
24
+ parse_folder_list,
24
25
  query_string,
25
26
  read_exact,
26
27
  resolve_upload_source,
@@ -339,12 +340,7 @@ class Folders:
339
340
 
340
341
  def list(self, *, parent_id: str | None = None) -> tuple[ArkFolder, ...]:
341
342
  value = self._ark._request("GET", f"/folders{query_string({'parentId': parent_id})}")
342
- raw_data = value.get("data")
343
- return tuple(
344
- ArkFolder.from_dict(item)
345
- for item in (raw_data if isinstance(raw_data, list) else [])
346
- if isinstance(item, Mapping)
347
- )
343
+ return tuple(ArkFolder.from_dict(item) for item in parse_folder_list(value))
348
344
 
349
345
  def create(self, name: str, *, parent_id: str | None = None) -> ArkFolder:
350
346
  payload: dict[str, Any] = {"name": name}
@@ -199,3 +199,68 @@ async def test_async_upload_requires_stream_metadata() -> None:
199
199
  with pytest.raises(ArkError, match="filename is required"):
200
200
  await ark.files.upload(source(), size=4)
201
201
  await client.aclose()
202
+
203
+
204
+ @pytest.mark.asyncio
205
+ async def test_async_folders_list_reads_the_api_envelope() -> None:
206
+ """The async client had no folder coverage at all, which is why it carried
207
+ the same 'data' vs 'folders' bug as the sync client and nothing caught it.
208
+ """
209
+
210
+ def handler(request: httpx.Request) -> httpx.Response:
211
+ return json_response(
212
+ {
213
+ "folders": [
214
+ {"id": "folder-1", "name": "Media", "parentId": None},
215
+ {"id": "folder-2", "name": "Docs", "parentId": "folder-1"},
216
+ ],
217
+ "pagination": {"page": 1, "limit": 50, "total": 2, "pages": 1},
218
+ }
219
+ )
220
+
221
+ client = async_client_for(handler)
222
+ ark = AsyncArk("token", client=client)
223
+ folders = await ark.folders.list()
224
+ assert [folder.name for folder in folders] == ["Media", "Docs"]
225
+ await client.aclose()
226
+
227
+
228
+ @pytest.mark.asyncio
229
+ async def test_async_folders_list_still_accepts_a_data_envelope() -> None:
230
+ def handler(request: httpx.Request) -> httpx.Response:
231
+ return json_response({"data": [{"id": "f1", "name": "Legacy", "parentId": None}]})
232
+
233
+ client = async_client_for(handler)
234
+ ark = AsyncArk("token", client=client)
235
+ assert [folder.name for folder in await ark.folders.list()] == ["Legacy"]
236
+ await client.aclose()
237
+
238
+
239
+ @pytest.mark.asyncio
240
+ async def test_async_folders_list_rejects_an_unrecognised_body() -> None:
241
+ def handler(request: httpx.Request) -> httpx.Response:
242
+ return json_response({"unexpected": []})
243
+
244
+ client = async_client_for(handler)
245
+ ark = AsyncArk("token", client=client)
246
+ with pytest.raises(ArkError) as caught:
247
+ await ark.folders.list()
248
+ assert caught.value.code == "INVALID_RESPONSE"
249
+ await client.aclose()
250
+
251
+
252
+ @pytest.mark.asyncio
253
+ async def test_async_folders_list_passes_parent_id_through() -> None:
254
+ """Nested resolution walks children level by level, so parentId has to
255
+ reach the query string for anything below the root to be listable."""
256
+ urls: list[str] = []
257
+
258
+ def handler(request: httpx.Request) -> httpx.Response:
259
+ urls.append(str(request.url))
260
+ return json_response({"folders": [], "pagination": {"total": 0}})
261
+
262
+ client = async_client_for(handler)
263
+ ark = AsyncArk("token", client=client)
264
+ await ark.folders.list(parent_id="folder-1")
265
+ assert "parentId=folder-1" in urls[0]
266
+ await client.aclose()
@@ -55,7 +55,16 @@ def test_resources_models_images_and_errors() -> None:
55
55
  if request.url.path.endswith("/files"):
56
56
  return json_response({"data": [file_response(4)], "nextCursor": "next"})
57
57
  if request.url.path.endswith("/folders"):
58
- return json_response({"data": [{"id": "folder-1", "name": "Media", "parentId": None}]})
58
+ # The envelope the API actually sends. This mock previously
59
+ # returned {"data": ...}, matching the SDK's mistaken expectation
60
+ # rather than the server, so it passed while every real call
61
+ # returned nothing.
62
+ return json_response(
63
+ {
64
+ "folders": [{"id": "folder-1", "name": "Media", "parentId": None}],
65
+ "pagination": {"page": 1, "limit": 50, "total": 1, "pages": 1},
66
+ }
67
+ )
59
68
  if request.url.path.endswith("/usage"):
60
69
  return json_response(
61
70
  {
@@ -284,3 +293,82 @@ def test_non_seekable_stream_requires_size_and_filename() -> None:
284
293
  with pytest.raises(ArkError, match="filename is required"):
285
294
  ark.files.upload(NonSeekable(b"data"), size=4)
286
295
  ark._client.close()
296
+
297
+
298
+ def test_folders_list_reads_the_shape_the_api_actually_returns() -> None:
299
+ """GET /v2/folders returns {"folders": [...], "pagination": {...}}.
300
+
301
+ The SDK read "data" -- the key /v2/files uses -- so this returned an empty
302
+ tuple against every real workspace. Nothing raised: the parser's
303
+ isinstance guard turned the missing key into [], so callers that resolve a
304
+ folder by name saw "no such folder", called create(), and got back "a
305
+ folder with this name already exists". That contradiction is what made the
306
+ bug expensive to diagnose, and it shipped because the test mock returned
307
+ the same wrong shape the code expected.
308
+ """
309
+
310
+ def handler(request: httpx.Request) -> httpx.Response:
311
+ return json_response(
312
+ {
313
+ "folders": [
314
+ {"id": "folder-1", "name": "Media", "parentId": None},
315
+ {"id": "folder-2", "name": "Docs", "parentId": None},
316
+ ],
317
+ "pagination": {"page": 1, "limit": 50, "total": 2, "pages": 1},
318
+ }
319
+ )
320
+
321
+ client = client_for(handler)
322
+ ark = Ark("token", client=client)
323
+ folders = ark.folders.list()
324
+ assert [folder.name for folder in folders] == ["Media", "Docs"]
325
+ client.close()
326
+
327
+
328
+ def test_folders_list_still_accepts_a_data_envelope() -> None:
329
+ """A deployment that has not been updated must keep working."""
330
+
331
+ def handler(request: httpx.Request) -> httpx.Response:
332
+ return json_response({"data": [{"id": "f1", "name": "Legacy", "parentId": None}]})
333
+
334
+ client = client_for(handler)
335
+ ark = Ark("token", client=client)
336
+ assert [folder.name for folder in ark.folders.list()] == ["Legacy"]
337
+ client.close()
338
+
339
+
340
+ def test_folders_list_rejects_a_body_it_does_not_understand() -> None:
341
+ """An unrecognised body must not be silently reported as "no folders".
342
+
343
+ Returning () for a response the SDK failed to parse is what turned a
344
+ parsing bug into a workflow that could never succeed.
345
+ """
346
+
347
+ def handler(request: httpx.Request) -> httpx.Response:
348
+ return json_response({"unexpected": []})
349
+
350
+ client = client_for(handler)
351
+ ark = Ark("token", client=client)
352
+ with pytest.raises(ArkError) as caught:
353
+ ark.folders.list()
354
+ assert caught.value.code == "INVALID_RESPONSE"
355
+ client.close()
356
+
357
+
358
+ def test_folders_list_distinguishes_empty_from_unparseable() -> None:
359
+ """An genuinely empty folder list is still an empty tuple, not an error."""
360
+
361
+ def handler(request: httpx.Request) -> httpx.Response:
362
+ return json_response({"folders": [], "pagination": {"total": 0}})
363
+
364
+ client = client_for(handler)
365
+ ark = Ark("token", client=client)
366
+ assert ark.folders.list() == ()
367
+ client.close()
368
+
369
+
370
+ def test_ark_error_exposes_message() -> None:
371
+ """The README documents `message`; only `str(error)` actually worked."""
372
+ error = ArkError("NOT_FOUND", "missing", status=404)
373
+ assert error.message == "missing"
374
+ assert str(error) == "missing"
File without changes
File without changes
File without changes