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.
- {runapi_core-0.5.1 → runapi_core-0.7.0}/PKG-INFO +20 -3
- {runapi_core-0.5.1 → runapi_core-0.7.0}/README.md +18 -1
- {runapi_core-0.5.1 → runapi_core-0.7.0}/pyproject.toml +1 -1
- {runapi_core-0.5.1 → runapi_core-0.7.0}/src/runapi/core/__init__.py +10 -3
- {runapi_core-0.5.1 → runapi_core-0.7.0}/src/runapi/core/contract_gen.py +73 -5
- {runapi_core-0.5.1 → runapi_core-0.7.0}/src/runapi/core/files.py +80 -5
- {runapi_core-0.5.1 → runapi_core-0.7.0}/src/runapi/core/http_client.py +31 -0
- {runapi_core-0.5.1 → runapi_core-0.7.0}/src/runapi/core/provider_client.py +2 -0
- runapi_core-0.7.0/src/runapi/core/uploads.py +91 -0
- runapi_core-0.7.0/src/runapi/core/version.py +1 -0
- {runapi_core-0.5.1 → runapi_core-0.7.0}/tests/test_files.py +42 -0
- runapi_core-0.7.0/tests/test_uploads.py +36 -0
- runapi_core-0.5.1/src/runapi/core/version.py +0 -1
- {runapi_core-0.5.1 → runapi_core-0.7.0}/src/runapi/core/account.py +0 -0
- {runapi_core-0.5.1 → runapi_core-0.7.0}/src/runapi/core/auth.py +0 -0
- {runapi_core-0.5.1 → runapi_core-0.7.0}/src/runapi/core/config.py +0 -0
- {runapi_core-0.5.1 → runapi_core-0.7.0}/src/runapi/core/constants.py +0 -0
- {runapi_core-0.5.1 → runapi_core-0.7.0}/src/runapi/core/errors.py +0 -0
- {runapi_core-0.5.1 → runapi_core-0.7.0}/src/runapi/core/models.py +0 -0
- {runapi_core-0.5.1 → runapi_core-0.7.0}/src/runapi/core/multipart.py +0 -0
- {runapi_core-0.5.1 → runapi_core-0.7.0}/src/runapi/core/options.py +0 -0
- {runapi_core-0.5.1 → runapi_core-0.7.0}/src/runapi/core/polling.py +0 -0
- {runapi_core-0.5.1 → runapi_core-0.7.0}/src/runapi/core/pricing.py +0 -0
- {runapi_core-0.5.1 → runapi_core-0.7.0}/src/runapi/core/py.typed +0 -0
- {runapi_core-0.5.1 → runapi_core-0.7.0}/src/runapi/core/resource.py +0 -0
- {runapi_core-0.5.1 → runapi_core-0.7.0}/src/runapi/core/response.py +0 -0
- {runapi_core-0.5.1 → runapi_core-0.7.0}/tests/test_account.py +0 -0
- {runapi_core-0.5.1 → runapi_core-0.7.0}/tests/test_auth.py +0 -0
- {runapi_core-0.5.1 → runapi_core-0.7.0}/tests/test_config.py +0 -0
- {runapi_core-0.5.1 → runapi_core-0.7.0}/tests/test_errors.py +0 -0
- {runapi_core-0.5.1 → runapi_core-0.7.0}/tests/test_http_client.py +0 -0
- {runapi_core-0.5.1 → runapi_core-0.7.0}/tests/test_models.py +0 -0
- {runapi_core-0.5.1 → runapi_core-0.7.0}/tests/test_polling.py +0 -0
- {runapi_core-0.5.1 → runapi_core-0.7.0}/tests/test_pricing.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
|
4
|
-
|
|
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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|