unwrapr 1.0.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.
- unwrapr-1.0.0/LICENSE +21 -0
- unwrapr-1.0.0/PKG-INFO +185 -0
- unwrapr-1.0.0/README.md +159 -0
- unwrapr-1.0.0/pyproject.toml +34 -0
- unwrapr-1.0.0/setup.cfg +4 -0
- unwrapr-1.0.0/src/unwrapr/__init__.py +25 -0
- unwrapr-1.0.0/src/unwrapr/core.py +68 -0
- unwrapr-1.0.0/src/unwrapr/envelope.py +41 -0
- unwrapr-1.0.0/src/unwrapr/exceptions.py +3 -0
- unwrapr-1.0.0/src/unwrapr/strategies.py +113 -0
- unwrapr-1.0.0/src/unwrapr.egg-info/PKG-INFO +185 -0
- unwrapr-1.0.0/src/unwrapr.egg-info/SOURCES.txt +13 -0
- unwrapr-1.0.0/src/unwrapr.egg-info/dependency_links.txt +1 -0
- unwrapr-1.0.0/src/unwrapr.egg-info/top_level.txt +1 -0
- unwrapr-1.0.0/tests/test_unwrapr.py +154 -0
unwrapr-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 eritrouib
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
unwrapr-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: unwrapr
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Normalise any API response into a consistent envelope: ok, data, error, status, meta
|
|
5
|
+
Author: eritrouib
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/eritrouib/unwrapr-py
|
|
8
|
+
Project-URL: Repository, https://github.com/eritrouib/unwrapr-py
|
|
9
|
+
Project-URL: Issues, https://github.com/eritrouib/unwrapr-py/issues
|
|
10
|
+
Keywords: api,response,normalise,normalize,envelope,http,rest
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# unwrapr
|
|
28
|
+
|
|
29
|
+
Stop writing the same API response unwrapping code in every project.
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install unwrapr
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
> **Requires Python 3.9+** · Zero dependencies
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## The problem
|
|
40
|
+
|
|
41
|
+
Every API you call returns a different shape:
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
{"data": {...}, "error": null} # some APIs
|
|
45
|
+
{"success": True, "result": {...}} # other APIs
|
|
46
|
+
{"status": 200, "payload": {...}} # yet others
|
|
47
|
+
[{"id": 1}, {"id": 2}] # or just raw lists
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
You end up writing custom unwrapping logic for every single one.
|
|
51
|
+
|
|
52
|
+
**unwrapr fixes that.** One function. Any shape. Consistent output.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Quick start
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from unwrapr import unwrap
|
|
60
|
+
|
|
61
|
+
# Works with any API response shape
|
|
62
|
+
env = unwrap({"success": True, "data": {"id": 1, "name": "Alice"}})
|
|
63
|
+
|
|
64
|
+
env.ok # True
|
|
65
|
+
env.data # {"id": 1, "name": "Alice"}
|
|
66
|
+
env.error # None
|
|
67
|
+
env.status # None
|
|
68
|
+
env.meta # {}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## Supported shapes
|
|
74
|
+
|
|
75
|
+
unwrapr auto-detects the response shape and normalises it:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
# { data, error }
|
|
79
|
+
unwrap({"data": {"id": 1}, "error": None})
|
|
80
|
+
|
|
81
|
+
# { success, data }
|
|
82
|
+
unwrap({"success": True, "result": {"id": 1}})
|
|
83
|
+
|
|
84
|
+
# { status, payload }
|
|
85
|
+
unwrap({"status": 200, "payload": {"id": 1}})
|
|
86
|
+
|
|
87
|
+
# JSON:API
|
|
88
|
+
unwrap({"data": [...], "included": [], "meta": {"total": 5}})
|
|
89
|
+
|
|
90
|
+
# Plain dict or list
|
|
91
|
+
unwrap({"id": 1, "name": "Alice"})
|
|
92
|
+
unwrap([1, 2, 3])
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## HTTP status override
|
|
98
|
+
|
|
99
|
+
Pass the HTTP status code to let it override the body:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
env = unwrap(response.json(), status=response.status_code)
|
|
103
|
+
env.ok # True if 2xx, False otherwise
|
|
104
|
+
env.status # the actual HTTP status code
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## Safe data access
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
env = unwrap({"success": False, "error": "Not found"})
|
|
113
|
+
|
|
114
|
+
# Raises UnwraprError if not ok
|
|
115
|
+
data = env.unwrap()
|
|
116
|
+
|
|
117
|
+
# Returns default if not ok — never raises
|
|
118
|
+
data = env.unwrap_or([])
|
|
119
|
+
data = env.unwrap_or(None)
|
|
120
|
+
|
|
121
|
+
# Use as a boolean
|
|
122
|
+
if env:
|
|
123
|
+
print(env.data)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## Custom strategies
|
|
129
|
+
|
|
130
|
+
Add your own shape detection logic:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from unwrapr import unwrap, DEFAULT_STRATEGIES
|
|
134
|
+
|
|
135
|
+
def my_api_strategy(raw):
|
|
136
|
+
if isinstance(raw, dict) and "response" in raw:
|
|
137
|
+
return {
|
|
138
|
+
"ok": raw["response"]["success"],
|
|
139
|
+
"data": raw["response"]["body"],
|
|
140
|
+
"error": raw["response"].get("error"),
|
|
141
|
+
"status": None,
|
|
142
|
+
"meta": {},
|
|
143
|
+
}
|
|
144
|
+
return None # return None to try the next strategy
|
|
145
|
+
|
|
146
|
+
env = unwrap(response, strategies=[my_api_strategy] + DEFAULT_STRATEGIES)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## Works great with petchr
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
from petchr import petch
|
|
155
|
+
from unwrapr import unwrap
|
|
156
|
+
|
|
157
|
+
resp = petch("https://api.example.com/users/1")
|
|
158
|
+
env = unwrap(resp.data, status=resp.status_code)
|
|
159
|
+
|
|
160
|
+
if env:
|
|
161
|
+
print(env.data)
|
|
162
|
+
else:
|
|
163
|
+
print(f"Error: {env.error}")
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## The Envelope
|
|
169
|
+
|
|
170
|
+
Every call returns an `Envelope`:
|
|
171
|
+
|
|
172
|
+
| Field | Type | Description |
|
|
173
|
+
|----------|------------|--------------------------------------|
|
|
174
|
+
| `ok` | `bool` | True if response is successful |
|
|
175
|
+
| `data` | `Any` | The extracted payload |
|
|
176
|
+
| `error` | `str|None` | Error message if not ok |
|
|
177
|
+
| `status` | `int|None` | HTTP status code |
|
|
178
|
+
| `meta` | `dict` | Extra fields (pagination, links etc) |
|
|
179
|
+
| `raw` | `Any` | Original response before normalising |
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## License
|
|
184
|
+
|
|
185
|
+
MIT
|
unwrapr-1.0.0/README.md
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# unwrapr
|
|
2
|
+
|
|
3
|
+
Stop writing the same API response unwrapping code in every project.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install unwrapr
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
> **Requires Python 3.9+** · Zero dependencies
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## The problem
|
|
14
|
+
|
|
15
|
+
Every API you call returns a different shape:
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
{"data": {...}, "error": null} # some APIs
|
|
19
|
+
{"success": True, "result": {...}} # other APIs
|
|
20
|
+
{"status": 200, "payload": {...}} # yet others
|
|
21
|
+
[{"id": 1}, {"id": 2}] # or just raw lists
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
You end up writing custom unwrapping logic for every single one.
|
|
25
|
+
|
|
26
|
+
**unwrapr fixes that.** One function. Any shape. Consistent output.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Quick start
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from unwrapr import unwrap
|
|
34
|
+
|
|
35
|
+
# Works with any API response shape
|
|
36
|
+
env = unwrap({"success": True, "data": {"id": 1, "name": "Alice"}})
|
|
37
|
+
|
|
38
|
+
env.ok # True
|
|
39
|
+
env.data # {"id": 1, "name": "Alice"}
|
|
40
|
+
env.error # None
|
|
41
|
+
env.status # None
|
|
42
|
+
env.meta # {}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Supported shapes
|
|
48
|
+
|
|
49
|
+
unwrapr auto-detects the response shape and normalises it:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
# { data, error }
|
|
53
|
+
unwrap({"data": {"id": 1}, "error": None})
|
|
54
|
+
|
|
55
|
+
# { success, data }
|
|
56
|
+
unwrap({"success": True, "result": {"id": 1}})
|
|
57
|
+
|
|
58
|
+
# { status, payload }
|
|
59
|
+
unwrap({"status": 200, "payload": {"id": 1}})
|
|
60
|
+
|
|
61
|
+
# JSON:API
|
|
62
|
+
unwrap({"data": [...], "included": [], "meta": {"total": 5}})
|
|
63
|
+
|
|
64
|
+
# Plain dict or list
|
|
65
|
+
unwrap({"id": 1, "name": "Alice"})
|
|
66
|
+
unwrap([1, 2, 3])
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## HTTP status override
|
|
72
|
+
|
|
73
|
+
Pass the HTTP status code to let it override the body:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
env = unwrap(response.json(), status=response.status_code)
|
|
77
|
+
env.ok # True if 2xx, False otherwise
|
|
78
|
+
env.status # the actual HTTP status code
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Safe data access
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
env = unwrap({"success": False, "error": "Not found"})
|
|
87
|
+
|
|
88
|
+
# Raises UnwraprError if not ok
|
|
89
|
+
data = env.unwrap()
|
|
90
|
+
|
|
91
|
+
# Returns default if not ok — never raises
|
|
92
|
+
data = env.unwrap_or([])
|
|
93
|
+
data = env.unwrap_or(None)
|
|
94
|
+
|
|
95
|
+
# Use as a boolean
|
|
96
|
+
if env:
|
|
97
|
+
print(env.data)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Custom strategies
|
|
103
|
+
|
|
104
|
+
Add your own shape detection logic:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from unwrapr import unwrap, DEFAULT_STRATEGIES
|
|
108
|
+
|
|
109
|
+
def my_api_strategy(raw):
|
|
110
|
+
if isinstance(raw, dict) and "response" in raw:
|
|
111
|
+
return {
|
|
112
|
+
"ok": raw["response"]["success"],
|
|
113
|
+
"data": raw["response"]["body"],
|
|
114
|
+
"error": raw["response"].get("error"),
|
|
115
|
+
"status": None,
|
|
116
|
+
"meta": {},
|
|
117
|
+
}
|
|
118
|
+
return None # return None to try the next strategy
|
|
119
|
+
|
|
120
|
+
env = unwrap(response, strategies=[my_api_strategy] + DEFAULT_STRATEGIES)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## Works great with petchr
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
from petchr import petch
|
|
129
|
+
from unwrapr import unwrap
|
|
130
|
+
|
|
131
|
+
resp = petch("https://api.example.com/users/1")
|
|
132
|
+
env = unwrap(resp.data, status=resp.status_code)
|
|
133
|
+
|
|
134
|
+
if env:
|
|
135
|
+
print(env.data)
|
|
136
|
+
else:
|
|
137
|
+
print(f"Error: {env.error}")
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## The Envelope
|
|
143
|
+
|
|
144
|
+
Every call returns an `Envelope`:
|
|
145
|
+
|
|
146
|
+
| Field | Type | Description |
|
|
147
|
+
|----------|------------|--------------------------------------|
|
|
148
|
+
| `ok` | `bool` | True if response is successful |
|
|
149
|
+
| `data` | `Any` | The extracted payload |
|
|
150
|
+
| `error` | `str|None` | Error message if not ok |
|
|
151
|
+
| `status` | `int|None` | HTTP status code |
|
|
152
|
+
| `meta` | `dict` | Extra fields (pagination, links etc) |
|
|
153
|
+
| `raw` | `Any` | Original response before normalising |
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## License
|
|
158
|
+
|
|
159
|
+
MIT
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "unwrapr"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Normalise any API response into a consistent envelope: ok, data, error, status, meta"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "eritrouib" }]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
keywords = ["api", "response", "normalise", "normalize", "envelope", "http", "rest"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.9",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
25
|
+
"Topic :: Software Development :: Libraries",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://github.com/eritrouib/unwrapr-py"
|
|
30
|
+
Repository = "https://github.com/eritrouib/unwrapr-py"
|
|
31
|
+
Issues = "https://github.com/eritrouib/unwrapr-py/issues"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
where = ["src"]
|
unwrapr-1.0.0/setup.cfg
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from .core import unwrap
|
|
2
|
+
from .envelope import Envelope
|
|
3
|
+
from .exceptions import UnwraprError
|
|
4
|
+
from .strategies import (
|
|
5
|
+
DEFAULT_STRATEGIES,
|
|
6
|
+
strategy_data_error,
|
|
7
|
+
strategy_jsonapi,
|
|
8
|
+
strategy_plain,
|
|
9
|
+
strategy_status_code,
|
|
10
|
+
strategy_success_flag,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"unwrap",
|
|
15
|
+
"Envelope",
|
|
16
|
+
"UnwraprError",
|
|
17
|
+
"DEFAULT_STRATEGIES",
|
|
18
|
+
"strategy_plain",
|
|
19
|
+
"strategy_data_error",
|
|
20
|
+
"strategy_success_flag",
|
|
21
|
+
"strategy_status_code",
|
|
22
|
+
"strategy_jsonapi",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Any, Callable
|
|
3
|
+
from .envelope import Envelope
|
|
4
|
+
from .strategies import DEFAULT_STRATEGIES
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def unwrap(
|
|
8
|
+
raw: Any,
|
|
9
|
+
*,
|
|
10
|
+
status: int | None = None,
|
|
11
|
+
strategies: list[Callable] | None = None,
|
|
12
|
+
strict: bool = False,
|
|
13
|
+
) -> Envelope:
|
|
14
|
+
"""
|
|
15
|
+
Normalise any API response into a consistent Envelope.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
raw: The raw response body (dict, list, str, etc).
|
|
19
|
+
status: HTTP status code (overrides any status found in the body).
|
|
20
|
+
strategies: Custom list of strategies to try. Defaults to built-in set.
|
|
21
|
+
strict: If True, raise UnwraprError when no strategy matches.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
Envelope with ok, data, error, status, meta, raw fields.
|
|
25
|
+
|
|
26
|
+
Example:
|
|
27
|
+
>>> from unwrapr import unwrap
|
|
28
|
+
>>> env = unwrap({"success": True, "data": {"id": 1}})
|
|
29
|
+
>>> env.ok
|
|
30
|
+
True
|
|
31
|
+
>>> env.data
|
|
32
|
+
{"id": 1}
|
|
33
|
+
"""
|
|
34
|
+
from .exceptions import UnwraprError
|
|
35
|
+
|
|
36
|
+
_strategies = strategies if strategies is not None else DEFAULT_STRATEGIES
|
|
37
|
+
|
|
38
|
+
for strategy in _strategies:
|
|
39
|
+
result = strategy(raw)
|
|
40
|
+
if result is not None:
|
|
41
|
+
env = Envelope(
|
|
42
|
+
ok=result["ok"],
|
|
43
|
+
data=result["data"],
|
|
44
|
+
error=result["error"],
|
|
45
|
+
status=status if status is not None else result["status"],
|
|
46
|
+
meta=result.get("meta", {}),
|
|
47
|
+
raw=raw,
|
|
48
|
+
)
|
|
49
|
+
# If HTTP status overrides body, recalculate ok
|
|
50
|
+
if status is not None:
|
|
51
|
+
env.ok = 200 <= status < 300
|
|
52
|
+
if not env.ok and not env.error:
|
|
53
|
+
env.error = f"Request failed with status {status}"
|
|
54
|
+
return env
|
|
55
|
+
|
|
56
|
+
if strict:
|
|
57
|
+
raise UnwraprError(f"No strategy matched response: {type(raw).__name__}")
|
|
58
|
+
|
|
59
|
+
# Fallback: wrap as-is
|
|
60
|
+
ok = status is None or 200 <= status < 300
|
|
61
|
+
return Envelope(
|
|
62
|
+
ok=ok,
|
|
63
|
+
data=raw,
|
|
64
|
+
error=None if ok else f"Request failed with status {status}",
|
|
65
|
+
status=status,
|
|
66
|
+
meta={},
|
|
67
|
+
raw=raw,
|
|
68
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class Envelope:
|
|
8
|
+
"""
|
|
9
|
+
A normalised API response envelope.
|
|
10
|
+
|
|
11
|
+
Attributes:
|
|
12
|
+
ok: True if the response represents success.
|
|
13
|
+
data: The extracted payload (dict, list, str, or None).
|
|
14
|
+
error: Error message if ok is False, else None.
|
|
15
|
+
status: HTTP status code if available.
|
|
16
|
+
meta: Any extra metadata (pagination, headers, etc).
|
|
17
|
+
raw: The original raw response before normalisation.
|
|
18
|
+
"""
|
|
19
|
+
ok: bool
|
|
20
|
+
data: Any = None
|
|
21
|
+
error: str | None = None
|
|
22
|
+
status: int | None = None
|
|
23
|
+
meta: dict = field(default_factory=dict)
|
|
24
|
+
raw: Any = None
|
|
25
|
+
|
|
26
|
+
def __bool__(self):
|
|
27
|
+
return self.ok
|
|
28
|
+
|
|
29
|
+
def __repr__(self):
|
|
30
|
+
return f"<Envelope ok={self.ok} status={self.status} data={type(self.data).__name__}>"
|
|
31
|
+
|
|
32
|
+
def unwrap(self) -> Any:
|
|
33
|
+
"""Return data if ok, raise UnwraprError otherwise."""
|
|
34
|
+
from .exceptions import UnwraprError
|
|
35
|
+
if not self.ok:
|
|
36
|
+
raise UnwraprError(self.error or "Response was not successful")
|
|
37
|
+
return self.data
|
|
38
|
+
|
|
39
|
+
def unwrap_or(self, default: Any = None) -> Any:
|
|
40
|
+
"""Return data if ok, else return default."""
|
|
41
|
+
return self.data if self.ok else default
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Built-in normalisation strategies for common API response shapes.
|
|
3
|
+
Each strategy is a callable: (raw: Any) -> dict with keys:
|
|
4
|
+
ok, data, error, status, meta
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _is_ok(status) -> bool:
|
|
11
|
+
return status is not None and 200 <= int(status) < 300
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ── Strategy: plain dict ─────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
def strategy_plain(raw: Any) -> dict | None:
|
|
17
|
+
"""
|
|
18
|
+
Handles a plain dict or list response with no envelope.
|
|
19
|
+
{ "id": 1, "name": "Alice" } → data=raw, ok=True
|
|
20
|
+
"""
|
|
21
|
+
if isinstance(raw, (dict, list)):
|
|
22
|
+
return {"ok": True, "data": raw, "error": None, "status": None, "meta": {}}
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ── Strategy: { data: ..., error: ... } ─────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
def strategy_data_error(raw: Any) -> dict | None:
|
|
29
|
+
"""
|
|
30
|
+
Handles: { "data": {...}, "error": null }
|
|
31
|
+
{ "data": null, "error": "Something went wrong" }
|
|
32
|
+
"""
|
|
33
|
+
if not isinstance(raw, dict):
|
|
34
|
+
return None
|
|
35
|
+
if "data" not in raw and "error" not in raw:
|
|
36
|
+
return None
|
|
37
|
+
error = raw.get("error") or raw.get("message") or raw.get("msg")
|
|
38
|
+
data = raw.get("data") or raw.get("result") or raw.get("payload")
|
|
39
|
+
status = raw.get("status") or raw.get("status_code") or raw.get("code")
|
|
40
|
+
ok = not error and (status is None or _is_ok(status))
|
|
41
|
+
meta = {k: v for k, v in raw.items() if k not in ("data", "result", "payload", "error", "message", "msg", "status", "status_code", "code")}
|
|
42
|
+
return {"ok": ok, "data": data, "error": str(error) if error else None, "status": int(status) if status else None, "meta": meta}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ── Strategy: { success: bool, ... } ────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
def strategy_success_flag(raw: Any) -> dict | None:
|
|
48
|
+
"""
|
|
49
|
+
Handles: { "success": true, "data": {...} }
|
|
50
|
+
{ "success": false, "message": "Not found" }
|
|
51
|
+
"""
|
|
52
|
+
if not isinstance(raw, dict):
|
|
53
|
+
return None
|
|
54
|
+
if "success" not in raw and "ok" not in raw:
|
|
55
|
+
return None
|
|
56
|
+
ok = bool(raw.get("success", raw.get("ok", False)))
|
|
57
|
+
data = raw.get("data") or raw.get("result") or raw.get("payload") or raw.get("body")
|
|
58
|
+
error = None if ok else (raw.get("message") or raw.get("error") or raw.get("msg") or "Request failed")
|
|
59
|
+
status = raw.get("status") or raw.get("status_code") or raw.get("code")
|
|
60
|
+
meta = {k: v for k, v in raw.items() if k not in ("success", "ok", "data", "result", "payload", "body", "message", "error", "msg", "status", "status_code", "code")}
|
|
61
|
+
return {"ok": ok, "data": data, "error": str(error) if error else None, "status": int(status) if status else None, "meta": meta}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ── Strategy: { status: 200, body/result: ... } ──────────────────────────────
|
|
65
|
+
|
|
66
|
+
def strategy_status_code(raw: Any) -> dict | None:
|
|
67
|
+
"""
|
|
68
|
+
Handles: { "status": 200, "result": {...} }
|
|
69
|
+
{ "code": 404, "message": "Not found" }
|
|
70
|
+
"""
|
|
71
|
+
if not isinstance(raw, dict):
|
|
72
|
+
return None
|
|
73
|
+
status = raw.get("status") or raw.get("status_code") or raw.get("code")
|
|
74
|
+
if status is None or not str(status).isdigit():
|
|
75
|
+
return None
|
|
76
|
+
status = int(status)
|
|
77
|
+
ok = _is_ok(status)
|
|
78
|
+
data = raw.get("data") or raw.get("result") or raw.get("payload") or raw.get("body") or raw.get("response")
|
|
79
|
+
error = None if ok else (raw.get("message") or raw.get("error") or raw.get("msg") or f"Request failed with status {status}")
|
|
80
|
+
meta = {k: v for k, v in raw.items() if k not in ("data", "result", "payload", "body", "response", "message", "error", "msg", "status", "status_code", "code")}
|
|
81
|
+
return {"ok": ok, "data": data, "error": str(error) if error else None, "status": status, "meta": meta}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ── Strategy: JSON:API { data: [...], included: [...] } ─────────────────────
|
|
85
|
+
|
|
86
|
+
def strategy_jsonapi(raw: Any) -> dict | None:
|
|
87
|
+
"""
|
|
88
|
+
Handles basic JSON:API shaped responses.
|
|
89
|
+
{ "data": [...], "included": [...], "meta": {...} }
|
|
90
|
+
"""
|
|
91
|
+
if not isinstance(raw, dict):
|
|
92
|
+
return None
|
|
93
|
+
if "data" not in raw or "included" not in raw and "meta" not in raw:
|
|
94
|
+
return None
|
|
95
|
+
errors = raw.get("errors")
|
|
96
|
+
ok = not errors
|
|
97
|
+
data = raw.get("data")
|
|
98
|
+
error = str(errors[0].get("detail", "JSON:API error")) if errors else None
|
|
99
|
+
meta = raw.get("meta", {})
|
|
100
|
+
if raw.get("included"):
|
|
101
|
+
meta["included"] = raw["included"]
|
|
102
|
+
if raw.get("links"):
|
|
103
|
+
meta["links"] = raw["links"]
|
|
104
|
+
return {"ok": ok, "data": data, "error": error, "status": None, "meta": meta}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
DEFAULT_STRATEGIES = [
|
|
108
|
+
strategy_jsonapi,
|
|
109
|
+
strategy_success_flag,
|
|
110
|
+
strategy_status_code,
|
|
111
|
+
strategy_data_error,
|
|
112
|
+
strategy_plain,
|
|
113
|
+
]
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: unwrapr
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Normalise any API response into a consistent envelope: ok, data, error, status, meta
|
|
5
|
+
Author: eritrouib
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/eritrouib/unwrapr-py
|
|
8
|
+
Project-URL: Repository, https://github.com/eritrouib/unwrapr-py
|
|
9
|
+
Project-URL: Issues, https://github.com/eritrouib/unwrapr-py/issues
|
|
10
|
+
Keywords: api,response,normalise,normalize,envelope,http,rest
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# unwrapr
|
|
28
|
+
|
|
29
|
+
Stop writing the same API response unwrapping code in every project.
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install unwrapr
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
> **Requires Python 3.9+** · Zero dependencies
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## The problem
|
|
40
|
+
|
|
41
|
+
Every API you call returns a different shape:
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
{"data": {...}, "error": null} # some APIs
|
|
45
|
+
{"success": True, "result": {...}} # other APIs
|
|
46
|
+
{"status": 200, "payload": {...}} # yet others
|
|
47
|
+
[{"id": 1}, {"id": 2}] # or just raw lists
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
You end up writing custom unwrapping logic for every single one.
|
|
51
|
+
|
|
52
|
+
**unwrapr fixes that.** One function. Any shape. Consistent output.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Quick start
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from unwrapr import unwrap
|
|
60
|
+
|
|
61
|
+
# Works with any API response shape
|
|
62
|
+
env = unwrap({"success": True, "data": {"id": 1, "name": "Alice"}})
|
|
63
|
+
|
|
64
|
+
env.ok # True
|
|
65
|
+
env.data # {"id": 1, "name": "Alice"}
|
|
66
|
+
env.error # None
|
|
67
|
+
env.status # None
|
|
68
|
+
env.meta # {}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## Supported shapes
|
|
74
|
+
|
|
75
|
+
unwrapr auto-detects the response shape and normalises it:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
# { data, error }
|
|
79
|
+
unwrap({"data": {"id": 1}, "error": None})
|
|
80
|
+
|
|
81
|
+
# { success, data }
|
|
82
|
+
unwrap({"success": True, "result": {"id": 1}})
|
|
83
|
+
|
|
84
|
+
# { status, payload }
|
|
85
|
+
unwrap({"status": 200, "payload": {"id": 1}})
|
|
86
|
+
|
|
87
|
+
# JSON:API
|
|
88
|
+
unwrap({"data": [...], "included": [], "meta": {"total": 5}})
|
|
89
|
+
|
|
90
|
+
# Plain dict or list
|
|
91
|
+
unwrap({"id": 1, "name": "Alice"})
|
|
92
|
+
unwrap([1, 2, 3])
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## HTTP status override
|
|
98
|
+
|
|
99
|
+
Pass the HTTP status code to let it override the body:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
env = unwrap(response.json(), status=response.status_code)
|
|
103
|
+
env.ok # True if 2xx, False otherwise
|
|
104
|
+
env.status # the actual HTTP status code
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## Safe data access
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
env = unwrap({"success": False, "error": "Not found"})
|
|
113
|
+
|
|
114
|
+
# Raises UnwraprError if not ok
|
|
115
|
+
data = env.unwrap()
|
|
116
|
+
|
|
117
|
+
# Returns default if not ok — never raises
|
|
118
|
+
data = env.unwrap_or([])
|
|
119
|
+
data = env.unwrap_or(None)
|
|
120
|
+
|
|
121
|
+
# Use as a boolean
|
|
122
|
+
if env:
|
|
123
|
+
print(env.data)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## Custom strategies
|
|
129
|
+
|
|
130
|
+
Add your own shape detection logic:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from unwrapr import unwrap, DEFAULT_STRATEGIES
|
|
134
|
+
|
|
135
|
+
def my_api_strategy(raw):
|
|
136
|
+
if isinstance(raw, dict) and "response" in raw:
|
|
137
|
+
return {
|
|
138
|
+
"ok": raw["response"]["success"],
|
|
139
|
+
"data": raw["response"]["body"],
|
|
140
|
+
"error": raw["response"].get("error"),
|
|
141
|
+
"status": None,
|
|
142
|
+
"meta": {},
|
|
143
|
+
}
|
|
144
|
+
return None # return None to try the next strategy
|
|
145
|
+
|
|
146
|
+
env = unwrap(response, strategies=[my_api_strategy] + DEFAULT_STRATEGIES)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## Works great with petchr
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
from petchr import petch
|
|
155
|
+
from unwrapr import unwrap
|
|
156
|
+
|
|
157
|
+
resp = petch("https://api.example.com/users/1")
|
|
158
|
+
env = unwrap(resp.data, status=resp.status_code)
|
|
159
|
+
|
|
160
|
+
if env:
|
|
161
|
+
print(env.data)
|
|
162
|
+
else:
|
|
163
|
+
print(f"Error: {env.error}")
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## The Envelope
|
|
169
|
+
|
|
170
|
+
Every call returns an `Envelope`:
|
|
171
|
+
|
|
172
|
+
| Field | Type | Description |
|
|
173
|
+
|----------|------------|--------------------------------------|
|
|
174
|
+
| `ok` | `bool` | True if response is successful |
|
|
175
|
+
| `data` | `Any` | The extracted payload |
|
|
176
|
+
| `error` | `str|None` | Error message if not ok |
|
|
177
|
+
| `status` | `int|None` | HTTP status code |
|
|
178
|
+
| `meta` | `dict` | Extra fields (pagination, links etc) |
|
|
179
|
+
| `raw` | `Any` | Original response before normalising |
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## License
|
|
184
|
+
|
|
185
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/unwrapr/__init__.py
|
|
5
|
+
src/unwrapr/core.py
|
|
6
|
+
src/unwrapr/envelope.py
|
|
7
|
+
src/unwrapr/exceptions.py
|
|
8
|
+
src/unwrapr/strategies.py
|
|
9
|
+
src/unwrapr.egg-info/PKG-INFO
|
|
10
|
+
src/unwrapr.egg-info/SOURCES.txt
|
|
11
|
+
src/unwrapr.egg-info/dependency_links.txt
|
|
12
|
+
src/unwrapr.egg-info/top_level.txt
|
|
13
|
+
tests/test_unwrapr.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
unwrapr
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from unwrapr import unwrap, Envelope, UnwraprError
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class TestPlainStrategy:
|
|
6
|
+
def test_plain_dict(self):
|
|
7
|
+
env = unwrap({"id": 1, "name": "Alice"})
|
|
8
|
+
assert env.ok is True
|
|
9
|
+
assert env.data == {"id": 1, "name": "Alice"}
|
|
10
|
+
|
|
11
|
+
def test_plain_list(self):
|
|
12
|
+
env = unwrap([1, 2, 3])
|
|
13
|
+
assert env.ok is True
|
|
14
|
+
assert env.data == [1, 2, 3]
|
|
15
|
+
|
|
16
|
+
def test_plain_with_bad_status(self):
|
|
17
|
+
env = unwrap({"id": 1}, status=404)
|
|
18
|
+
assert env.ok is False
|
|
19
|
+
assert env.status == 404
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TestSuccessFlagStrategy:
|
|
23
|
+
def test_success_true(self):
|
|
24
|
+
env = unwrap({"success": True, "data": {"id": 1}})
|
|
25
|
+
assert env.ok is True
|
|
26
|
+
assert env.data == {"id": 1}
|
|
27
|
+
|
|
28
|
+
def test_success_false(self):
|
|
29
|
+
env = unwrap({"success": False, "message": "Not found"})
|
|
30
|
+
assert env.ok is False
|
|
31
|
+
assert env.error == "Not found"
|
|
32
|
+
|
|
33
|
+
def test_ok_flag(self):
|
|
34
|
+
env = unwrap({"ok": True, "result": {"id": 2}})
|
|
35
|
+
assert env.ok is True
|
|
36
|
+
assert env.data == {"id": 2}
|
|
37
|
+
|
|
38
|
+
def test_ok_false_with_error(self):
|
|
39
|
+
env = unwrap({"ok": False, "error": "Unauthorized"})
|
|
40
|
+
assert env.ok is False
|
|
41
|
+
assert env.error == "Unauthorized"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TestStatusCodeStrategy:
|
|
45
|
+
def test_200_status(self):
|
|
46
|
+
env = unwrap({"status": 200, "result": {"name": "Alice"}})
|
|
47
|
+
assert env.ok is True
|
|
48
|
+
assert env.data == {"name": "Alice"}
|
|
49
|
+
assert env.status == 200
|
|
50
|
+
|
|
51
|
+
def test_404_status(self):
|
|
52
|
+
env = unwrap({"status": 404, "message": "Not found"})
|
|
53
|
+
assert env.ok is False
|
|
54
|
+
assert env.error == "Not found"
|
|
55
|
+
assert env.status == 404
|
|
56
|
+
|
|
57
|
+
def test_500_status(self):
|
|
58
|
+
env = unwrap({"code": 500, "message": "Internal server error"})
|
|
59
|
+
assert env.ok is False
|
|
60
|
+
assert env.status == 500
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TestDataErrorStrategy:
|
|
64
|
+
def test_data_present(self):
|
|
65
|
+
env = unwrap({"data": {"id": 1}, "error": None})
|
|
66
|
+
assert env.ok is True
|
|
67
|
+
assert env.data == {"id": 1}
|
|
68
|
+
|
|
69
|
+
def test_error_present(self):
|
|
70
|
+
env = unwrap({"data": None, "error": "Something went wrong"})
|
|
71
|
+
assert env.ok is False
|
|
72
|
+
assert env.error == "Something went wrong"
|
|
73
|
+
|
|
74
|
+
def test_payload_key(self):
|
|
75
|
+
env = unwrap({"payload": {"user": "Alice"}, "error": None})
|
|
76
|
+
assert env.ok is True
|
|
77
|
+
assert env.data == {"user": "Alice"}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class TestJsonApiStrategy:
|
|
81
|
+
def test_jsonapi_success(self):
|
|
82
|
+
env = unwrap({
|
|
83
|
+
"data": [{"id": "1", "type": "user"}],
|
|
84
|
+
"included": [],
|
|
85
|
+
"meta": {"total": 1}
|
|
86
|
+
})
|
|
87
|
+
assert env.ok is True
|
|
88
|
+
assert env.data == [{"id": "1", "type": "user"}]
|
|
89
|
+
assert env.meta["total"] == 1
|
|
90
|
+
|
|
91
|
+
def test_jsonapi_errors(self):
|
|
92
|
+
env = unwrap({
|
|
93
|
+
"errors": [{"detail": "Resource not found"}],
|
|
94
|
+
"data": None,
|
|
95
|
+
"meta": {}
|
|
96
|
+
})
|
|
97
|
+
assert env.ok is False
|
|
98
|
+
assert "Resource not found" in env.error
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class TestEnvelope:
|
|
102
|
+
def test_bool_true(self):
|
|
103
|
+
env = Envelope(ok=True, data={"id": 1})
|
|
104
|
+
assert bool(env) is True
|
|
105
|
+
|
|
106
|
+
def test_bool_false(self):
|
|
107
|
+
env = Envelope(ok=False, error="Failed")
|
|
108
|
+
assert bool(env) is False
|
|
109
|
+
|
|
110
|
+
def test_unwrap_ok(self):
|
|
111
|
+
env = Envelope(ok=True, data={"id": 1})
|
|
112
|
+
assert env.unwrap() == {"id": 1}
|
|
113
|
+
|
|
114
|
+
def test_unwrap_raises(self):
|
|
115
|
+
env = Envelope(ok=False, error="Failed")
|
|
116
|
+
with pytest.raises(UnwraprError):
|
|
117
|
+
env.unwrap()
|
|
118
|
+
|
|
119
|
+
def test_unwrap_or(self):
|
|
120
|
+
env = Envelope(ok=False, error="Failed")
|
|
121
|
+
assert env.unwrap_or("default") == "default"
|
|
122
|
+
|
|
123
|
+
def test_unwrap_or_ok(self):
|
|
124
|
+
env = Envelope(ok=True, data={"id": 1})
|
|
125
|
+
assert env.unwrap_or("default") == {"id": 1}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class TestStatusOverride:
|
|
129
|
+
def test_status_overrides_body_ok(self):
|
|
130
|
+
env = unwrap({"success": True, "data": {"id": 1}}, status=201)
|
|
131
|
+
assert env.ok is True
|
|
132
|
+
assert env.status == 201
|
|
133
|
+
|
|
134
|
+
def test_status_overrides_body_fail(self):
|
|
135
|
+
env = unwrap({"success": True, "data": {"id": 1}}, status=500)
|
|
136
|
+
assert env.ok is False
|
|
137
|
+
assert env.status == 500
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class TestCustomStrategy:
|
|
141
|
+
def test_custom_strategy(self):
|
|
142
|
+
def my_strategy(raw):
|
|
143
|
+
if isinstance(raw, dict) and "mine" in raw:
|
|
144
|
+
return {"ok": True, "data": raw["mine"], "error": None, "status": None, "meta": {}}
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
env = unwrap({"mine": {"id": 99}}, strategies=[my_strategy])
|
|
148
|
+
assert env.ok is True
|
|
149
|
+
assert env.data == {"id": 99}
|
|
150
|
+
|
|
151
|
+
def test_strict_raises_on_no_match(self):
|
|
152
|
+
from unwrapr import UnwraprError
|
|
153
|
+
with pytest.raises(UnwraprError):
|
|
154
|
+
unwrap("plain string", strategies=[], strict=True)
|