runapi-core 0.5.1__tar.gz → 0.7.0__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 (35) hide show
  1. {runapi_core-0.5.1 → runapi_core-0.7.0}/PKG-INFO +20 -3
  2. {runapi_core-0.5.1 → runapi_core-0.7.0}/README.md +18 -1
  3. {runapi_core-0.5.1 → runapi_core-0.7.0}/pyproject.toml +1 -1
  4. {runapi_core-0.5.1 → runapi_core-0.7.0}/src/runapi/core/__init__.py +10 -3
  5. {runapi_core-0.5.1 → runapi_core-0.7.0}/src/runapi/core/contract_gen.py +73 -5
  6. {runapi_core-0.5.1 → runapi_core-0.7.0}/src/runapi/core/files.py +80 -5
  7. {runapi_core-0.5.1 → runapi_core-0.7.0}/src/runapi/core/http_client.py +31 -0
  8. {runapi_core-0.5.1 → runapi_core-0.7.0}/src/runapi/core/provider_client.py +2 -0
  9. runapi_core-0.7.0/src/runapi/core/uploads.py +91 -0
  10. runapi_core-0.7.0/src/runapi/core/version.py +1 -0
  11. {runapi_core-0.5.1 → runapi_core-0.7.0}/tests/test_files.py +42 -0
  12. runapi_core-0.7.0/tests/test_uploads.py +36 -0
  13. runapi_core-0.5.1/src/runapi/core/version.py +0 -1
  14. {runapi_core-0.5.1 → runapi_core-0.7.0}/src/runapi/core/account.py +0 -0
  15. {runapi_core-0.5.1 → runapi_core-0.7.0}/src/runapi/core/auth.py +0 -0
  16. {runapi_core-0.5.1 → runapi_core-0.7.0}/src/runapi/core/config.py +0 -0
  17. {runapi_core-0.5.1 → runapi_core-0.7.0}/src/runapi/core/constants.py +0 -0
  18. {runapi_core-0.5.1 → runapi_core-0.7.0}/src/runapi/core/errors.py +0 -0
  19. {runapi_core-0.5.1 → runapi_core-0.7.0}/src/runapi/core/models.py +0 -0
  20. {runapi_core-0.5.1 → runapi_core-0.7.0}/src/runapi/core/multipart.py +0 -0
  21. {runapi_core-0.5.1 → runapi_core-0.7.0}/src/runapi/core/options.py +0 -0
  22. {runapi_core-0.5.1 → runapi_core-0.7.0}/src/runapi/core/polling.py +0 -0
  23. {runapi_core-0.5.1 → runapi_core-0.7.0}/src/runapi/core/pricing.py +0 -0
  24. {runapi_core-0.5.1 → runapi_core-0.7.0}/src/runapi/core/py.typed +0 -0
  25. {runapi_core-0.5.1 → runapi_core-0.7.0}/src/runapi/core/resource.py +0 -0
  26. {runapi_core-0.5.1 → runapi_core-0.7.0}/src/runapi/core/response.py +0 -0
  27. {runapi_core-0.5.1 → runapi_core-0.7.0}/tests/test_account.py +0 -0
  28. {runapi_core-0.5.1 → runapi_core-0.7.0}/tests/test_auth.py +0 -0
  29. {runapi_core-0.5.1 → runapi_core-0.7.0}/tests/test_config.py +0 -0
  30. {runapi_core-0.5.1 → runapi_core-0.7.0}/tests/test_errors.py +0 -0
  31. {runapi_core-0.5.1 → runapi_core-0.7.0}/tests/test_http_client.py +0 -0
  32. {runapi_core-0.5.1 → runapi_core-0.7.0}/tests/test_models.py +0 -0
  33. {runapi_core-0.5.1 → runapi_core-0.7.0}/tests/test_polling.py +0 -0
  34. {runapi_core-0.5.1 → runapi_core-0.7.0}/tests/test_pricing.py +0 -0
  35. {runapi_core-0.5.1 → runapi_core-0.7.0}/tests/test_resource.py +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: runapi-core
