runapi-core 0.1.6__tar.gz → 0.2.1__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.1.6 → runapi_core-0.2.1}/PKG-INFO +9 -5
- runapi_core-0.2.1/pyproject.toml +37 -0
- {runapi_core-0.1.6 → runapi_core-0.2.1}/src/runapi/core/contract_gen.py +84 -4
- {runapi_core-0.1.6 → runapi_core-0.2.1}/src/runapi/core/errors.py +26 -0
- {runapi_core-0.1.6 → runapi_core-0.2.1}/src/runapi/core/resource.py +22 -1
- runapi_core-0.2.1/src/runapi/core/version.py +1 -0
- {runapi_core-0.1.6 → runapi_core-0.2.1}/tests/test_errors.py +31 -0
- {runapi_core-0.1.6 → runapi_core-0.2.1}/tests/test_resource.py +24 -0
- runapi_core-0.1.6/pyproject.toml +0 -33
- runapi_core-0.1.6/src/runapi/core/version.py +0 -1
- {runapi_core-0.1.6 → runapi_core-0.2.1}/README.md +0 -0
- {runapi_core-0.1.6 → runapi_core-0.2.1}/src/runapi/core/__init__.py +0 -0
- {runapi_core-0.1.6 → runapi_core-0.2.1}/src/runapi/core/auth.py +0 -0
- {runapi_core-0.1.6 → runapi_core-0.2.1}/src/runapi/core/config.py +0 -0
- {runapi_core-0.1.6 → runapi_core-0.2.1}/src/runapi/core/constants.py +0 -0
- {runapi_core-0.1.6 → runapi_core-0.2.1}/src/runapi/core/files.py +0 -0
- {runapi_core-0.1.6 → runapi_core-0.2.1}/src/runapi/core/http_client.py +0 -0
- {runapi_core-0.1.6 → runapi_core-0.2.1}/src/runapi/core/models.py +0 -0
- {runapi_core-0.1.6 → runapi_core-0.2.1}/src/runapi/core/multipart.py +0 -0
- {runapi_core-0.1.6 → runapi_core-0.2.1}/src/runapi/core/options.py +0 -0
- {runapi_core-0.1.6 → runapi_core-0.2.1}/src/runapi/core/polling.py +0 -0
- {runapi_core-0.1.6 → runapi_core-0.2.1}/src/runapi/core/py.typed +0 -0
- {runapi_core-0.1.6 → runapi_core-0.2.1}/src/runapi/core/response.py +0 -0
- {runapi_core-0.1.6 → runapi_core-0.2.1}/tests/test_auth.py +0 -0
- {runapi_core-0.1.6 → runapi_core-0.2.1}/tests/test_config.py +0 -0
- {runapi_core-0.1.6 → runapi_core-0.2.1}/tests/test_files.py +0 -0
- {runapi_core-0.1.6 → runapi_core-0.2.1}/tests/test_http_client.py +0 -0
- {runapi_core-0.1.6 → runapi_core-0.2.1}/tests/test_models.py +0 -0
- {runapi_core-0.1.6 → runapi_core-0.2.1}/tests/test_polling.py +0 -0
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: runapi-core
|
|
3
|
-
Version: 0.1
|
|
4
|
-
Summary:
|
|
5
|
-
Project-URL: Homepage, https://runapi.ai
|
|
6
|
-
Project-URL: Documentation, https://runapi.ai/docs#
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: RunAPI core SDK for JavaScript, Python, Ruby, Go, Java, and PHP
|
|
5
|
+
Project-URL: Homepage, https://runapi.ai/models
|
|
6
|
+
Project-URL: Documentation, https://runapi.ai/docs#runapi-sdks
|
|
7
|
+
Project-URL: Source, https://github.com/runapi-ai/core-sdk
|
|
8
|
+
Project-URL: Issues, https://github.com/runapi-ai/core-sdk/issues
|
|
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.2.1
|
|
7
11
|
Author-email: RunAPI <contact@runapi.ai>
|
|
8
12
|
License-Expression: Apache-2.0
|
|
9
|
-
Keywords:
|
|
13
|
+
Keywords: api,core,golang,gradle,java,maven,pypi,python,ruby,runapi,runapi-ai,sdk,typescript
|
|
10
14
|
Requires-Python: >=3.9
|
|
11
15
|
Requires-Dist: httpx>=0.27
|
|
12
16
|
Description-Content-Type: text/markdown
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "runapi-core"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "RunAPI core SDK for JavaScript, Python, Ruby, Go, Java, and PHP"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
authors = [{ name = "RunAPI", email = "contact@runapi.ai" }]
|
|
13
|
+
keywords = ["runapi", "runapi-ai", "core", "api", "sdk", "typescript", "python", "ruby", "golang", "java", "pypi", "maven", "gradle"]
|
|
14
|
+
dependencies = ["httpx>=0.27"]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://runapi.ai/models"
|
|
18
|
+
Documentation = "https://runapi.ai/docs#runapi-sdks"
|
|
19
|
+
Source = "https://github.com/runapi-ai/core-sdk"
|
|
20
|
+
Issues = "https://github.com/runapi-ai/core-sdk/issues"
|
|
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.2.1"
|
|
23
|
+
|
|
24
|
+
[tool.hatch.version]
|
|
25
|
+
path = "src/runapi/core/version.py"
|
|
26
|
+
|
|
27
|
+
[tool.hatch.build.targets.wheel]
|
|
28
|
+
packages = ["src/runapi"]
|
|
29
|
+
|
|
30
|
+
[tool.uv]
|
|
31
|
+
package = true
|
|
32
|
+
|
|
33
|
+
[dependency-groups]
|
|
34
|
+
dev = ["pytest>=8", "httpx>=0.27"]
|
|
35
|
+
|
|
36
|
+
[tool.runapi]
|
|
37
|
+
slug = "core"
|
|
@@ -38,6 +38,13 @@ CONTRACT = {
|
|
|
38
38
|
"text-to-speech-turbo-v2.5": {}
|
|
39
39
|
}
|
|
40
40
|
},
|
|
41
|
+
"fish-audio/text-to-speech": {
|
|
42
|
+
"models": ["s1", "s2-pro"],
|
|
43
|
+
"fields_by_model": {
|
|
44
|
+
"s1": {},
|
|
45
|
+
"s2-pro": {}
|
|
46
|
+
}
|
|
47
|
+
},
|
|
41
48
|
"flux-2/remix-image": {
|
|
42
49
|
"models": ["flux-2-flex-remix-image", "flux-2-pro-remix-image"],
|
|
43
50
|
"fields_by_model": {
|
|
@@ -114,8 +121,16 @@ CONTRACT = {
|
|
|
114
121
|
}
|
|
115
122
|
},
|
|
116
123
|
"gemini-omni/text-to-video": {
|
|
117
|
-
"models": ["gemini-omni-text-to-video"],
|
|
124
|
+
"models": ["gemini-omni-flash-preview", "gemini-omni-text-to-video"],
|
|
118
125
|
"fields_by_model": {
|
|
126
|
+
"gemini-omni-flash-preview": {
|
|
127
|
+
"aspect_ratio": {
|
|
128
|
+
"enum": ["16:9", "9:16"]
|
|
129
|
+
},
|
|
130
|
+
"output_resolution": {
|
|
131
|
+
"enum": ["720p"]
|
|
132
|
+
}
|
|
133
|
+
},
|
|
119
134
|
"gemini-omni-text-to-video": {
|
|
120
135
|
"aspect_ratio": {
|
|
121
136
|
"enum": ["16:9", "9:16"]
|
|
@@ -129,6 +144,13 @@ CONTRACT = {
|
|
|
129
144
|
}
|
|
130
145
|
}
|
|
131
146
|
},
|
|
147
|
+
"gemini-tts/text-to-speech": {
|
|
148
|
+
"models": ["gemini-2.5-pro-tts", "gemini-3.1-flash-tts"],
|
|
149
|
+
"fields_by_model": {
|
|
150
|
+
"gemini-2.5-pro-tts": {},
|
|
151
|
+
"gemini-3.1-flash-tts": {}
|
|
152
|
+
}
|
|
153
|
+
},
|
|
132
154
|
"gpt-4o-image/text-to-image": {
|
|
133
155
|
"models": ["gpt-4o-image"],
|
|
134
156
|
"fields_by_model": {
|
|
@@ -665,6 +687,12 @@ CONTRACT = {
|
|
|
665
687
|
}
|
|
666
688
|
}
|
|
667
689
|
},
|
|
690
|
+
"midjourney/shorten-prompt": {
|
|
691
|
+
"models": [],
|
|
692
|
+
"fields_by_model": {
|
|
693
|
+
"_": {}
|
|
694
|
+
}
|
|
695
|
+
},
|
|
668
696
|
"midjourney/text-to-image": {
|
|
669
697
|
"models": ["midjourney-v8.1"],
|
|
670
698
|
"fields_by_model": {
|
|
@@ -751,6 +779,23 @@ CONTRACT = {
|
|
|
751
779
|
"omnihuman-1.5-subject-detection": {}
|
|
752
780
|
}
|
|
753
781
|
},
|
|
782
|
+
"openai-tts/text-to-speech": {
|
|
783
|
+
"models": ["tts-1", "tts-1-hd"],
|
|
784
|
+
"fields_by_model": {
|
|
785
|
+
"tts-1": {},
|
|
786
|
+
"tts-1-hd": {}
|
|
787
|
+
}
|
|
788
|
+
},
|
|
789
|
+
"producer/text-to-music": {
|
|
790
|
+
"models": ["fuzz-2.0"],
|
|
791
|
+
"fields_by_model": {
|
|
792
|
+
"fuzz-2.0": {
|
|
793
|
+
"vocal_mode": {
|
|
794
|
+
"enum": ["exact_lyrics", "instrumental"]
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
},
|
|
754
799
|
"qwen-2/edit-image": {
|
|
755
800
|
"models": ["qwen-2-edit-image"],
|
|
756
801
|
"fields_by_model": {
|
|
@@ -906,7 +951,7 @@ CONTRACT = {
|
|
|
906
951
|
}
|
|
907
952
|
},
|
|
908
953
|
"seedream/edit-image": {
|
|
909
|
-
"models": ["seedream-4.5-edit", "seedream-5-lite-edit", "seedream-v4-edit"],
|
|
954
|
+
"models": ["seedream-4.5-edit", "seedream-5-lite-edit", "seedream-5-pro-edit", "seedream-v4-edit"],
|
|
910
955
|
"fields_by_model": {
|
|
911
956
|
"seedream-4.5-edit": {
|
|
912
957
|
"aspect_ratio": {
|
|
@@ -920,6 +965,20 @@ CONTRACT = {
|
|
|
920
965
|
"aspect_ratio": {
|
|
921
966
|
"enum": ["1:1", "4:3", "3:4", "16:9", "9:16", "2:3", "3:2", "21:9"]
|
|
922
967
|
},
|
|
968
|
+
"output_format": {
|
|
969
|
+
"enum": ["png", "jpeg"]
|
|
970
|
+
},
|
|
971
|
+
"output_quality": {
|
|
972
|
+
"enum": ["basic", "high"]
|
|
973
|
+
}
|
|
974
|
+
},
|
|
975
|
+
"seedream-5-pro-edit": {
|
|
976
|
+
"aspect_ratio": {
|
|
977
|
+
"enum": ["1:1", "4:3", "3:4", "16:9", "9:16", "2:3", "3:2", "21:9"]
|
|
978
|
+
},
|
|
979
|
+
"output_format": {
|
|
980
|
+
"enum": ["png", "jpeg"]
|
|
981
|
+
},
|
|
923
982
|
"output_quality": {
|
|
924
983
|
"enum": ["basic", "high"]
|
|
925
984
|
}
|
|
@@ -938,7 +997,7 @@ CONTRACT = {
|
|
|
938
997
|
}
|
|
939
998
|
},
|
|
940
999
|
"seedream/text-to-image": {
|
|
941
|
-
"models": ["seedream-4.5-text-to-image", "seedream-5-lite-text-to-image", "seedream-v4-text-to-image"],
|
|
1000
|
+
"models": ["seedream-4.5-text-to-image", "seedream-5-lite-text-to-image", "seedream-5-pro-text-to-image", "seedream-v4-text-to-image"],
|
|
942
1001
|
"fields_by_model": {
|
|
943
1002
|
"seedream-4.5-text-to-image": {
|
|
944
1003
|
"aspect_ratio": {
|
|
@@ -952,6 +1011,20 @@ CONTRACT = {
|
|
|
952
1011
|
"aspect_ratio": {
|
|
953
1012
|
"enum": ["1:1", "4:3", "3:4", "16:9", "9:16", "2:3", "3:2", "21:9"]
|
|
954
1013
|
},
|
|
1014
|
+
"output_format": {
|
|
1015
|
+
"enum": ["png", "jpeg"]
|
|
1016
|
+
},
|
|
1017
|
+
"output_quality": {
|
|
1018
|
+
"enum": ["basic", "high"]
|
|
1019
|
+
}
|
|
1020
|
+
},
|
|
1021
|
+
"seedream-5-pro-text-to-image": {
|
|
1022
|
+
"aspect_ratio": {
|
|
1023
|
+
"enum": ["1:1", "4:3", "3:4", "16:9", "9:16", "2:3", "3:2", "21:9"]
|
|
1024
|
+
},
|
|
1025
|
+
"output_format": {
|
|
1026
|
+
"enum": ["png", "jpeg"]
|
|
1027
|
+
},
|
|
955
1028
|
"output_quality": {
|
|
956
1029
|
"enum": ["basic", "high"]
|
|
957
1030
|
}
|
|
@@ -1294,7 +1367,14 @@ CONTRACT = {
|
|
|
1294
1367
|
"suno/separate-audio-stems": {
|
|
1295
1368
|
"models": [],
|
|
1296
1369
|
"fields_by_model": {
|
|
1297
|
-
"_": {
|
|
1370
|
+
"_": {
|
|
1371
|
+
"stem_name": {
|
|
1372
|
+
"enum": ["Lead Vocal", "Drum Kit", "Kick", "Snare", "Risers", "Bass", "Backing Vocals", "Piano", "Electric Guitar", "Percussion", "String Section", "Synth", "Acoustic Guitar", "Sound Effects", "Synth Pad", "Synth Bass", "Guitar", "Brass Section", "Organ", "Electronic Drum Kit", "Lead Electric Guitar", "Synth Keys", "Rhythm Electric Guitar", "Electric Piano", "Upright Bass", "Keyboards", "Distorted Electric Guitar", "Synth Strings", "Synth Lead", "Woodwinds", "Rhythm Acoustic Guitar", "Flute", "Harp", "Tambourine", "Trumpet", "Arpeggiator", "Accordion", "Fiddle", "Pedal Steel Guitar", "Synth Voice", "Violin", "Digital Piano", "Synth Brass", "Mandolin", "Choir", "Banjo", "Bells", "Clarinet", "Tenor Saxophone", "Trombone", "Shaker", "French Horn", "Glockenspiel", "Electric Bass", "Cello", "Timpani", "Harmonica", "Marimba", "Vibraphone", "Lap Steel Guitar", "Saxophone", "Orchestra", "Horns", "Cymbals", "Hand Clap", "Oboe", "Celesta", "Congas", "Drone", "Alto Saxophone", "Double Bass", "Ukulele", "Harpsichord", "Baritone Saxophone", "Xylophone", "Tuba", "Bass Guitar", "Whistle", "Lead Guitar", "Rhodes", "808", "Bongos", "Bassoon", "Cowbell", "Viola", "Sitar", "Steel Drums", "Piccolo", "Theremin", "Bagpipes", "Hi-Hat", "Music Box", "Melodica", "Tabla", "Koto", "Djembe", "Taiko", "Didgeridoo"]
|
|
1373
|
+
},
|
|
1374
|
+
"type": {
|
|
1375
|
+
"enum": ["separate_vocal", "split_stem", "split_stem_advanced"]
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1298
1378
|
}
|
|
1299
1379
|
},
|
|
1300
1380
|
"suno/text-to-music": {
|
|
@@ -28,6 +28,7 @@ class Error(Exception):
|
|
|
28
28
|
self,
|
|
29
29
|
message: Optional[str] = None,
|
|
30
30
|
*,
|
|
31
|
+
code: Optional[str] = None,
|
|
31
32
|
status: Optional[int] = None,
|
|
32
33
|
request_id: Optional[str] = None,
|
|
33
34
|
details: Any = None,
|
|
@@ -35,6 +36,7 @@ class Error(Exception):
|
|
|
35
36
|
) -> None:
|
|
36
37
|
super().__init__(message)
|
|
37
38
|
self.message = message
|
|
39
|
+
self.code = code
|
|
38
40
|
self.status = status
|
|
39
41
|
self.request_id = request_id
|
|
40
42
|
self.details = details
|
|
@@ -53,6 +55,7 @@ class Error(Exception):
|
|
|
53
55
|
data = {
|
|
54
56
|
"error": type(self).__name__,
|
|
55
57
|
"message": self.message,
|
|
58
|
+
"code": self.code,
|
|
56
59
|
"status": self.status,
|
|
57
60
|
"request_id": self.request_id,
|
|
58
61
|
"details": self.details,
|
|
@@ -64,6 +67,7 @@ class AuthenticationError(Error):
|
|
|
64
67
|
"""API key is missing or invalid (HTTP 401)."""
|
|
65
68
|
|
|
66
69
|
def __init__(self, message: str = "Unauthorized", *, status: int = 401, **kwargs: Any) -> None:
|
|
70
|
+
kwargs.setdefault("code", "authentication")
|
|
67
71
|
super().__init__(message, status=status, **kwargs)
|
|
68
72
|
|
|
69
73
|
|
|
@@ -78,6 +82,7 @@ class RateLimitError(Error):
|
|
|
78
82
|
retry_after: Optional[float] = None,
|
|
79
83
|
**kwargs: Any,
|
|
80
84
|
) -> None:
|
|
85
|
+
kwargs.setdefault("code", "rate_limit")
|
|
81
86
|
super().__init__(message, status=status, **kwargs)
|
|
82
87
|
self.retry_after = retry_after
|
|
83
88
|
|
|
@@ -86,6 +91,7 @@ class InsufficientCreditsError(Error):
|
|
|
86
91
|
"""Account has insufficient credits (HTTP 402)."""
|
|
87
92
|
|
|
88
93
|
def __init__(self, message: str = "Insufficient credits", *, status: int = 402, **kwargs: Any) -> None:
|
|
94
|
+
kwargs.setdefault("code", "insufficient_credits")
|
|
89
95
|
super().__init__(message, status=status, **kwargs)
|
|
90
96
|
|
|
91
97
|
|
|
@@ -93,6 +99,7 @@ class NotFoundError(Error):
|
|
|
93
99
|
"""Requested resource does not exist (HTTP 404)."""
|
|
94
100
|
|
|
95
101
|
def __init__(self, message: str = "Not found", *, status: int = 404, **kwargs: Any) -> None:
|
|
102
|
+
kwargs.setdefault("code", "not_found")
|
|
96
103
|
super().__init__(message, status=status, **kwargs)
|
|
97
104
|
|
|
98
105
|
|
|
@@ -100,6 +107,7 @@ class ValidationError(Error):
|
|
|
100
107
|
"""Request validation failed (HTTP 400 / 422), or a client-side check failed."""
|
|
101
108
|
|
|
102
109
|
def __init__(self, message: str = "Validation failed", **kwargs: Any) -> None:
|
|
110
|
+
kwargs.setdefault("code", "validation")
|
|
103
111
|
super().__init__(message, **kwargs)
|
|
104
112
|
|
|
105
113
|
|
|
@@ -107,6 +115,7 @@ class ServiceUnavailableError(Error):
|
|
|
107
115
|
"""Service is temporarily unavailable (HTTP 503)."""
|
|
108
116
|
|
|
109
117
|
def __init__(self, message: str = "Service unavailable", *, status: Optional[int] = None, **kwargs: Any) -> None:
|
|
118
|
+
kwargs.setdefault("code", "service_unavailable")
|
|
110
119
|
super().__init__(message, status=503 if status is None else status, **kwargs)
|
|
111
120
|
|
|
112
121
|
|
|
@@ -114,6 +123,7 @@ class ConflictError(Error):
|
|
|
114
123
|
"""Request conflicts with the current resource state (HTTP 409)."""
|
|
115
124
|
|
|
116
125
|
def __init__(self, message: str = "Conflict", *, status: int = 409, **kwargs: Any) -> None:
|
|
126
|
+
kwargs.setdefault("code", "conflict")
|
|
117
127
|
super().__init__(message, status=status, **kwargs)
|
|
118
128
|
|
|
119
129
|
|
|
@@ -121,6 +131,7 @@ class ServerError(Error):
|
|
|
121
131
|
"""Server encountered an internal error (HTTP 5xx)."""
|
|
122
132
|
|
|
123
133
|
def __init__(self, message: str = "Server error", *, status: Optional[int] = None, **kwargs: Any) -> None:
|
|
134
|
+
kwargs.setdefault("code", "server")
|
|
124
135
|
super().__init__(message, status=500 if status is None else status, **kwargs)
|
|
125
136
|
|
|
126
137
|
|
|
@@ -128,6 +139,7 @@ class NetworkError(Error):
|
|
|
128
139
|
"""Network connection failed or the request could not be sent."""
|
|
129
140
|
|
|
130
141
|
def __init__(self, message: str = "Network error", **kwargs: Any) -> None:
|
|
142
|
+
kwargs.setdefault("code", "network")
|
|
131
143
|
super().__init__(message, **kwargs)
|
|
132
144
|
|
|
133
145
|
|
|
@@ -135,6 +147,7 @@ class TimeoutError(Error): # noqa: A001 - intentional SDK error name, parallels
|
|
|
135
147
|
"""HTTP request exceeded the configured timeout."""
|
|
136
148
|
|
|
137
149
|
def __init__(self, message: str = "Request timed out", **kwargs: Any) -> None:
|
|
150
|
+
kwargs.setdefault("code", "timeout")
|
|
138
151
|
super().__init__(message, **kwargs)
|
|
139
152
|
|
|
140
153
|
|
|
@@ -142,6 +155,7 @@ class TaskTimeoutError(Error):
|
|
|
142
155
|
"""Polling for task completion exceeded the maximum wait time."""
|
|
143
156
|
|
|
144
157
|
def __init__(self, message: str = "Task polling timed out", **kwargs: Any) -> None:
|
|
158
|
+
kwargs.setdefault("code", "task_timeout")
|
|
145
159
|
super().__init__(message, **kwargs)
|
|
146
160
|
|
|
147
161
|
|
|
@@ -149,6 +163,7 @@ class TaskFailedError(Error):
|
|
|
149
163
|
"""Async task failed during processing."""
|
|
150
164
|
|
|
151
165
|
def __init__(self, message: str = "Task failed", **kwargs: Any) -> None:
|
|
166
|
+
kwargs.setdefault("code", "task_failed")
|
|
152
167
|
super().__init__(message, **kwargs)
|
|
153
168
|
|
|
154
169
|
|
|
@@ -198,6 +213,7 @@ def error_from_response(response: "Any") -> Error:
|
|
|
198
213
|
error_class = STATUS_MAP.get(status, Error)
|
|
199
214
|
|
|
200
215
|
kwargs: Dict[str, Any] = {
|
|
216
|
+
"code": _extract_code(parsed_body),
|
|
201
217
|
"status": status,
|
|
202
218
|
"request_id": request_id,
|
|
203
219
|
"details": parsed_body,
|
|
@@ -209,6 +225,16 @@ def error_from_response(response: "Any") -> Error:
|
|
|
209
225
|
return error_class(message, **kwargs)
|
|
210
226
|
|
|
211
227
|
|
|
228
|
+
def _extract_code(body: Any) -> Optional[str]:
|
|
229
|
+
if not isinstance(body, dict):
|
|
230
|
+
return None
|
|
231
|
+
error = body.get("error")
|
|
232
|
+
if not isinstance(error, dict):
|
|
233
|
+
return None
|
|
234
|
+
code = error.get("code")
|
|
235
|
+
return code if isinstance(code, str) and code else None
|
|
236
|
+
|
|
237
|
+
|
|
212
238
|
def _parse_body(body: Optional[str]) -> Any:
|
|
213
239
|
if not body:
|
|
214
240
|
return None
|
|
@@ -79,13 +79,16 @@ class Resource:
|
|
|
79
79
|
self._validate_schema_field(params, field, rules)
|
|
80
80
|
|
|
81
81
|
def _validate_schema_field(self, params: Dict[str, Any], field: str, rules: Dict[str, Any]) -> None:
|
|
82
|
+
value = params.get(field)
|
|
83
|
+
if value is not None and ("min_items" in rules or "max_items" in rules):
|
|
84
|
+
self._validate_item_count(field, value, rules)
|
|
85
|
+
|
|
82
86
|
present = self._field_present(params, field)
|
|
83
87
|
if rules.get("required") and not present:
|
|
84
88
|
raise ValidationError(f"{field} is required")
|
|
85
89
|
if not present:
|
|
86
90
|
return
|
|
87
91
|
|
|
88
|
-
value = params[field]
|
|
89
92
|
enum = rules.get("enum")
|
|
90
93
|
if enum is not None and not self._enum_allowed(enum, value):
|
|
91
94
|
raise ValidationError(f"{field} must be one of: {', '.join(str(option) for option in enum)}")
|
|
@@ -96,6 +99,24 @@ class Resource:
|
|
|
96
99
|
if "min" in rules or "max" in rules:
|
|
97
100
|
self._validate_range(field, value, rules)
|
|
98
101
|
|
|
102
|
+
def _validate_item_count(self, field: str, value: Any, rules: Dict[str, Any]) -> None:
|
|
103
|
+
if not isinstance(value, (list, tuple)):
|
|
104
|
+
raise ValidationError(f"{field} must be an array")
|
|
105
|
+
|
|
106
|
+
minimum = rules.get("min_items")
|
|
107
|
+
maximum = rules.get("max_items")
|
|
108
|
+
if (minimum is None or len(value) >= minimum) and (maximum is None or len(value) <= maximum):
|
|
109
|
+
return
|
|
110
|
+
raise ValidationError(self._item_count_message(field, minimum, maximum))
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def _item_count_message(field: str, minimum: Any, maximum: Any) -> str:
|
|
114
|
+
if minimum is not None and maximum is not None:
|
|
115
|
+
return f"{field} must contain between {minimum} and {maximum} items"
|
|
116
|
+
if minimum is not None:
|
|
117
|
+
return f"{field} must contain at least {minimum} items"
|
|
118
|
+
return f"{field} must contain at most {maximum} items"
|
|
119
|
+
|
|
99
120
|
@staticmethod
|
|
100
121
|
def _validate_integer(field: str, value: Any, rules: Dict[str, Any]) -> None:
|
|
101
122
|
# Mirrors GatewayEntry#validate_schema_integer!: a type: integer field
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.1"
|
|
@@ -70,6 +70,36 @@ def test_extracts_message_from_nested_error():
|
|
|
70
70
|
assert error.message == "Nested message"
|
|
71
71
|
|
|
72
72
|
|
|
73
|
+
def test_preserves_explicit_http_error_code_and_leaves_missing_code_none():
|
|
74
|
+
explicit = errors.error_from_response(
|
|
75
|
+
response(409, body='{"error":{"code":"source_task_not_ready","message":"wait"}}')
|
|
76
|
+
)
|
|
77
|
+
missing = errors.error_from_response(response(409, body='{"error":{"message":"wait"}}'))
|
|
78
|
+
|
|
79
|
+
assert explicit.code == "source_task_not_ready"
|
|
80
|
+
assert missing.code is None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_continuation_errors_preserve_codes_and_status_classification():
|
|
84
|
+
cases = [
|
|
85
|
+
(400, "invalid_resource_id", errors.ValidationError),
|
|
86
|
+
(409, "request_conflict", errors.ConflictError),
|
|
87
|
+
(409, "source_task_not_ready", errors.ConflictError),
|
|
88
|
+
(422, "source_task_unusable", errors.ValidationError),
|
|
89
|
+
(422, "continuation_not_supported", errors.ValidationError),
|
|
90
|
+
(429, "rate_limited", errors.RateLimitError),
|
|
91
|
+
(503, "continuation_unavailable", errors.ServiceUnavailableError),
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
for status, code, error_class in cases:
|
|
95
|
+
error = errors.error_from_response(
|
|
96
|
+
response(status, body=f'{{"error":{{"code":"{code}","message":"failed"}}}}')
|
|
97
|
+
)
|
|
98
|
+
assert isinstance(error, error_class)
|
|
99
|
+
assert error.status == status
|
|
100
|
+
assert error.code == code
|
|
101
|
+
|
|
102
|
+
|
|
73
103
|
def test_extracts_message_from_errors_array():
|
|
74
104
|
error = errors.error_from_response(response(400, body='{"errors":["First error"]}'))
|
|
75
105
|
assert error.message == "First error"
|
|
@@ -99,6 +129,7 @@ def test_to_dict_is_compact():
|
|
|
99
129
|
assert error.to_dict() == {
|
|
100
130
|
"error": "AuthenticationError",
|
|
101
131
|
"message": "Bad key",
|
|
132
|
+
"code": "authentication",
|
|
102
133
|
"status": 401,
|
|
103
134
|
"request_id": "req-1",
|
|
104
135
|
}
|
|
@@ -135,6 +135,30 @@ def test_validate_integer_rejects_bool_and_whole_float():
|
|
|
135
135
|
assert _run_validate({"model": "m", "tolerance": 5}) == ""
|
|
136
136
|
|
|
137
137
|
|
|
138
|
+
def test_validate_array_item_count_constraints():
|
|
139
|
+
schema = {
|
|
140
|
+
"models": ["m"],
|
|
141
|
+
"fields_by_model": {
|
|
142
|
+
"m": {
|
|
143
|
+
"reference_image_urls": {"min_items": 1, "max_items": 3},
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
resource = SampleResource(FakeHttp())
|
|
148
|
+
|
|
149
|
+
with pytest.raises(ValidationError, match="^reference_image_urls must be an array$"):
|
|
150
|
+
resource._validate_contract(schema, {"model": "m", "reference_image_urls": "image.png"})
|
|
151
|
+
with pytest.raises(ValidationError, match="^reference_image_urls must contain between 1 and 3 items$"):
|
|
152
|
+
resource._validate_contract(schema, {"model": "m", "reference_image_urls": []})
|
|
153
|
+
with pytest.raises(ValidationError, match="^reference_image_urls must contain between 1 and 3 items$"):
|
|
154
|
+
resource._validate_contract(schema, {"model": "m", "reference_image_urls": ["a", "b", "c", "d"]})
|
|
155
|
+
with pytest.raises(ValidationError, match="^reference_image_urls must contain between 1 and 3 items$"):
|
|
156
|
+
resource._validate_contract(schema, {"model": "m", "reference_image_urls": ("a", "b", "c", "d")})
|
|
157
|
+
|
|
158
|
+
resource._validate_contract(schema, {"model": "m", "reference_image_urls": ["a", "b", "c"]})
|
|
159
|
+
resource._validate_contract(schema, {"model": "m", "reference_image_urls": ("a", "b", "c")})
|
|
160
|
+
|
|
161
|
+
|
|
138
162
|
def test_validate_contract_runs_rules_before_field_requirements():
|
|
139
163
|
schema = {
|
|
140
164
|
"models": ["m"],
|
runapi_core-0.1.6/pyproject.toml
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
[build-system]
|
|
2
|
-
requires = ["hatchling"]
|
|
3
|
-
build-backend = "hatchling.build"
|
|
4
|
-
|
|
5
|
-
[project]
|
|
6
|
-
name = "runapi-core"
|
|
7
|
-
dynamic = ["version"]
|
|
8
|
-
description = "Core module for the RunAPI Python SDK"
|
|
9
|
-
readme = "README.md"
|
|
10
|
-
requires-python = ">=3.9"
|
|
11
|
-
license = "Apache-2.0"
|
|
12
|
-
authors = [{ name = "RunAPI", email = "contact@runapi.ai" }]
|
|
13
|
-
keywords = ["runapi", "ai", "sdk", "api"]
|
|
14
|
-
dependencies = ["httpx>=0.27"]
|
|
15
|
-
|
|
16
|
-
[project.urls]
|
|
17
|
-
Homepage = "https://runapi.ai"
|
|
18
|
-
Documentation = "https://runapi.ai/docs#sdk"
|
|
19
|
-
|
|
20
|
-
[tool.hatch.version]
|
|
21
|
-
path = "src/runapi/core/version.py"
|
|
22
|
-
|
|
23
|
-
[tool.hatch.build.targets.wheel]
|
|
24
|
-
packages = ["src/runapi"]
|
|
25
|
-
|
|
26
|
-
[tool.uv]
|
|
27
|
-
package = true
|
|
28
|
-
|
|
29
|
-
[dependency-groups]
|
|
30
|
-
dev = ["pytest>=8", "httpx>=0.27"]
|
|
31
|
-
|
|
32
|
-
[tool.runapi]
|
|
33
|
-
slug = "core"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.6"
|
|
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
|