pyattackforge 0.0.1__py3-none-any.whl

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.
@@ -0,0 +1,139 @@
1
+ """Resource: notes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, Optional
6
+ import os
7
+ import base64
8
+
9
+ from .base import BaseResource
10
+ from ..exceptions import APIError
11
+
12
+
13
+ class NotesResource(BaseResource):
14
+ """Notes API resource wrapper."""
15
+
16
+ def create_remediation_note(self, vulnerability_id: str, payload: Dict[str, Any]) -> Any:
17
+ return self._post(f"/api/ss/vulnerability/{vulnerability_id}/remediationNote", json=payload)
18
+
19
+ def update_remediation_note(self, vulnerability_id: str, remediation_note_id: str, payload: Dict[str, Any]) -> Any:
20
+ return self._put(
21
+ f"/api/ss/vulnerability/{vulnerability_id}/remediationNote/{remediation_note_id}",
22
+ json=payload,
23
+ )
24
+
25
+ def upload_remediation_note_file(self, vulnerability_id: str, remediation_note_id: str, file_path: str) -> Any:
26
+ if not os.path.isfile(file_path):
27
+ raise FileNotFoundError(file_path)
28
+ with open(file_path, "rb") as handle:
29
+ return self._post_files(
30
+ f"/api/ss/vulnerability/{vulnerability_id}/remediationNote/{remediation_note_id}/file",
31
+ files={"file": (os.path.basename(file_path), handle)},
32
+ )
33
+
34
+ def download_remediation_note_file(
35
+ self,
36
+ vulnerability_id: str,
37
+ remediation_note_id: str,
38
+ file_name: str,
39
+ *,
40
+ project_id: Optional[str] = None,
41
+ allow_report_fallback: bool = True,
42
+ ) -> Any:
43
+ """
44
+ Download a remediation note file.
45
+
46
+ If the SSAPI download endpoint fails, optionally fall back to report data
47
+ (base64) when available.
48
+ """
49
+ try:
50
+ return self._get(
51
+ f"/api/ss/vulnerability/{vulnerability_id}/remediationNote/{remediation_note_id}/file/{file_name}"
52
+ )
53
+ except APIError as exc:
54
+ if not allow_report_fallback:
55
+ raise
56
+ resolved_project_id = project_id or self._find_project_id_for_vulnerability(vulnerability_id)
57
+ if not resolved_project_id:
58
+ raise
59
+ report = self._post(
60
+ f"/api/ss/project/{resolved_project_id}/report/raw",
61
+ json={"excludeBinaries": False, "vulnerabilityIds": [vulnerability_id]},
62
+ )
63
+ file_entry = self._find_remediation_note_file_from_report(
64
+ report, vulnerability_id, remediation_note_id, file_name
65
+ )
66
+ if file_entry is None:
67
+ report = self._post(
68
+ f"/api/ss/project/{resolved_project_id}/report/raw",
69
+ json={"excludeBinaries": False},
70
+ )
71
+ file_entry = self._find_remediation_note_file_from_report(
72
+ report, vulnerability_id, remediation_note_id, file_name
73
+ )
74
+ if isinstance(file_entry, dict):
75
+ payload = file_entry.get("fileBase64")
76
+ if isinstance(payload, str) and payload:
77
+ try:
78
+ padded = payload + ("=" * (-len(payload) % 4))
79
+ return base64.b64decode(padded)
80
+ except (ValueError, TypeError):
81
+ pass
82
+ raise exc
83
+
84
+ def _find_project_id_for_vulnerability(self, vulnerability_id: str) -> Optional[str]:
85
+ data = self._get("/api/ss/projects-and-vulnerabilities")
86
+ projects = data.get("projects") if isinstance(data, dict) else None
87
+ if not isinstance(projects, list):
88
+ return None
89
+ for project in projects:
90
+ if not isinstance(project, dict):
91
+ continue
92
+ vulns = project.get("project_vulnerabilities")
93
+ if not isinstance(vulns, list):
94
+ continue
95
+ for vuln in vulns:
96
+ if not isinstance(vuln, dict):
97
+ continue
98
+ vid = vuln.get("vulnerability_id") or vuln.get("id")
99
+ if vid == vulnerability_id:
100
+ return project.get("project_id") or project.get("id")
101
+ return None
102
+
103
+ def _find_remediation_note_file_from_report(
104
+ self,
105
+ report: Any,
106
+ vulnerability_id: str,
107
+ remediation_note_id: str,
108
+ file_name: Optional[str],
109
+ ) -> Optional[Dict[str, Any]]:
110
+ if not isinstance(report, dict):
111
+ return None
112
+ vulnerabilities = report.get("vulnerabilities")
113
+ if not isinstance(vulnerabilities, list):
114
+ return None
115
+ for vuln in vulnerabilities:
116
+ if not isinstance(vuln, dict):
117
+ continue
118
+ vid = vuln.get("id") or vuln.get("vulnerability_id")
119
+ if vid != vulnerability_id:
120
+ continue
121
+ for asset in vuln.get("affected_assets") or []:
122
+ if not isinstance(asset, dict):
123
+ continue
124
+ for note in asset.get("remediation_notes") or []:
125
+ if not isinstance(note, dict):
126
+ continue
127
+ nid = note.get("id") or note.get("remediation_note_id") or note.get("note_id")
128
+ if nid != remediation_note_id:
129
+ continue
130
+ files = note.get("remediation_note_files") or []
131
+ if not isinstance(files, list):
132
+ continue
133
+ for entry in files:
134
+ if not isinstance(entry, dict):
135
+ continue
136
+ entry_name = entry.get("fileName") or entry.get("file_name") or entry.get("name")
137
+ if file_name is None or entry_name == file_name:
138
+ return entry
139
+ return None
@@ -0,0 +1,154 @@
1
+ """Resource: projects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, Optional, Iterable, List
6
+ import os
7
+
8
+ from ..cache import TTLCache
9
+ from .base import BaseResource
10
+
11
+
12
+ class ProjectsResource(BaseResource):
13
+ """Projects API resource wrapper."""
14
+
15
+ def __init__(self, transport) -> None: # type: ignore[override]
16
+ super().__init__(transport)
17
+ self._cache = TTLCache(default_ttl=300.0)
18
+
19
+ def create_project(self, payload: Dict[str, Any]) -> Any:
20
+ return self._post("/api/ss/project", json=payload)
21
+
22
+ def get_project(self, project_id: str, params: Optional[Dict[str, Any]] = None, *, force_refresh: bool = False) -> Any:
23
+ cache_key = f"project:{project_id}"
24
+ if params is None and not force_refresh:
25
+ cached = self._cache.get(cache_key)
26
+ if cached is not None:
27
+ return cached
28
+ data = self._get(f"/api/ss/project/{project_id}", params=params)
29
+ if params is None:
30
+ self._cache.set(cache_key, data)
31
+ return data
32
+
33
+ def get_projects(self, params: Optional[Dict[str, Any]] = None) -> Any:
34
+ return self._get("/api/ss/projects", params=params)
35
+
36
+ def get_projects_and_vulnerabilities(self, params: Optional[Dict[str, Any]] = None) -> Any:
37
+ return self._get("/api/ss/projects-and-vulnerabilities", params=params)
38
+
39
+ def update_project(self, project_id: str, payload: Dict[str, Any]) -> Any:
40
+ data = self._put(f"/api/ss/project/{project_id}", json=payload)
41
+ self._cache.set(f"project:{project_id}", data)
42
+ return data
43
+
44
+ def archive_project(self, project_id: str) -> Any:
45
+ return self._put(f"/api/ss/project/{project_id}/archive")
46
+
47
+ def restore_project(self, project_id: str) -> Any:
48
+ return self._put(f"/api/ss/project/{project_id}/restore")
49
+
50
+ def destroy_projects(self, project_ids: Iterable[str], keep_logs: Optional[bool] = None) -> Any:
51
+ payload: Dict[str, Any] = {"project_ids": list(project_ids)}
52
+ if keep_logs is not None:
53
+ payload["keep_logs"] = keep_logs
54
+ return self._delete("/api/ss/project/destroy", json=payload)
55
+
56
+ def clone_project(self, project_id: str, payload: Optional[Dict[str, Any]] = None) -> Any:
57
+ return self._post(f"/api/ss/project/{project_id}/clone", json=payload or {})
58
+
59
+ def create_scope(self, project_id: str, payload: Dict[str, Any]) -> Any:
60
+ return self._post(f"/api/ss/project/{project_id}/assets", json=payload)
61
+
62
+ def update_scope(self, project_id: str, asset_id: str, payload: Dict[str, Any]) -> Any:
63
+ return self._put(f"/api/ss/project/{project_id}/asset/{asset_id}", json=payload)
64
+
65
+ def get_project_workspace(self, project_id: str) -> Any:
66
+ return self._get(f"/api/ss/project/{project_id}/workspace")
67
+
68
+ def upload_workspace_file(self, project_id: str, file_path: str) -> Any:
69
+ if not os.path.isfile(file_path):
70
+ raise FileNotFoundError(file_path)
71
+ with open(file_path, "rb") as handle:
72
+ return self._post_files(
73
+ f"/api/ss/project/{project_id}/workspace/file",
74
+ files={"file": (os.path.basename(file_path), handle)},
75
+ )
76
+
77
+ def download_workspace_file(self, project_id: str, file_name: str) -> Any:
78
+ return self._get(f"/api/ss/project/{project_id}/workspace/{file_name}")
79
+
80
+ def get_project_notes(self, project_id: str) -> Any:
81
+ return self._get(f"/api/ss/project/{project_id}/notes")
82
+
83
+ def create_project_note(self, project_id: str, payload: Dict[str, Any]) -> Any:
84
+ return self._post(f"/api/ss/project/{project_id}/note", json=payload)
85
+
86
+ def update_project_note(self, project_id: str, note_id: str, payload: Dict[str, Any]) -> Any:
87
+ return self._put(f"/api/ss/project/{project_id}/note/{note_id}", json=payload)
88
+
89
+ def create_project_workspace_note(self, project_id: str, payload: Dict[str, Any]) -> Any:
90
+ return self._post(f"/api/ss/project/{project_id}/workspace/note", json=payload)
91
+
92
+ def update_project_workspace_note(self, project_id: str, note_id: str, payload: Dict[str, Any]) -> Any:
93
+ return self._put(f"/api/ss/project/{project_id}/workspace/note/{note_id}", json=payload)
94
+
95
+ def get_project_membership_administrators(self, project_id: str) -> Any:
96
+ return self._get(f"/api/ss/project/{project_id}/member-admins")
97
+
98
+ def _normalize_member_admin_payload(self, payload: Dict[str, Any]) -> Dict[str, Any]:
99
+ if "member_admins" in payload:
100
+ return payload
101
+ return {"member_admins": [payload]}
102
+
103
+ def add_project_membership_administrators(self, project_id: str, payload: Dict[str, Any]) -> Any:
104
+ return self._post(
105
+ f"/api/ss/project/{project_id}/member-admins",
106
+ json=self._normalize_member_admin_payload(payload),
107
+ )
108
+
109
+ def update_project_membership_administrators(self, project_id: str, payload: Dict[str, Any]) -> Any:
110
+ return self._put(
111
+ f"/api/ss/project/{project_id}/member-admins",
112
+ json=self._normalize_member_admin_payload(payload),
113
+ )
114
+
115
+ def remove_project_membership_administrators(self, project_id: str, payload: Dict[str, Any]) -> Any:
116
+ return self._delete(
117
+ f"/api/ss/project/{project_id}/member-admins",
118
+ json=self._normalize_member_admin_payload(payload),
119
+ )
120
+
121
+ def invite_user_to_project(self, project_id: str, payload: Dict[str, Any]) -> Any:
122
+ return self._post(f"/api/ss/project/{project_id}/invite", json=payload)
123
+
124
+ def invite_users_to_project_team(self, project_id: str, payload: Dict[str, Any]) -> Any:
125
+ return self._post(f"/api/ss/project/{project_id}/team/invite", json=payload)
126
+
127
+ def remove_project_team_members(self, project_id: str, payload: Dict[str, Any]) -> Any:
128
+ return self._put(f"/api/ss/project/{project_id}/team/remove", json=payload)
129
+
130
+ def update_user_access_on_project(self, project_id: str, user_id: str, payload: Dict[str, Any]) -> Any:
131
+ return self._put(f"/api/ss/project/{project_id}/access/{user_id}", json=payload)
132
+
133
+ def extract_projects_list(self, projects_data: Any) -> List[Dict[str, Any]]:
134
+ if not isinstance(projects_data, dict):
135
+ return []
136
+ projects = projects_data.get("projects") or (projects_data.get("data") or {}).get("projects") or []
137
+ return [proj for proj in projects if isinstance(proj, dict)] if isinstance(projects, list) else []
138
+
139
+ def find_project_by_name(
140
+ self, name: str, *, projects_data: Optional[Any] = None, case_insensitive: bool = True
141
+ ) -> Optional[Dict[str, Any]]:
142
+ data = projects_data if projects_data is not None else self.get_projects()
143
+ desired = self._normalize_string(name) if case_insensitive else name
144
+ for project in self.extract_projects_list(data):
145
+ project_name = project.get("project_name") or project.get("name")
146
+ if not isinstance(project_name, str):
147
+ continue
148
+ candidate = self._normalize_string(project_name) if case_insensitive else project_name
149
+ if candidate == desired:
150
+ return project
151
+ return None
152
+
153
+ def _normalize_string(self, value: str) -> str:
154
+ return value.strip().lower()
@@ -0,0 +1,20 @@
1
+ """Resource: reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, Optional
6
+
7
+ from .base import BaseResource
8
+
9
+
10
+ class ReportsResource(BaseResource):
11
+ """Reports API resource wrapper."""
12
+
13
+ def get_project_report(self, project_id: str, report_type: str, params: Optional[Dict[str, Any]] = None) -> Any:
14
+ return self._get(f"/api/ss/project/{project_id}/report/{report_type}", params=params)
15
+
16
+ def get_project_report_data(self, project_id: str, report_type: str, payload: Dict[str, Any]) -> Any:
17
+ return self._post(f"/api/ss/project/{project_id}/report/{report_type}", json=payload)
18
+
19
+ def update_exec_summary_notes(self, project_id: str, payload: Dict[str, Any]) -> Any:
20
+ return self._put(f"/api/ss/project/{project_id}/execSummaryNotes", json=payload)
@@ -0,0 +1,59 @@
1
+ """Resource: users."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, Optional
6
+ import urllib.parse
7
+
8
+ from .base import BaseResource
9
+
10
+
11
+ class UsersResource(BaseResource):
12
+ """Users API resource wrapper."""
13
+
14
+ def create_user(self, payload: Dict[str, Any]) -> Any:
15
+ return self._post("/api/ss/user", json=payload)
16
+
17
+ def create_users(self, users: Any) -> Any:
18
+ return self._post("/api/ss/users", json=users)
19
+
20
+ def get_user(self, user_id: str) -> Any:
21
+ return self._get(f"/api/ss/users/{user_id}")
22
+
23
+ def get_users(self, params: Optional[Dict[str, Any]] = None) -> Any:
24
+ return self._get("/api/ss/users", params=params)
25
+
26
+ def get_user_by_email(self, email: str) -> Any:
27
+ email_value = urllib.parse.quote(email, safe="")
28
+ return self._get(f"/api/ss/users/email/{email_value}")
29
+
30
+ def get_user_by_username(self, username: str) -> Any:
31
+ username_value = urllib.parse.quote(username, safe="")
32
+ return self._get(f"/api/ss/users/username/{username_value}")
33
+
34
+ def update_user(self, user_id: str, payload: Dict[str, Any]) -> Any:
35
+ return self._put(f"/api/ss/user/{user_id}", json=payload)
36
+
37
+ def activate_user(self, user_id: str) -> Any:
38
+ return self._put(f"/api/ss/user/{user_id}/activate")
39
+
40
+ def deactivate_user(self, user_id: str) -> Any:
41
+ return self._put(f"/api/ss/user/{user_id}/deactivate")
42
+
43
+ def add_user_to_group(self, payload: Dict[str, Any]) -> Any:
44
+ return self._post("/api/ss/group/user", json=payload)
45
+
46
+ def update_user_access_on_group(self, user_id: str, payload: Dict[str, Any]) -> Any:
47
+ return self._put(f"/api/ss/group/user/{user_id}", json=payload)
48
+
49
+ def get_user_groups(self, user_id: str) -> Any:
50
+ return self._get(f"/api/ss/user/{user_id}/groups")
51
+
52
+ def get_user_projects(self, user_id: str) -> Any:
53
+ return self._get(f"/api/ss/user/{user_id}/projects")
54
+
55
+ def get_user_audit_logs(self, user_id: str, params: Optional[Dict[str, Any]] = None) -> Any:
56
+ return self._get(f"/api/ss/user/{user_id}/auditlogs", params=params)
57
+
58
+ def get_user_login_history(self, user_id: str, params: Optional[Dict[str, Any]] = None) -> Any:
59
+ return self._get(f"/api/ss/user/{user_id}/logins", params=params)
@@ -0,0 +1,79 @@
1
+ """Resource: writeups."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, Optional
6
+ import os
7
+
8
+ from ..cache import TTLCache
9
+ from .base import BaseResource
10
+
11
+
12
+ class WriteupsResource(BaseResource):
13
+ """Writeups (library vulnerabilities) API resource wrapper."""
14
+
15
+ def __init__(self, transport) -> None: # type: ignore[override]
16
+ super().__init__(transport)
17
+ self._cache = TTLCache(default_ttl=300.0)
18
+
19
+ def get_writeups(self, params: Optional[Dict[str, Any]] = None, *, force_refresh: bool = False) -> Any:
20
+ cache_key = "writeups:list"
21
+ if params is None and not force_refresh:
22
+ cached = self._cache.get(cache_key)
23
+ if cached is not None:
24
+ return cached
25
+ data = self._get("/api/ss/library", params=params)
26
+ if params is None:
27
+ self._cache.set(cache_key, data)
28
+ return data
29
+
30
+ def get_writeup_files(self, *, writeup_id: Optional[str] = None, name: Optional[str] = None) -> list[Dict[str, Any]]:
31
+ """
32
+ Fetch file entries for a writeup (library vulnerability).
33
+
34
+ The SSAPI returns file storage names under `files[].storage_name` when
35
+ querying `/api/ss/library` with `id` or `name` filters.
36
+ """
37
+ params: Dict[str, Any] = {}
38
+ if writeup_id:
39
+ params["id"] = writeup_id
40
+ if name:
41
+ params["name"] = name
42
+ data = self.get_writeups(params=params, force_refresh=True)
43
+ if isinstance(data, dict):
44
+ candidates = data.get("vulnerabilities") or data.get("library") or data.get("issues") or []
45
+ elif isinstance(data, list):
46
+ candidates = data
47
+ else:
48
+ candidates = []
49
+ if not isinstance(candidates, list):
50
+ return []
51
+ for entry in candidates:
52
+ if not isinstance(entry, dict):
53
+ continue
54
+ if writeup_id and entry.get("id") != writeup_id:
55
+ continue
56
+ if name and entry.get("title") != name:
57
+ continue
58
+ files = entry.get("files")
59
+ if isinstance(files, list):
60
+ return files
61
+ return []
62
+
63
+ def create_writeup(self, payload: Dict[str, Any]) -> Any:
64
+ return self._post("/api/ss/library/vulnerability", json=payload)
65
+
66
+ def update_writeup(self, writeup_id: str, payload: Dict[str, Any]) -> Any:
67
+ return self._put(f"/api/ss/library/{writeup_id}", json=payload)
68
+
69
+ def upload_writeup_file(self, writeup_id: str, file_path: str) -> Any:
70
+ if not os.path.isfile(file_path):
71
+ raise FileNotFoundError(file_path)
72
+ with open(file_path, "rb") as handle:
73
+ return self._post_files(
74
+ f"/api/ss/library/{writeup_id}/file",
75
+ files={"file": (os.path.basename(file_path), handle)},
76
+ )
77
+
78
+ def download_writeup_file(self, writeup_id: str, file_name: str) -> Any:
79
+ return self._get(f"/api/ss/library/{writeup_id}/file/{file_name}")
@@ -0,0 +1,134 @@
1
+ """HTTP transport for AttackForge SSAPI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, Dict, Optional
7
+ import time
8
+ import httpx
9
+
10
+ from .exceptions import APIError
11
+ from .config import ClientConfig
12
+
13
+
14
+ @dataclass
15
+ class TransportResponse:
16
+ status_code: int
17
+ data: Any
18
+ headers: Dict[str, str]
19
+
20
+
21
+ class AttackForgeTransport:
22
+ def __init__(self, config: ClientConfig, client: Optional[httpx.Client] = None) -> None:
23
+ self._config = config
24
+ self._client = client or httpx.Client(
25
+ base_url=config.base_url.rstrip("/"),
26
+ timeout=config.timeout,
27
+ headers={
28
+ "X-SSAPI-KEY": config.api_key,
29
+ "Connection": "close",
30
+ "User-Agent": config.user_agent,
31
+ },
32
+ http2=config.http2,
33
+ )
34
+ self._http2_client: Optional[httpx.Client] = None
35
+
36
+ def close(self) -> None:
37
+ self._client.close()
38
+ if self._http2_client is not None:
39
+ self._http2_client.close()
40
+
41
+ def _get_http2_client(self) -> httpx.Client:
42
+ if self._http2_client is None:
43
+ self._http2_client = httpx.Client(
44
+ base_url=self._config.base_url.rstrip("/"),
45
+ timeout=self._config.timeout,
46
+ headers={
47
+ "X-SSAPI-KEY": self._config.api_key,
48
+ "Connection": "close",
49
+ "User-Agent": self._config.user_agent,
50
+ },
51
+ http2=True,
52
+ )
53
+ return self._http2_client
54
+
55
+ def request(
56
+ self,
57
+ method: str,
58
+ path: str,
59
+ *,
60
+ params: Optional[Dict[str, Any]] = None,
61
+ json: Optional[Dict[str, Any]] = None,
62
+ files: Optional[Dict[str, Any]] = None,
63
+ data: Optional[Dict[str, Any]] = None,
64
+ headers: Optional[Dict[str, str]] = None,
65
+ timeout: Optional[float] = None,
66
+ retries: Optional[int] = None,
67
+ ) -> TransportResponse:
68
+ attempt = 0
69
+ max_retries = self._config.max_retries if retries is None else retries
70
+ backoff = self._config.backoff_factor
71
+ while True:
72
+ try:
73
+ request_headers = None
74
+ if headers:
75
+ request_headers = headers
76
+ if files:
77
+ request_headers = dict(self._client.headers)
78
+ request_headers.pop("Content-Type", None)
79
+ if headers:
80
+ request_headers.update(headers)
81
+ response = self._client.request(
82
+ method=method,
83
+ url=path,
84
+ params=params,
85
+ json=json,
86
+ files=files,
87
+ data=data,
88
+ headers=request_headers,
89
+ timeout=timeout,
90
+ )
91
+ except httpx.HTTPError as exc:
92
+ if attempt >= max_retries:
93
+ raise APIError(status_code=0, message=str(exc)) from exc
94
+ time.sleep(backoff * (2 ** attempt))
95
+ attempt += 1
96
+ continue
97
+
98
+ if response.status_code >= 500 and files:
99
+ try:
100
+ response = self._get_http2_client().request(
101
+ method=method,
102
+ url=path,
103
+ params=params,
104
+ json=json,
105
+ files=files,
106
+ data=data,
107
+ headers=request_headers,
108
+ timeout=timeout,
109
+ )
110
+ except httpx.HTTPError:
111
+ pass
112
+
113
+ if response.status_code in (429, 500, 502, 503, 504) and attempt < max_retries:
114
+ time.sleep(backoff * (2 ** attempt))
115
+ attempt += 1
116
+ continue
117
+
118
+ data_out: Any
119
+ if response.headers.get("content-type", "").startswith("application/json"):
120
+ try:
121
+ data_out = response.json()
122
+ except ValueError:
123
+ data_out = response.text
124
+ else:
125
+ data_out = response.content
126
+
127
+ if response.status_code >= 400:
128
+ raise APIError(response.status_code, response.text, payload={"data": data_out})
129
+
130
+ return TransportResponse(
131
+ status_code=response.status_code,
132
+ data=data_out,
133
+ headers=dict(response.headers),
134
+ )