3
- Version: 0.5.1
3
+ Version: 0.7.0
4
4
  Summary: RunAPI core SDK for JavaScript, Python, Ruby, Go, Java, and PHP
5
5
  Project-URL: Homepage, https://runapi.ai/models
6
6
  Project-URL: Documentation, https://runapi.ai/docs/resources/sdks
7
7
  Project-URL: Source, https://github.com/runapi-ai/core-sdk
8
8
  Project-URL: Issues, https://github.com/runapi-ai/core-sdk/issues
9
9
  Project-URL: Changelog, https://github.com/runapi-ai/core-sdk/blob/main/CHANGELOG.md
10
- Project-URL: Release Notes, https://github.com/runapi-ai/core-sdk/releases/tag/python%2Fv0.5.1
10
+ Project-URL: Release Notes, https://github.com/runapi-ai/core-sdk/releases/tag/python%2Fv0.7.0
11
11
  Author-email: RunAPI <contact@runapi.ai>
12
12
  License-Expression: Apache-2.0
13
13
  Keywords: api,core,golang,gradle,java,maven,pypi,python,ruby,runapi,runapi-ai,sdk,typescript
@@ -17,7 +17,7 @@ Description-Content-Type: text/markdown
17
17
 
18
18
  # RunAPI Core Python SDK
19
19
 
20
- The RunAPI Core Python SDK provides shared authentication, HTTP, retry, error, and polling primitives for RunAPI model packages. Install `runapi-core` only when you are building SDK infrastructure or shared Python tooling; application code should normally install a concrete model package such as `runapi-flux-2`.
20
+ The RunAPI Core Python SDK provides shared authentication, HTTP, retry, error, Files, Uploads, and polling primitives for RunAPI model packages. Install `runapi-core` only when you are building SDK infrastructure or shared Python tooling; application code should normally install a concrete model package such as `runapi-flux-2`.
21
21
 
22
22
  ## Install
23
23
 
@@ -79,6 +79,23 @@ inline = files.create(source="iVBORw0KGgo...")
79
79
  print(uploaded.url)
80
80
  ```
81
81
 
82
+ The existing `files.create()` method keeps its temporary URL behavior. Every Provider Client also exposes persistent Files and multipart Uploads:
83
+
84
+ ```python
85
+ file = client.files.create_file(file="./knowledge.pdf", purpose="user_data")
86
+ content = client.files.content(file.id)
87
+
88
+ upload = client.uploads.create(
89
+ bytes=1048576,
90
+ filename="archive.bin",
91
+ mime_type="application/octet-stream",
92
+ )
93
+ part = client.uploads.add_part(upload.id, data="./archive.part-01")
94
+ completed = client.uploads.complete(upload.id, part_ids=[part.id])
95
+ ```
96
+
97
+ Use `files.list()`, `retrieve()`, and `delete_file()` for the remaining File lifecycle. See https://runapi.ai/docs/resources/files for limits and REST examples.
98
+
82
99
  Public SDK docs live at https://runapi.ai/docs/resources/sdks and the model catalog lives at https://runapi.ai/models.
83
100
 
84
101
  ## Universal resources
@@ -1,6 +1,6 @@
1
1
  # RunAPI Core Python SDK
2
2
 
3
- The RunAPI Core Python SDK provides shared authentication, HTTP, retry, error, and polling primitives for RunAPI model packages. Install `runapi-core` only when you are building SDK infrastructure or shared Python tooling; application code should normally install a concrete model package such as `runapi-flux-2`.
3
+ The RunAPI Core Python SDK provides shared authentication, HTTP, retry, error, Files, Uploads, and polling primitives for RunAPI model packages. Install `runapi-core` only when you are building SDK infrastructure or shared Python tooling; application code should normally install a concrete model package such as `runapi-flux-2`.
4
4
 
5
5
  ## Install
6
6
 
@@ -62,6 +62,23 @@ inline = files.create(source="iVBORw0KGgo...")
62
62
  print(uploaded.url)
63
63
  ```
