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 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
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,3 @@
1
+ class UnwraprError(Exception):
2
+ """Raised when unwrapr cannot normalise a response."""
3
+ pass
@@ -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
+ 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)