bt-cli 0.4.13__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.
- bt_cli/__init__.py +3 -0
- bt_cli/cli.py +830 -0
- bt_cli/commands/__init__.py +1 -0
- bt_cli/commands/configure.py +415 -0
- bt_cli/commands/learn.py +229 -0
- bt_cli/commands/quick.py +784 -0
- bt_cli/core/__init__.py +1 -0
- bt_cli/core/auth.py +213 -0
- bt_cli/core/client.py +313 -0
- bt_cli/core/config.py +393 -0
- bt_cli/core/config_file.py +420 -0
- bt_cli/core/csv_utils.py +91 -0
- bt_cli/core/errors.py +247 -0
- bt_cli/core/output.py +205 -0
- bt_cli/core/prompts.py +87 -0
- bt_cli/core/rest_debug.py +221 -0
- bt_cli/data/CLAUDE.md +94 -0
- bt_cli/data/__init__.py +0 -0
- bt_cli/data/skills/bt/SKILL.md +108 -0
- bt_cli/data/skills/entitle/SKILL.md +170 -0
- bt_cli/data/skills/epmw/SKILL.md +144 -0
- bt_cli/data/skills/pra/SKILL.md +150 -0
- bt_cli/data/skills/pws/SKILL.md +198 -0
- bt_cli/entitle/__init__.py +1 -0
- bt_cli/entitle/client/__init__.py +5 -0
- bt_cli/entitle/client/base.py +443 -0
- bt_cli/entitle/commands/__init__.py +24 -0
- bt_cli/entitle/commands/accounts.py +53 -0
- bt_cli/entitle/commands/applications.py +39 -0
- bt_cli/entitle/commands/auth.py +68 -0
- bt_cli/entitle/commands/bundles.py +218 -0
- bt_cli/entitle/commands/integrations.py +60 -0
- bt_cli/entitle/commands/permissions.py +70 -0
- bt_cli/entitle/commands/policies.py +97 -0
- bt_cli/entitle/commands/resources.py +131 -0
- bt_cli/entitle/commands/roles.py +74 -0
- bt_cli/entitle/commands/users.py +123 -0
- bt_cli/entitle/commands/workflows.py +187 -0
- bt_cli/entitle/models/__init__.py +31 -0
- bt_cli/entitle/models/bundle.py +28 -0
- bt_cli/entitle/models/common.py +37 -0
- bt_cli/entitle/models/integration.py +30 -0
- bt_cli/entitle/models/permission.py +27 -0
- bt_cli/entitle/models/policy.py +25 -0
- bt_cli/entitle/models/resource.py +29 -0
- bt_cli/entitle/models/role.py +28 -0
- bt_cli/entitle/models/user.py +24 -0
- bt_cli/entitle/models/workflow.py +55 -0
- bt_cli/epmw/__init__.py +1 -0
- bt_cli/epmw/client/__init__.py +5 -0
- bt_cli/epmw/client/base.py +848 -0
- bt_cli/epmw/commands/__init__.py +33 -0
- bt_cli/epmw/commands/audits.py +250 -0
- bt_cli/epmw/commands/auth.py +55 -0
- bt_cli/epmw/commands/computers.py +140 -0
- bt_cli/epmw/commands/events.py +233 -0
- bt_cli/epmw/commands/groups.py +215 -0
- bt_cli/epmw/commands/policies.py +673 -0
- bt_cli/epmw/commands/quick.py +348 -0
- bt_cli/epmw/commands/requests.py +224 -0
- bt_cli/epmw/commands/roles.py +78 -0
- bt_cli/epmw/commands/tasks.py +38 -0
- bt_cli/epmw/commands/users.py +219 -0
- bt_cli/epmw/models/__init__.py +1 -0
- bt_cli/pra/__init__.py +1 -0
- bt_cli/pra/client/__init__.py +5 -0
- bt_cli/pra/client/base.py +618 -0
- bt_cli/pra/commands/__init__.py +30 -0
- bt_cli/pra/commands/auth.py +55 -0
- bt_cli/pra/commands/import_export.py +442 -0
- bt_cli/pra/commands/jump_clients.py +139 -0
- bt_cli/pra/commands/jump_groups.py +146 -0
- bt_cli/pra/commands/jump_items.py +638 -0
- bt_cli/pra/commands/jumpoints.py +95 -0
- bt_cli/pra/commands/policies.py +197 -0
- bt_cli/pra/commands/quick.py +470 -0
- bt_cli/pra/commands/teams.py +81 -0
- bt_cli/pra/commands/users.py +87 -0
- bt_cli/pra/commands/vault.py +564 -0
- bt_cli/pra/models/__init__.py +27 -0
- bt_cli/pra/models/common.py +12 -0
- bt_cli/pra/models/jump_client.py +25 -0
- bt_cli/pra/models/jump_group.py +15 -0
- bt_cli/pra/models/jump_item.py +72 -0
- bt_cli/pra/models/jumpoint.py +19 -0
- bt_cli/pra/models/team.py +14 -0
- bt_cli/pra/models/user.py +17 -0
- bt_cli/pra/models/vault.py +45 -0
- bt_cli/pws/__init__.py +1 -0
- bt_cli/pws/client/__init__.py +5 -0
- bt_cli/pws/client/base.py +356 -0
- bt_cli/pws/client/beyondinsight.py +869 -0
- bt_cli/pws/client/passwordsafe.py +1786 -0
- bt_cli/pws/commands/__init__.py +33 -0
- bt_cli/pws/commands/accounts.py +372 -0
- bt_cli/pws/commands/assets.py +311 -0
- bt_cli/pws/commands/auth.py +166 -0
- bt_cli/pws/commands/clouds.py +221 -0
- bt_cli/pws/commands/config.py +344 -0
- bt_cli/pws/commands/credentials.py +347 -0
- bt_cli/pws/commands/databases.py +306 -0
- bt_cli/pws/commands/directories.py +199 -0
- bt_cli/pws/commands/functional.py +298 -0
- bt_cli/pws/commands/import_export.py +452 -0
- bt_cli/pws/commands/platforms.py +118 -0
- bt_cli/pws/commands/quick.py +1646 -0
- bt_cli/pws/commands/search.py +256 -0
- bt_cli/pws/commands/secrets.py +1343 -0
- bt_cli/pws/commands/systems.py +389 -0
- bt_cli/pws/commands/users.py +415 -0
- bt_cli/pws/commands/workgroups.py +166 -0
- bt_cli/pws/config.py +18 -0
- bt_cli/pws/models/__init__.py +19 -0
- bt_cli/pws/models/account.py +186 -0
- bt_cli/pws/models/asset.py +102 -0
- bt_cli/pws/models/common.py +132 -0
- bt_cli/pws/models/system.py +121 -0
- bt_cli-0.4.13.dist-info/METADATA +417 -0
- bt_cli-0.4.13.dist-info/RECORD +121 -0
- bt_cli-0.4.13.dist-info/WHEEL +4 -0
- bt_cli-0.4.13.dist-info/entry_points.txt +2 -0
bt_cli/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core shared utilities for BeyondTrust CLI."""
|
bt_cli/core/auth.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Authentication strategies for BeyondTrust products."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AuthStrategy(ABC):
|
|
13
|
+
"""Base class for authentication strategies.
|
|
14
|
+
|
|
15
|
+
Each product may use different authentication mechanisms.
|
|
16
|
+
This abstraction allows the base client to work with any auth method.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def get_headers(self) -> dict[str, str]:
|
|
21
|
+
"""Get authentication headers for requests.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
Dictionary of headers to include in requests
|
|
25
|
+
"""
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
def authenticate(self, client: httpx.Client) -> dict:
|
|
29
|
+
"""Perform any initial authentication if needed.
|
|
30
|
+
|
|
31
|
+
Some auth methods (like OAuth) require an initial token exchange.
|
|
32
|
+
Others (like API key) don't need this step.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
client: HTTP client to use for auth requests
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Authentication response data (may be empty dict)
|
|
39
|
+
"""
|
|
40
|
+
return {}
|
|
41
|
+
|
|
42
|
+
def sign_out(self, client: httpx.Client) -> None:
|
|
43
|
+
"""Perform any cleanup/sign-out if needed.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
client: HTTP client to use for sign-out
|
|
47
|
+
"""
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class BearerTokenAuth(AuthStrategy):
|
|
52
|
+
"""Simple Bearer token authentication.
|
|
53
|
+
|
|
54
|
+
Used by: Entitle
|
|
55
|
+
|
|
56
|
+
The API key is passed directly as a Bearer token.
|
|
57
|
+
No session management required.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, api_key: str):
|
|
61
|
+
"""Initialize with API key.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
api_key: API key to use as Bearer token
|
|
65
|
+
"""
|
|
66
|
+
self.api_key = api_key
|
|
67
|
+
|
|
68
|
+
def __repr__(self) -> str:
|
|
69
|
+
"""Safe repr that doesn't expose credentials."""
|
|
70
|
+
return f"BearerTokenAuth(api_key='***')"
|
|
71
|
+
|
|
72
|
+
def get_headers(self) -> dict[str, str]:
|
|
73
|
+
"""Get Bearer auth header."""
|
|
74
|
+
return {"Authorization": f"Bearer {self.api_key}"}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class PSAuthKeyAuth(AuthStrategy):
|
|
78
|
+
"""PS-Auth header authentication for Password Safe.
|
|
79
|
+
|
|
80
|
+
Used by: Password Safe (API key method)
|
|
81
|
+
|
|
82
|
+
Uses custom PS-Auth header format with optional impersonation.
|
|
83
|
+
Requires session establishment via SignAppIn.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(self, api_key: str, run_as: Optional[str] = None):
|
|
87
|
+
"""Initialize with API key and optional impersonation.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
api_key: API key for authentication
|
|
91
|
+
run_as: Optional username for impersonation
|
|
92
|
+
"""
|
|
93
|
+
self.api_key = api_key
|
|
94
|
+
self.run_as = run_as
|
|
95
|
+
self._session_active = False
|
|
96
|
+
|
|
97
|
+
def __repr__(self) -> str:
|
|
98
|
+
"""Safe repr that doesn't expose credentials."""
|
|
99
|
+
run_as_str = f", run_as='{self.run_as}'" if self.run_as else ""
|
|
100
|
+
return f"PSAuthKeyAuth(api_key='***'{run_as_str})"
|
|
101
|
+
|
|
102
|
+
def get_headers(self) -> dict[str, str]:
|
|
103
|
+
"""Get PS-Auth header with optional runas."""
|
|
104
|
+
auth_value = f"PS-Auth key={self.api_key};"
|
|
105
|
+
if self.run_as:
|
|
106
|
+
auth_value += f" runas={self.run_as};"
|
|
107
|
+
return {"Authorization": auth_value}
|
|
108
|
+
|
|
109
|
+
def authenticate(self, client: httpx.Client) -> dict:
|
|
110
|
+
"""Establish session via SignAppIn.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
client: HTTP client configured with base URL
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Session info from SignAppIn response
|
|
117
|
+
"""
|
|
118
|
+
headers = self.get_headers()
|
|
119
|
+
headers["Content-Type"] = "application/json"
|
|
120
|
+
|
|
121
|
+
response = client.post("/Auth/SignAppIn", headers=headers)
|
|
122
|
+
response.raise_for_status()
|
|
123
|
+
self._session_active = True
|
|
124
|
+
return response.json()
|
|
125
|
+
|
|
126
|
+
def sign_out(self, client: httpx.Client) -> None:
|
|
127
|
+
"""Sign out and end session."""
|
|
128
|
+
if self._session_active:
|
|
129
|
+
try:
|
|
130
|
+
headers = self.get_headers()
|
|
131
|
+
headers["Content-Type"] = "application/json"
|
|
132
|
+
client.post("/Auth/Signout", headers=headers)
|
|
133
|
+
except Exception as e:
|
|
134
|
+
logger.debug(f"Sign out failed (best effort): {e}")
|
|
135
|
+
finally:
|
|
136
|
+
self._session_active = False
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class OAuthClientCredentials(AuthStrategy):
|
|
140
|
+
"""OAuth client credentials flow for Password Safe.
|
|
141
|
+
|
|
142
|
+
Used by: Password Safe (OAuth method)
|
|
143
|
+
|
|
144
|
+
Exchanges client_id/client_secret for access token,
|
|
145
|
+
then uses Bearer token for subsequent requests.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
def __init__(self, client_id: str, client_secret: str):
|
|
149
|
+
"""Initialize with OAuth credentials.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
client_id: OAuth client ID
|
|
153
|
+
client_secret: OAuth client secret
|
|
154
|
+
"""
|
|
155
|
+
self.client_id = client_id
|
|
156
|
+
self.client_secret = client_secret
|
|
157
|
+
self._access_token: Optional[str] = None
|
|
158
|
+
self._session_active = False
|
|
159
|
+
|
|
160
|
+
def __repr__(self) -> str:
|
|
161
|
+
"""Safe repr that doesn't expose credentials."""
|
|
162
|
+
return f"OAuthClientCredentials(client_id='{self.client_id}', client_secret='***')"
|
|
163
|
+
|
|
164
|
+
def get_headers(self) -> dict[str, str]:
|
|
165
|
+
"""Get Bearer auth header with access token."""
|
|
166
|
+
if self._access_token:
|
|
167
|
+
return {"Authorization": f"Bearer {self._access_token}"}
|
|
168
|
+
return {}
|
|
169
|
+
|
|
170
|
+
def authenticate(self, client: httpx.Client) -> dict:
|
|
171
|
+
"""Exchange credentials for token and establish session.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
client: HTTP client configured with base URL
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
Session info from SignAppIn response
|
|
178
|
+
"""
|
|
179
|
+
# Step 1: Get access token
|
|
180
|
+
token_response = client.post(
|
|
181
|
+
"/Auth/connect/token",
|
|
182
|
+
data={
|
|
183
|
+
"grant_type": "client_credentials",
|
|
184
|
+
"client_id": self.client_id,
|
|
185
|
+
"client_secret": self.client_secret,
|
|
186
|
+
},
|
|
187
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
188
|
+
)
|
|
189
|
+
token_response.raise_for_status()
|
|
190
|
+
token_data = token_response.json()
|
|
191
|
+
self._access_token = token_data.get("access_token")
|
|
192
|
+
|
|
193
|
+
# Step 2: Establish session
|
|
194
|
+
headers = self.get_headers()
|
|
195
|
+
headers["Content-Type"] = "application/json"
|
|
196
|
+
|
|
197
|
+
session_response = client.post("/Auth/SignAppIn", headers=headers)
|
|
198
|
+
session_response.raise_for_status()
|
|
199
|
+
self._session_active = True
|
|
200
|
+
return session_response.json()
|
|
201
|
+
|
|
202
|
+
def sign_out(self, client: httpx.Client) -> None:
|
|
203
|
+
"""Sign out and end session."""
|
|
204
|
+
if self._session_active:
|
|
205
|
+
try:
|
|
206
|
+
headers = self.get_headers()
|
|
207
|
+
headers["Content-Type"] = "application/json"
|
|
208
|
+
client.post("/Auth/Signout", headers=headers)
|
|
209
|
+
except Exception as e:
|
|
210
|
+
logger.debug(f"Sign out failed (best effort): {e}")
|
|
211
|
+
finally:
|
|
212
|
+
self._session_active = False
|
|
213
|
+
self._access_token = None
|
bt_cli/core/client.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""Base HTTP client for BeyondTrust products."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .auth import AuthStrategy
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
# Track if SSL warning has been shown (only show once per process)
|
|
15
|
+
_ssl_warning_shown = False
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _warn_ssl_disabled() -> None:
|
|
19
|
+
"""Print warning to stderr when SSL verification is disabled.
|
|
20
|
+
|
|
21
|
+
Security: SSL verification protects against man-in-the-middle attacks.
|
|
22
|
+
Disabling it allows attackers to intercept credentials and data.
|
|
23
|
+
|
|
24
|
+
Only shows detailed warning once per process, but logs every time.
|
|
25
|
+
Suppression requires explicit BT_SSL_INSECURE_ALLOW=1 (not just any value).
|
|
26
|
+
"""
|
|
27
|
+
global _ssl_warning_shown
|
|
28
|
+
|
|
29
|
+
# Always log for audit trail
|
|
30
|
+
logger.warning("SSL certificate verification is DISABLED - connection is insecure")
|
|
31
|
+
|
|
32
|
+
# Check for explicit opt-in to suppress visual warning
|
|
33
|
+
# Requires explicit value "1" to prevent accidental suppression
|
|
34
|
+
if os.environ.get("BT_SSL_INSECURE_ALLOW") == "1":
|
|
35
|
+
if not _ssl_warning_shown:
|
|
36
|
+
_ssl_warning_shown = True
|
|
37
|
+
print(
|
|
38
|
+
"\033[93m[SSL WARNING SUPPRESSED - BT_SSL_INSECURE_ALLOW=1]\033[0m",
|
|
39
|
+
file=sys.stderr,
|
|
40
|
+
)
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
if _ssl_warning_shown:
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
_ssl_warning_shown = True
|
|
47
|
+
print(
|
|
48
|
+
"\n\033[91m" + "=" * 70 + "\n"
|
|
49
|
+
"SECURITY WARNING: SSL certificate verification is DISABLED\n"
|
|
50
|
+
"=" * 70 + "\033[0m\n"
|
|
51
|
+
"\033[93m"
|
|
52
|
+
"This exposes your credentials to man-in-the-middle attacks.\n"
|
|
53
|
+
"Only use for testing with self-signed certificates.\n"
|
|
54
|
+
"\n"
|
|
55
|
+
"To suppress this warning (NOT RECOMMENDED):\n"
|
|
56
|
+
" export BT_SSL_INSECURE_ALLOW=1\n"
|
|
57
|
+
"\033[0m",
|
|
58
|
+
file=sys.stderr,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class BaseClient:
|
|
63
|
+
"""Base HTTP client that all product clients inherit from.
|
|
64
|
+
|
|
65
|
+
Provides common functionality:
|
|
66
|
+
- Context manager for connection lifecycle
|
|
67
|
+
- Request methods (GET, POST, PUT, DELETE)
|
|
68
|
+
- Response handling
|
|
69
|
+
- Auth integration
|
|
70
|
+
|
|
71
|
+
Product clients extend this with:
|
|
72
|
+
- Product-specific auth configuration
|
|
73
|
+
- Pagination handling (varies by API)
|
|
74
|
+
- Convenience methods for API endpoints
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
base_url: str,
|
|
80
|
+
auth: AuthStrategy,
|
|
81
|
+
timeout: float = 30.0,
|
|
82
|
+
verify_ssl: bool = True,
|
|
83
|
+
):
|
|
84
|
+
"""Initialize base client.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
base_url: API base URL
|
|
88
|
+
auth: Authentication strategy to use
|
|
89
|
+
timeout: Request timeout in seconds
|
|
90
|
+
verify_ssl: Whether to verify SSL certificates
|
|
91
|
+
"""
|
|
92
|
+
self.base_url = base_url.rstrip("/")
|
|
93
|
+
self.auth = auth
|
|
94
|
+
self.timeout = timeout
|
|
95
|
+
self.verify_ssl = verify_ssl
|
|
96
|
+
self._client: Optional[httpx.Client] = None
|
|
97
|
+
|
|
98
|
+
def _ensure_client(self) -> httpx.Client:
|
|
99
|
+
"""Ensure HTTP client is initialized.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
The httpx client
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
RuntimeError: If client not initialized (not in context manager)
|
|
106
|
+
"""
|
|
107
|
+
if self._client is None:
|
|
108
|
+
raise RuntimeError(
|
|
109
|
+
"Client not initialized. Use as context manager: "
|
|
110
|
+
"'with get_client() as client:'"
|
|
111
|
+
)
|
|
112
|
+
return self._client
|
|
113
|
+
|
|
114
|
+
def __enter__(self) -> "BaseClient":
|
|
115
|
+
"""Enter context manager - create HTTP client."""
|
|
116
|
+
if not self.verify_ssl:
|
|
117
|
+
_warn_ssl_disabled()
|
|
118
|
+
|
|
119
|
+
self._client = httpx.Client(
|
|
120
|
+
base_url=self.base_url,
|
|
121
|
+
timeout=self.timeout,
|
|
122
|
+
verify=self.verify_ssl,
|
|
123
|
+
)
|
|
124
|
+
return self
|
|
125
|
+
|
|
126
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
127
|
+
"""Exit context manager - close HTTP client."""
|
|
128
|
+
if self._client:
|
|
129
|
+
self._client.close()
|
|
130
|
+
self._client = None
|
|
131
|
+
|
|
132
|
+
def _get_headers(self) -> dict[str, str]:
|
|
133
|
+
"""Build request headers including auth.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Headers dictionary with content type and auth
|
|
137
|
+
"""
|
|
138
|
+
headers = {
|
|
139
|
+
"Content-Type": "application/json",
|
|
140
|
+
"Accept": "application/json",
|
|
141
|
+
}
|
|
142
|
+
headers.update(self.auth.get_headers())
|
|
143
|
+
return headers
|
|
144
|
+
|
|
145
|
+
def _request(
|
|
146
|
+
self,
|
|
147
|
+
method: str,
|
|
148
|
+
path: str,
|
|
149
|
+
params: Optional[dict[str, Any]] = None,
|
|
150
|
+
json: Optional[dict[str, Any]] = None,
|
|
151
|
+
data: Optional[dict[str, Any]] = None,
|
|
152
|
+
headers: Optional[dict[str, str]] = None,
|
|
153
|
+
files: Optional[dict[str, Any]] = None,
|
|
154
|
+
) -> Any:
|
|
155
|
+
"""Make an HTTP request.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
method: HTTP method (GET, POST, PUT, DELETE)
|
|
159
|
+
path: API endpoint path
|
|
160
|
+
params: Query parameters
|
|
161
|
+
json: JSON body (for POST/PUT)
|
|
162
|
+
data: Form data (for POST)
|
|
163
|
+
headers: Additional headers
|
|
164
|
+
files: File upload data
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
Parsed JSON response, or empty dict for 204/empty responses
|
|
168
|
+
|
|
169
|
+
Raises:
|
|
170
|
+
httpx.HTTPStatusError: If request fails
|
|
171
|
+
"""
|
|
172
|
+
client = self._ensure_client()
|
|
173
|
+
|
|
174
|
+
# Build headers
|
|
175
|
+
request_headers = self._get_headers()
|
|
176
|
+
if headers:
|
|
177
|
+
request_headers.update(headers)
|
|
178
|
+
|
|
179
|
+
# Handle file uploads - don't send Content-Type, let httpx set it
|
|
180
|
+
if files:
|
|
181
|
+
request_headers.pop("Content-Type", None)
|
|
182
|
+
|
|
183
|
+
# Filter out None params
|
|
184
|
+
if params:
|
|
185
|
+
params = {k: v for k, v in params.items() if v is not None}
|
|
186
|
+
|
|
187
|
+
response = client.request(
|
|
188
|
+
method=method,
|
|
189
|
+
url=path,
|
|
190
|
+
params=params,
|
|
191
|
+
json=json,
|
|
192
|
+
data=data,
|
|
193
|
+
headers=request_headers,
|
|
194
|
+
files=files,
|
|
195
|
+
)
|
|
196
|
+
response.raise_for_status()
|
|
197
|
+
|
|
198
|
+
# Handle empty responses
|
|
199
|
+
if response.status_code == 204 or not response.content:
|
|
200
|
+
return {}
|
|
201
|
+
|
|
202
|
+
# Try to parse JSON
|
|
203
|
+
try:
|
|
204
|
+
return response.json()
|
|
205
|
+
except Exception:
|
|
206
|
+
return {"content": response.text}
|
|
207
|
+
|
|
208
|
+
def get(
|
|
209
|
+
self,
|
|
210
|
+
path: str,
|
|
211
|
+
params: Optional[dict[str, Any]] = None,
|
|
212
|
+
) -> Any:
|
|
213
|
+
"""Make a GET request.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
path: API endpoint path
|
|
217
|
+
params: Query parameters
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
Parsed JSON response
|
|
221
|
+
"""
|
|
222
|
+
return self._request("GET", path, params=params)
|
|
223
|
+
|
|
224
|
+
def post(
|
|
225
|
+
self,
|
|
226
|
+
path: str,
|
|
227
|
+
json: Optional[dict[str, Any]] = None,
|
|
228
|
+
data: Optional[dict[str, Any]] = None,
|
|
229
|
+
headers: Optional[dict[str, str]] = None,
|
|
230
|
+
files: Optional[dict[str, Any]] = None,
|
|
231
|
+
) -> Any:
|
|
232
|
+
"""Make a POST request.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
path: API endpoint path
|
|
236
|
+
json: JSON body
|
|
237
|
+
data: Form data
|
|
238
|
+
headers: Additional headers
|
|
239
|
+
files: File upload data
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
Parsed JSON response
|
|
243
|
+
"""
|
|
244
|
+
return self._request(
|
|
245
|
+
"POST", path, json=json, data=data, headers=headers, files=files
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
def put(
|
|
249
|
+
self,
|
|
250
|
+
path: str,
|
|
251
|
+
json: Optional[dict[str, Any]] = None,
|
|
252
|
+
data: Optional[dict[str, Any]] = None,
|
|
253
|
+
headers: Optional[dict[str, str]] = None,
|
|
254
|
+
files: Optional[dict[str, Any]] = None,
|
|
255
|
+
) -> Any:
|
|
256
|
+
"""Make a PUT request.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
path: API endpoint path
|
|
260
|
+
json: JSON body
|
|
261
|
+
data: Form data
|
|
262
|
+
headers: Additional headers
|
|
263
|
+
files: File upload data
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
Parsed JSON response
|
|
267
|
+
"""
|
|
268
|
+
return self._request(
|
|
269
|
+
"PUT", path, json=json, data=data, headers=headers, files=files
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
def delete(
|
|
273
|
+
self,
|
|
274
|
+
path: str,
|
|
275
|
+
params: Optional[dict[str, Any]] = None,
|
|
276
|
+
) -> Any:
|
|
277
|
+
"""Make a DELETE request.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
path: API endpoint path
|
|
281
|
+
params: Query parameters
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
Parsed JSON response
|
|
285
|
+
"""
|
|
286
|
+
return self._request("DELETE", path, params=params)
|
|
287
|
+
|
|
288
|
+
def get_raw(
|
|
289
|
+
self,
|
|
290
|
+
path: str,
|
|
291
|
+
params: Optional[dict[str, Any]] = None,
|
|
292
|
+
) -> bytes:
|
|
293
|
+
"""Make a GET request and return raw bytes.
|
|
294
|
+
|
|
295
|
+
Useful for file downloads.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
path: API endpoint path
|
|
299
|
+
params: Query parameters
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
Raw response bytes
|
|
303
|
+
"""
|
|
304
|
+
client = self._ensure_client()
|
|
305
|
+
headers = self._get_headers()
|
|
306
|
+
headers.pop("Content-Type", None) # Not needed for downloads
|
|
307
|
+
|
|
308
|
+
if params:
|
|
309
|
+
params = {k: v for k, v in params.items() if v is not None}
|
|
310
|
+
|
|
311
|
+
response = client.get(path, params=params, headers=headers)
|
|
312
|
+
response.raise_for_status()
|
|
313
|
+
return response.content
|