64
64
 
65
+ The existing `files.create()` method keeps its temporary URL behavior. Every Provider Client also exposes persistent Files and multipart Uploads:
66
+
67
+ ```python
68
+ file = client.files.create_file(file="./knowledge.pdf", purpose="user_data")
69
+ content = client.files.content(file.id)
70
+
71
+ upload = client.uploads.create(
72
+ bytes=1048576,
73
+ filename="archive.bin",
74
+ mime_type="application/octet-stream",
75
+ )
76
+ part = client.uploads.add_part(upload.id, data="./archive.part-01")
77
+ completed = client.uploads.complete(upload.id, part_ids=[part.id])
78
+ ```
79
+
80
+ Use `files.list()`, `retrieve()`, and `delete_file()` for the remaining File lifecycle. See https://runapi.ai/docs/resources/files for limits and REST examples.
81
+
65
82
  Public SDK docs live at https://runapi.ai/docs/resources/sdks and the model catalog lives at https://runapi.ai/models.
66
83
 
67
84
  ## Universal resources
@@ -19,7 +19,7 @@ Documentation = "https://runapi.ai/docs/resources/sdks"
19
19
  Source = "https://github.com/runapi-ai/core-sdk"
20
20
  Issues = "https://github.com/runapi-ai/core-sdk/issues"
21
21
  Changelog = "https://github.com/runapi-ai/core-sdk/blob/main/CHANGELOG.md"
22
- "Release Notes" = "https://github.com/runapi-ai/core-sdk/releases/tag/python%2Fv0.5.1"
22
+ "Release Notes" = "https://github.com/runapi-ai/core-sdk/releases/tag/python%2Fv0.7.0"
23
23
 
24
24
  [tool.hatch.version]
25
25
  path = "src/runapi/core/version.py"
@@ -1,7 +1,7 @@
1
1
  """RunAPI Python SDK — shared core.
2
2
 
3
- Configuration, errors, models, HTTP transport, polling, and the file upload
4
- client shared by every per-model-line package.
3
+ Configuration, errors, models, HTTP transport, polling, Files, and Uploads
4
+ shared by every per-model-line package.
5
5
  """
6
6
 
7
7
  from . import config, errors, polling
@@ -25,7 +25,7 @@ from .errors import (
25
25
  ValidationError,
26
26
  error_from_response,
27
27
  )
28
- from .files import FilesClient, UploadResponse
28
+ from .files import DeletedFile, FileList, FileObject, FilesClient, UploadResponse
29
29
  from .http_client import HttpClient
30
30
  from .models import (
31
31
  BaseModel,
@@ -44,6 +44,7 @@ from .response import ApiResponse, ResponseHeaders
44
44
  from .resource import Resource
45
45
  from .pricing import PriceQuote, PriceQuoteResponse, PriceSchedule, PriceScheduleListResponse, Pricing, PricingClient
46
46
  from .provider_client import ProviderClient
47
+ from .uploads import UploadObject, UploadPart, Uploads
47
48
  from .version import __version__
48
49
 
49
50
  __all__ = [
@@ -93,6 +94,12 @@ __all__ = [
93
94
  "Resource",
94
95
  "FilesClient",
95
96
  "UploadResponse",
97
+ "DeletedFile",
98
+ "FileList",
99
+ "FileObject",
100
+ "UploadObject",
101
+ "UploadPart",
102
+ "Uploads",
96
103
  "Pricing",
97
104
  "PricingClient",
98
105
  "PriceSchedule",
@@ -38,6 +38,24 @@ CONTRACT = {
38
38
  "text-to-speech-turbo-v2.5": {}
39
39
  }
40
40
  },
41
+ "fish-audio/create-voice": {
42
+ "models": [],
43
+ "fields_by_model": {
44
+ "_": {}
45
+ }
46
+ },
47
+ "fish-audio/get-voice": {
48
+ "models": [],
49
+ "fields_by_model": {
50
+ "_": {}
51
+ }
52
+ },
53
+ "fish-audio/list-voices": {
54
+ "models": [],
55
+ "fields_by_model": {
56
+ "_": {}
57
+ }
58
+ },
41
59
  "fish-audio/text-to-speech": {
42
60
  "models": ["s1", "s2-pro", "s2.1-pro"],
43
61
  "fields_by_model": {
@@ -320,9 +338,10 @@ CONTRACT = {
320
338
  }
321
339
  },
322
340
  "grok-imagine/edit-image": {
323
- "models": ["grok-imagine-edit-image"],
341
+ "models": ["grok-imagine-edit-image", "grok-imagine-image-2-0"],
324
342
  "fields_by_model": {
325
- "grok-imagine-edit-image": {}
343
+ "grok-imagine-edit-image": {},
344
+ "grok-imagine-image-2-0": {}
326
345
  }
327
346
  },
328
347
  "grok-imagine/extend": {
@@ -367,9 +386,20 @@ CONTRACT = {
367
386
  }
368
387
  }
369
388
  },
389
+ "grok-imagine/segment-map": {
390
+ "models": ["grok-imagine-image-2-0"],
391
+ "fields_by_model": {
392
+ "grok-imagine-image-2-0": {}
393
+ }
394
+ },
370
395
  "grok-imagine/text-to-image": {
371
- "models": ["grok-imagine-text-to-image"],
396
+ "models": ["grok-imagine-image-2-0", "grok-imagine-text-to-image"],
372
397
  "fields_by_model": {
398
+ "grok-imagine-image-2-0": {
399
+ "aspect_ratio": {
400
+ "enum": ["1:1", "2:3", "3:2", "16:9", "9:16"]
401
+ }
402
+ },
373
403
  "grok-imagine-text-to-image": {
374
404
  "aspect_ratio": {
375
405
  "enum": ["2:3", "3:2", "1:1", "16:9", "9:16"]
@@ -685,6 +715,33 @@ CONTRACT = {
685
715
  "kling-v1-avatar-standard": {}
686
716
  }
687
717
  },
718
+ "kling/edit-video": {
719
+ "models": ["kling-v3-omni-edit", "kling-v3-omni-reference"],
720
+ "fields_by_model": {
721
+ "kling-v3-omni-edit": {
722
+ "aspect_ratio": {
723
+ "enum": ["auto", "16:9", "9:16", "1:1"]
724
+ },
725
+ "duration_seconds": {
726
+ "enum": [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
727
+ },
728
+ "output_resolution": {
729
+ "enum": ["720p", "1080p", "4k"]
730
+ }
731
+ },
732
+ "kling-v3-omni-reference": {
733
+ "aspect_ratio": {
734
+ "enum": ["auto", "16:9", "9:16", "1:1"]
735
+ },
736
+ "duration_seconds": {
737
+ "enum": [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
738
+ },
739
+ "output_resolution": {
740
+ "enum": ["720p", "1080p", "4k"]
741
+ }
742
+ }
743
+ }
744
+ },
688
745
  "kling/extend-video": {
689
746
  "models": ["kling-v2.5-turbo-image-to-video-pro", "kling-v2.5-turbo-text-to-video-pro"],
690
747
  "fields_by_model": {
@@ -794,7 +851,7 @@ CONTRACT = {
794
851
  }
795
852
  },
796
853
  "kling/text-to-video": {
797
- "models": ["kling-3.0", "kling-o1", "kling-v2.1-master-text-to-video", "kling-v2.5-turbo-text-to-video-pro", "kling-v2.6", "kling-v3-omni", "kling-v3-turbo-text-to-video"],
854
+ "models": ["kling-3.0", "kling-o1", "kling-v2.1-master-text-to-video", "kling-v2.5-turbo-text-to-video-pro", "kling-v2.6", "kling-v3-omni", "kling-v3-omni-reference", "kling-v3-turbo-text-to-video"],
798
855
  "fields_by_model": {
799
856
  "kling-3.0": {
800
857
  "aspect_ratio": {
@@ -859,6 +916,17 @@ CONTRACT = {
859
916
  "enum": ["720p", "1080p", "4k"]
860
917
  }
861
918
  },
919
+ "kling-v3-omni-reference": {
920
+ "aspect_ratio": {
921
+ "enum": ["16:9", "9:16", "1:1"]
922
+ },
923
+ "duration_seconds": {
924
+ "enum": [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
925
+ },
926
+ "output_resolution": {
927
+ "enum": ["720p", "1080p", "4k"]
928
+ }
929
+ },
862
930
  "kling-v3-turbo-text-to-video": {
863
931
  "aspect_ratio": {
864
932
  "enum": ["16:9", "9:16", "1:1"]
@@ -1387,7 +1455,7 @@ CONTRACT = {
1387
1455
  "enum": ["mp4", "mov"]
1388
1456
  },
1389
1457
  "output_resolution": {
1390
- "enum": ["480p", "720p"]
1458
+ "enum": ["480p", "720p", "1080p"]
1391
1459
  }
1392
1460
  },
1393
1461
  "seedance-v1-pro": {
@@ -1,8 +1,4 @@
1
- """File upload client.
2
-
3
- Shared infrastructure for every model line that takes file inputs: upload a
4
- local file or register a remote URL and receive a usable file reference.
5
- """
1
+ """Persistent File lifecycle and temporary URL upload client."""
6
2
 
7
3
  from __future__ import annotations
8
4
 
@@ -10,10 +6,12 @@ import base64
10
6
  import hashlib
11
7
  import os
12
8
  from typing import Any, Mapping, Optional, Union
9
+ from urllib.parse import quote, urlencode
13
10
 
14
11
  from . import auth
15
12
  from .http_client import HttpClient
16
13
  from .models import BaseModel, optional, required
14
+ from .multipart import MultipartBody, MultipartFile
17
15
  from .options import ClientOptions, RequestOptions
18
16
  from .resource import Resource
19
17
 
@@ -27,10 +25,35 @@ class UploadResponse(BaseModel):
27
25
  expires_at = required(str)
28
26
 
29
27
 
28
+ class FileObject(BaseModel):
29
+ id = required(str)
30
+ object = required(str)
31
+ bytes = required(int)
32
+ created_at = required(int)
33
+ expires_at = optional(int)
34
+ filename = required(str)
35
+ purpose = required(str)
36
+
37
+
38
+ class FileList(BaseModel):
39
+ object = required(str)
40
+ data = required([FileObject])
41
+ first_id = optional(str)
42
+ last_id = optional(str)
43
+ has_more = required(bool)
44
+
45
+
46
+ class DeletedFile(BaseModel):
47
+ id = required(str)
48
+ object = required(str)
49
+ deleted = required(bool)
50
+
51
+
30
52
  class FilesClient(Resource):
31
53
  ENDPOINT = "/api/v1/files"
32
54
  PREPARE_ENDPOINT = f"{ENDPOINT}/prepare"
33
55
  CONFIRM_ENDPOINT = f"{ENDPOINT}/confirm"
56
+ PROTOCOL_ENDPOINT = "/v1/files"
34
57
 
35
58
  RESPONSE_CLASS = UploadResponse
36
59
 
@@ -74,6 +97,52 @@ class FilesClient(Resource):
74
97
  )
75
98
  return self._request("post", self.ENDPOINT, body=body, options=options)
76
99
 
100
+ def create_file(
101
+ self,
102
+ file: Any,
103
+ purpose: str = "user_data",
104
+ file_name: Optional[str] = None,
105
+ options: Optional[RequestOptions] = None,
106
+ ) -> FileObject:
107
+ path = self._file_path(file)
108
+ body = MultipartBody(
109
+ fields={"purpose": purpose},
110
+ files={"file": MultipartFile(path=path, filename=file_name or os.path.basename(path))},
111
+ )
112
+ return self._request(
113
+ "post", self.PROTOCOL_ENDPOINT, body=body, options=options, response_class=FileObject
114
+ )
115
+
116
+ def list(
117
+ self,
118
+ *,
119
+ after: Optional[str] = None,
120
+ limit: Optional[int] = None,
121
+ order: Optional[str] = None,
122
+ purpose: Optional[str] = None,
123
+ options: Optional[RequestOptions] = None,
124
+ ) -> FileList:
125
+ query = urlencode(self._compact_params({
126
+ "after": after, "limit": limit, "order": order, "purpose": purpose
127
+ }))
128
+ path = self.PROTOCOL_ENDPOINT + (f"?{query}" if query else "")
129
+ return self._request("get", path, options=options, response_class=FileList)
130
+
131
+ def retrieve(self, file_id: str, options: Optional[RequestOptions] = None) -> FileObject:
132
+ return self._request(
133
+ "get", self._protocol_file_path(file_id), options=options, response_class=FileObject
134
+ )
135
+
136
+ def content(self, file_id: str, options: Optional[RequestOptions] = None) -> bytes:
137
+ return self._http.request_bytes(
138
+ "get", f"{self._protocol_file_path(file_id)}/content", options=options
139
+ )
140
+
141
+ def delete_file(self, file_id: str, options: Optional[RequestOptions] = None) -> DeletedFile:
142
+ return self._request(
143
+ "delete", self._protocol_file_path(file_id), options=options, response_class=DeletedFile
144
+ )
145
+
77
146
  def _upload_direct(self, file: Any, file_name: Optional[str], options: Optional[RequestOptions]) -> Any:
78
147
  """Local files upload straight to storage: ask for a pre-authorized target,
79
148
  PUT the bytes there (never through the API), then confirm. The caller still
@@ -139,3 +208,9 @@ class FilesClient(Resource):
139
208
  if name:
140
209
  return name
141
210
  raise ValueError("file must be a file path or a file-like object with a name")
211
+
212
+ @classmethod
213
+ def _protocol_file_path(cls, file_id: str) -> str:
214
+ if not file_id.strip():
215
+ raise ValueError("file_id is required")
216
+ return f"{cls.PROTOCOL_ENDPOINT}/{quote(file_id, safe='')}"
@@ -115,6 +115,37 @@ class HttpClient:
115
115
  if not response.is_success:
116
116
  raise error_from_response(response)
117
117
 
118
+ def request_bytes(
119
+ self,
120
+ method: str,
121
+ path: str,
122
+ options: Optional[RequestOptions] = None,
123
+ ) -> bytes:
124
+ """Return a successful response body without text decoding."""
125
+ max_retries = self._options.max_retries
126
+ if options is not None and options.max_retries is not None:
127
+ max_retries = options.max_retries
128
+ headers = {str(key): str(value) for key, value in (options.headers or {}).items()} if options else {}
129
+ timeout = options.timeout if (options and options.timeout is not None) else httpx.USE_CLIENT_DEFAULT
130
+ method = method.upper()
131
+ retries = 0
132
+
133
+ while True:
134
+ try:
135
+ response = self._client.request(method, path, headers=headers, timeout=timeout)
136
+ except httpx.TimeoutException as exc:
137
+ raise TimeoutError(str(exc))
138
+ except httpx.TransportError as exc:
139
+ raise NetworkError(str(exc))
140
+ if response.is_success:
141
+ return response.content
142
+ error = error_from_response(response)
143
+ if self._retryable(method, response.status_code) and retries < max_retries:
144
+ retries += 1
145
+ time.sleep(self._retry_delay(retries, error))
146
+ continue
147
+ raise error
148
+
118
149
  def close(self) -> None:
119
150
  self._client.close()
120
151
  self._upload_client.close()
@@ -10,6 +10,7 @@ from .files import FilesClient
10
10
  from .http_client import HttpClient
11
11
  from .options import ClientOptions
12
12
  from .pricing import Pricing
13
+ from .uploads import Uploads
13
14
 
14
15
 
15
16
  class ProviderClient:
@@ -22,3 +23,4 @@ class ProviderClient:
22
23
  self.files = FilesClient(http=self._http)
23
24
  self.account = Account(self._http)
24
25
  self.pricing = Pricing(self._http)
26
+ self.uploads = Uploads(self._http)
@@ -0,0 +1,91 @@
1
+ """Multipart Upload lifecycle client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Any, Optional, Sequence
7
+ from urllib.parse import quote
8
+
9
+ from .files import FileObject, FilesClient
10
+ from .models import BaseModel, optional, required
11
+ from .multipart import MultipartBody, MultipartFile
12
+ from .options import RequestOptions
13
+ from .resource import Resource
14
+
15
+
16
+ class UploadObject(BaseModel):
17
+ id = required(str)
18
+ object = required(str)
19
+ bytes = required(int)
20
+ created_at = required(int)
21
+ filename = required(str)
22
+ purpose = required(str)
23
+ status = required(str)
24
+ expires_at = required(int)
25
+ file = optional(FileObject)
26
+
27
+
28
+ class UploadPart(BaseModel):
29
+ id = required(str)
30
+ object = required(str)
31
+ created_at = required(int)
32
+ upload_id = required(str)
33
+
34
+
35
+ class Uploads(Resource):
36
+ ENDPOINT = "/v1/uploads"
37
+ RESPONSE_CLASS = UploadObject
38
+
39
+ def create(
40
+ self,
41
+ *,
42
+ bytes: int,
43
+ filename: str,
44
+ mime_type: str,
45
+ purpose: str = "user_data",
46
+ options: Optional[RequestOptions] = None,
47
+ ) -> UploadObject:
48
+ return self._request("post", self.ENDPOINT, body={
49
+ "bytes": bytes,
50
+ "filename": filename,
51
+ "mime_type": mime_type,
52
+ "purpose": purpose,
53
+ }, options=options)
54
+
55
+ def add_part(
56
+ self,
57
+ upload_id: str,
58
+ data: Any,
59
+ file_name: Optional[str] = None,
60
+ options: Optional[RequestOptions] = None,
61
+ ) -> UploadPart:
62
+ path = FilesClient._file_path(data)
63
+ body = MultipartBody(files={
64
+ "data": MultipartFile(path=path, filename=file_name or os.path.basename(path))
65
+ })
66
+ return self._request(
67
+ "post", f"{self._upload_path(upload_id)}/parts", body=body,
68
+ options=options, response_class=UploadPart,
69
+ )
70
+
71
+ def complete(
72
+ self,
73
+ upload_id: str,
74
+ part_ids: Sequence[str],
75
+ options: Optional[RequestOptions] = None,
76
+ ) -> UploadObject:
77
+ return self._request(
78
+ "post", f"{self._upload_path(upload_id)}/complete",
79
+ body={"part_ids": list(part_ids)}, options=options,
80
+ )
81
+
82
+ def cancel(self, upload_id: str, options: Optional[RequestOptions] = None) -> UploadObject:
83
+ return self._request(
84
+ "post", f"{self._upload_path(upload_id)}/cancel", body={}, options=options
85
+ )
86
+
87
+ @classmethod
88
+ def _upload_path(cls, upload_id: str) -> str:
89
+ if not upload_id.strip():
90
+ raise ValueError("upload_id is required")
91
+ return f"{cls.ENDPOINT}/{quote(upload_id, safe='')}"
@@ -0,0 +1 @@
1
+ __version__ = "0.7.0"
@@ -176,3 +176,45 @@ def test_create_rejects_blank_source():
176
176
  client.create(source="")
177
177
  with pytest.raises(ValueError):
178
178
  client.create(source=" ")
179
+
180
+
181
+ def test_protocol_file_lifecycle_keeps_binary_content(tmp_path):
182
+ path = tmp_path / "input.bin"
183
+ path.write_bytes(b"\x00\xff\x01")
184
+ responses = iter([
185
+ {"id": "file_123", "object": "file", "bytes": 3, "created_at": 1,
186
+ "filename": "input.bin", "purpose": "user_data"},
187
+ {"object": "list", "data": [], "has_more": False},
188
+ {"id": "file_123", "object": "file", "bytes": 3, "created_at": 1,
189
+ "filename": "input.bin", "purpose": "user_data"},
190
+ {"id": "file_123", "object": "file", "deleted": True},
191
+ ])
192
+
193
+ class ProtocolHttp:
194
+ def __init__(self):
195
+ self.calls = []
196
+
197
+ def request(self, method, request_path, body=None, options=None):
198
+ self.calls.append((method, request_path, body))
199
+ return next(responses)
200
+
201
+ def request_bytes(self, method, request_path, options=None):
202
+ self.calls.append((method, request_path, None))
203
+ return b"\x00\xff\x01"
204
+
205
+ fake = ProtocolHttp()
206
+ files = FilesClient(http=fake)
207
+
208
+ created = files.create_file(path)
209
+ listed = files.list(limit=1, order="asc")
210
+ retrieved = files.retrieve("file_123")
211
+ content = files.content("file_123")
212
+ deleted = files.delete_file("file_123")
213
+
214
+ assert created.id == retrieved.id == deleted.id == "file_123"
215
+ assert listed.has_more is False
216
+ assert fake.calls[0][1] == "/v1/files"
217
+ assert fake.calls[1][1] == "/v1/files?limit=1&order=asc"
218
+ assert fake.calls[3][1] == "/v1/files/file_123/content"
219
+ assert fake.calls[4][0] == "delete"
220
+ assert content == b"\x00\xff\x01"
@@ -0,0 +1,36 @@
1
+ from runapi.core.multipart import MultipartBody
2
+ from runapi.core.uploads import Uploads
3
+
4
+
5
+ class FakeHttp:
6
+ def __init__(self):
7
+ self.calls = []
8
+
9
+ def request(self, method, path, body=None, options=None):
10
+ self.calls.append((method, path, body))
11
+ if path.endswith("/parts"):
12
+ return {"id": "part_123", "object": "upload.part", "created_at": 1, "upload_id": "upload_123"}
13
+ return {
14
+ "id": "upload_123", "object": "upload", "bytes": 3, "created_at": 1,
15
+ "filename": "data.bin", "purpose": "user_data", "status": "pending", "expires_at": 2,
16
+ }
17
+
18
+
19
+ def test_upload_lifecycle(tmp_path):
20
+ part = tmp_path / "part.bin"
21
+ part.write_bytes(b"abc")
22
+ fake = FakeHttp()
23
+ uploads = Uploads(fake)
24
+
25
+ uploads.create(bytes=3, filename="data.bin", mime_type="application/octet-stream")
26
+ uploads.add_part("upload_123", part)
27
+ uploads.complete("upload_123", ["part_123"])
28
+ uploads.cancel("upload_123")
29
+
30
+ assert fake.calls[0][1:] == ("/v1/uploads", {
31
+ "bytes": 3, "filename": "data.bin", "mime_type": "application/octet-stream", "purpose": "user_data"
32
+ })
33
+ assert fake.calls[1][1] == "/v1/uploads/upload_123/parts"
34
+ assert isinstance(fake.calls[1][2], MultipartBody)
35
+ assert fake.calls[2][2] == {"part_ids": ["part_123"]}
36
+ assert fake.calls[3][1] == "/v1/uploads/upload_123/cancel"
@@ -1 +0,0 @@
1
- __version__ = "0.5.1"