mreg-cli 1.0.0__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.
- mreg_cli/__about__.py +7 -0
- mreg_cli/__init__.py +5 -0
- mreg_cli/__main__.py +8 -0
- mreg_cli/_version.py +16 -0
- mreg_cli/api/__init__.py +7 -0
- mreg_cli/api/abstracts.py +486 -0
- mreg_cli/api/endpoints.py +130 -0
- mreg_cli/api/fields.py +104 -0
- mreg_cli/api/history.py +152 -0
- mreg_cli/api/models.py +3416 -0
- mreg_cli/cli.py +382 -0
- mreg_cli/commands/__init__.py +1 -0
- mreg_cli/commands/base.py +62 -0
- mreg_cli/commands/dhcp.py +136 -0
- mreg_cli/commands/group.py +326 -0
- mreg_cli/commands/help.py +77 -0
- mreg_cli/commands/host.py +54 -0
- mreg_cli/commands/host_submodules/__init__.py +28 -0
- mreg_cli/commands/host_submodules/a_aaaa.py +452 -0
- mreg_cli/commands/host_submodules/bacnet.py +126 -0
- mreg_cli/commands/host_submodules/cname.py +166 -0
- mreg_cli/commands/host_submodules/core.py +507 -0
- mreg_cli/commands/host_submodules/rr.py +973 -0
- mreg_cli/commands/label.py +146 -0
- mreg_cli/commands/logging.py +112 -0
- mreg_cli/commands/network.py +516 -0
- mreg_cli/commands/permission.py +202 -0
- mreg_cli/commands/policy.py +519 -0
- mreg_cli/commands/recording.py +59 -0
- mreg_cli/commands/registry.py +56 -0
- mreg_cli/commands/root.py +58 -0
- mreg_cli/commands/zone.py +288 -0
- mreg_cli/config.py +253 -0
- mreg_cli/errorbuilder.py +193 -0
- mreg_cli/exceptions.py +237 -0
- mreg_cli/help_formatter.py +38 -0
- mreg_cli/main.py +238 -0
- mreg_cli/outputmanager.py +466 -0
- mreg_cli/py.typed +0 -0
- mreg_cli/tags.txt +55 -0
- mreg_cli/tokenfile.py +89 -0
- mreg_cli/types.py +160 -0
- mreg_cli/utilities/__init__.py +5 -0
- mreg_cli/utilities/api.py +595 -0
- mreg_cli/utilities/shared.py +65 -0
- mreg_cli/utilities/validators.py +19 -0
- mreg_cli-1.0.0.dist-info/AUTHORS +12 -0
- mreg_cli-1.0.0.dist-info/LICENSE +674 -0
- mreg_cli-1.0.0.dist-info/METADATA +1079 -0
- mreg_cli-1.0.0.dist-info/RECORD +53 -0
- mreg_cli-1.0.0.dist-info/WHEEL +5 -0
- mreg_cli-1.0.0.dist-info/entry_points.txt +2 -0
- mreg_cli-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
"""Utility functions for mreg_cli.
|
|
2
|
+
|
|
3
|
+
Due to circular dependencies, this module is not allowed to import anything from mreg_cli.
|
|
4
|
+
And this rule is promptly broken by importing from mreg_cli.outputmanager...
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
from typing import Any, Literal, NoReturn, TypeVar, get_origin, overload
|
|
15
|
+
from urllib.parse import urljoin
|
|
16
|
+
from uuid import uuid4
|
|
17
|
+
|
|
18
|
+
import requests
|
|
19
|
+
from prompt_toolkit import prompt
|
|
20
|
+
from pydantic import BaseModel, TypeAdapter, field_validator
|
|
21
|
+
from requests import Response
|
|
22
|
+
|
|
23
|
+
from mreg_cli.config import MregCliConfig
|
|
24
|
+
from mreg_cli.exceptions import (
|
|
25
|
+
APINotOk,
|
|
26
|
+
CliError,
|
|
27
|
+
LoginFailedError,
|
|
28
|
+
MultipleEntititesFound,
|
|
29
|
+
TooManyResults,
|
|
30
|
+
ValidationError,
|
|
31
|
+
)
|
|
32
|
+
from mreg_cli.outputmanager import OutputManager
|
|
33
|
+
from mreg_cli.tokenfile import TokenFile
|
|
34
|
+
from mreg_cli.types import Json, JsonMapping, QueryParams
|
|
35
|
+
|
|
36
|
+
session = requests.Session()
|
|
37
|
+
session.headers.update({"User-Agent": "mreg-cli"})
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
HTTP_TIMEOUT = 20
|
|
42
|
+
|
|
43
|
+
T = TypeVar("T")
|
|
44
|
+
|
|
45
|
+
JsonMappingValidator = TypeAdapter(JsonMapping)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def error(msg: str | Exception, code: int = os.EX_UNAVAILABLE) -> NoReturn:
|
|
49
|
+
"""Print an error message and exits with the given code."""
|
|
50
|
+
print(f"ERROR: {msg}", file=sys.stderr)
|
|
51
|
+
sys.exit(code)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def create_and_set_corrolation_id(suffix: str) -> str:
|
|
55
|
+
"""Set currently active corrolation id.
|
|
56
|
+
|
|
57
|
+
This will take a suffix and append it to a generated UUIDv4 and set it as the corrolation id.
|
|
58
|
+
|
|
59
|
+
:param suffix: The suffix to use for the corrolation id.
|
|
60
|
+
|
|
61
|
+
:returns: The generated corrolation id.
|
|
62
|
+
"""
|
|
63
|
+
suffix = re.sub(r"\s+", "_", suffix)
|
|
64
|
+
correlation_id = f"{uuid4()}-{suffix}"
|
|
65
|
+
|
|
66
|
+
session.headers.update({"X-Correlation-ID": correlation_id})
|
|
67
|
+
return correlation_id
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_correlation_id() -> str:
|
|
71
|
+
"""Get the currently active corrolation id.
|
|
72
|
+
|
|
73
|
+
:returns: The currently active corrolation id.
|
|
74
|
+
"""
|
|
75
|
+
return str(session.headers.get("X-Correlation-ID"))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def set_session_token(token: str) -> None:
|
|
79
|
+
"""Update session headers with an authorization token.
|
|
80
|
+
|
|
81
|
+
:param username: The username to use.
|
|
82
|
+
:param url: The URL to use.
|
|
83
|
+
"""
|
|
84
|
+
session.headers.update({"Authorization": f"Token {token}"})
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def get_session_token() -> str | None:
|
|
88
|
+
"""Get the authorization token from an active session if it exists.
|
|
89
|
+
|
|
90
|
+
:param username: The username to use.
|
|
91
|
+
:param url: The URL to use.
|
|
92
|
+
|
|
93
|
+
:returns: The token if it exists, otherwise None.
|
|
94
|
+
"""
|
|
95
|
+
auth = str(session.headers.get("Authorization"))
|
|
96
|
+
return auth.partition(" ")[2] or None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def try_token_or_login(user: str, url: str, fail_without_token: bool = False) -> None:
|
|
100
|
+
"""Check for a valid token or interactively log in to MREG.
|
|
101
|
+
|
|
102
|
+
Exits on connection failure.
|
|
103
|
+
|
|
104
|
+
:param user: Username to login with.
|
|
105
|
+
:param url: URL to MREG.
|
|
106
|
+
|
|
107
|
+
:raises LoginFailedError: If login fails.
|
|
108
|
+
|
|
109
|
+
:returns: Nothing.
|
|
110
|
+
"""
|
|
111
|
+
token = TokenFile.get_entry(user, url)
|
|
112
|
+
if token:
|
|
113
|
+
set_session_token(token.token)
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
ret = session.get(
|
|
117
|
+
urljoin(MregCliConfig().get_url(), "/api/v1/hosts/"),
|
|
118
|
+
params={"page_size": 1},
|
|
119
|
+
timeout=5,
|
|
120
|
+
)
|
|
121
|
+
except requests.exceptions.ConnectionError as e:
|
|
122
|
+
error(f"Could not connect to {url}: {e}")
|
|
123
|
+
|
|
124
|
+
if ret.status_code == 401:
|
|
125
|
+
if fail_without_token:
|
|
126
|
+
raise SystemExit("Token only login failed.")
|
|
127
|
+
prompt_for_password_and_login(user, url, catch_exception=False)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def prompt_for_password_and_login(user: str, url: str, catch_exception: bool = True) -> None:
|
|
131
|
+
"""Login to MREG.
|
|
132
|
+
|
|
133
|
+
:param user: Username to login with.
|
|
134
|
+
:param url: URL to MREG.
|
|
135
|
+
:param catch_exception: If True, login errors are caught, otherwise they are passed on.
|
|
136
|
+
|
|
137
|
+
:raises LoginFailedError: If login fails and catch_exception is False.
|
|
138
|
+
|
|
139
|
+
:returns: Nothing.
|
|
140
|
+
"""
|
|
141
|
+
print(f"Connecting to {url}")
|
|
142
|
+
password = prompt(f"Password for {user}: ", is_password=True)
|
|
143
|
+
try:
|
|
144
|
+
auth_and_update_token(user, password)
|
|
145
|
+
except CliError as e:
|
|
146
|
+
if catch_exception:
|
|
147
|
+
e.print_self()
|
|
148
|
+
else:
|
|
149
|
+
raise LoginFailedError("Updating token failed.") from e
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def logout() -> None:
|
|
153
|
+
"""Logout from MREG."""
|
|
154
|
+
path = urljoin(MregCliConfig().get_url(), "/api/token-logout/")
|
|
155
|
+
# Try to logout, and ignore errors
|
|
156
|
+
try:
|
|
157
|
+
session.post(path)
|
|
158
|
+
except requests.exceptions.ConnectionError:
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def prompt_for_password_and_try_update_token() -> None:
|
|
163
|
+
"""Prompt for a password and try to update the token."""
|
|
164
|
+
password = prompt("You need to re-autenticate\nEnter password: ", is_password=True)
|
|
165
|
+
try:
|
|
166
|
+
user = MregCliConfig().get("user")
|
|
167
|
+
if not user:
|
|
168
|
+
raise LoginFailedError("Unable to determine username.")
|
|
169
|
+
auth_and_update_token(user, password)
|
|
170
|
+
except CliError as e:
|
|
171
|
+
e.print_self()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def auth_and_update_token(username: str, password: str) -> None:
|
|
175
|
+
"""Perform the actual token update."""
|
|
176
|
+
tokenurl = urljoin(MregCliConfig().get_url(), "/api/token-auth/")
|
|
177
|
+
logger.info("Updating token for %s @ %s", username, tokenurl)
|
|
178
|
+
try:
|
|
179
|
+
result = requests.post(tokenurl, {"username": username, "password": password})
|
|
180
|
+
except requests.exceptions.SSLError as e:
|
|
181
|
+
error(e)
|
|
182
|
+
except requests.exceptions.ConnectionError as err:
|
|
183
|
+
error(err)
|
|
184
|
+
if not result.ok:
|
|
185
|
+
try:
|
|
186
|
+
res = result.json()
|
|
187
|
+
except json.JSONDecodeError:
|
|
188
|
+
res = result.text
|
|
189
|
+
if result.status_code == 400:
|
|
190
|
+
if "non_field_errors" in res:
|
|
191
|
+
raise LoginFailedError("Invalid username/password")
|
|
192
|
+
else:
|
|
193
|
+
raise LoginFailedError(res)
|
|
194
|
+
token = result.json()["token"]
|
|
195
|
+
logger.info("Token updated for %s @ %s", username, tokenurl)
|
|
196
|
+
set_session_token(token)
|
|
197
|
+
TokenFile.set_entry(username, MregCliConfig().get_url(), token)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def result_check(result: Response, operation_type: str, url: str) -> None:
|
|
201
|
+
"""Check the result of a request."""
|
|
202
|
+
if not result.ok:
|
|
203
|
+
message = f'{operation_type} "{url}": {result.status_code}: {result.reason}'
|
|
204
|
+
try:
|
|
205
|
+
body = result.json()
|
|
206
|
+
except ValueError:
|
|
207
|
+
pass
|
|
208
|
+
else:
|
|
209
|
+
message += f"\n{json.dumps(body, indent=2)}"
|
|
210
|
+
raise APINotOk(message)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _strip_none(data: dict[str, Any]) -> dict[str, Any]:
|
|
214
|
+
"""Recursively strip None values from a dictionary."""
|
|
215
|
+
new: dict[str, Any] = {}
|
|
216
|
+
for key, value in data.items():
|
|
217
|
+
if value is not None:
|
|
218
|
+
if isinstance(value, dict):
|
|
219
|
+
v = _strip_none(value) # pyright: ignore[reportUnknownArgumentType]
|
|
220
|
+
if v:
|
|
221
|
+
new[key] = v
|
|
222
|
+
else:
|
|
223
|
+
new[key] = value
|
|
224
|
+
return new
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _request_wrapper(
|
|
228
|
+
operation_type: Literal["get", "post", "patch", "delete"],
|
|
229
|
+
path: str,
|
|
230
|
+
params: QueryParams | None = None,
|
|
231
|
+
ok404: bool = False,
|
|
232
|
+
first: bool = True,
|
|
233
|
+
**data: Any,
|
|
234
|
+
) -> Response | None:
|
|
235
|
+
"""Wrap request calls to MREG for logging and token management."""
|
|
236
|
+
if params is None:
|
|
237
|
+
params = {}
|
|
238
|
+
url = urljoin(MregCliConfig().get_url(), path)
|
|
239
|
+
|
|
240
|
+
logurl = url
|
|
241
|
+
if operation_type.upper() == "GET" and params:
|
|
242
|
+
logurl = logurl + "?" + "&".join(f"{k}={v}" for k, v in params.items())
|
|
243
|
+
|
|
244
|
+
logger.info("Request: %s %s [%s]", operation_type.upper(), logurl, get_correlation_id())
|
|
245
|
+
|
|
246
|
+
if operation_type.upper() != "GET" and params:
|
|
247
|
+
logger.debug("Params: %s", params)
|
|
248
|
+
|
|
249
|
+
if data:
|
|
250
|
+
logger.debug("Data: %s", data)
|
|
251
|
+
|
|
252
|
+
# Strip None values from data
|
|
253
|
+
if data and operation_type != "patch":
|
|
254
|
+
data = _strip_none(data)
|
|
255
|
+
|
|
256
|
+
if operation_type == "get":
|
|
257
|
+
func = session.get
|
|
258
|
+
elif operation_type == "post":
|
|
259
|
+
func = session.post
|
|
260
|
+
elif operation_type == "patch":
|
|
261
|
+
func = session.patch
|
|
262
|
+
elif operation_type == "delete":
|
|
263
|
+
func = session.delete
|
|
264
|
+
else:
|
|
265
|
+
raise ValueError(f"Unknown operation type: {operation_type}")
|
|
266
|
+
|
|
267
|
+
result = func(
|
|
268
|
+
url,
|
|
269
|
+
params=params,
|
|
270
|
+
json=data or None,
|
|
271
|
+
timeout=HTTP_TIMEOUT,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
request_id = result.headers.get("X-Request-Id", "?")
|
|
275
|
+
correlation_id = result.headers.get("X-Correlation-ID", "?")
|
|
276
|
+
id_str = f"[R:{request_id} C:{correlation_id}]"
|
|
277
|
+
log_message = f"Response: {operation_type.upper()} {logurl} {result.status_code} {id_str}"
|
|
278
|
+
|
|
279
|
+
if result.status_code >= 300:
|
|
280
|
+
logger.warning(log_message)
|
|
281
|
+
else:
|
|
282
|
+
logger.info(log_message)
|
|
283
|
+
|
|
284
|
+
# This is a workaround for old server versions that can't handle JSON data in requests
|
|
285
|
+
if (
|
|
286
|
+
result.status_code == 500
|
|
287
|
+
and (operation_type == "post" or operation_type == "patch")
|
|
288
|
+
and params == {}
|
|
289
|
+
and data is not None
|
|
290
|
+
):
|
|
291
|
+
result = func(url, params={}, timeout=HTTP_TIMEOUT, data=data)
|
|
292
|
+
|
|
293
|
+
OutputManager().recording_request(operation_type, url, params, data, result)
|
|
294
|
+
|
|
295
|
+
if first and result.status_code == 401:
|
|
296
|
+
prompt_for_password_and_try_update_token()
|
|
297
|
+
return _request_wrapper(operation_type, path, params=params, first=False, **data)
|
|
298
|
+
elif result.status_code == 404 and ok404:
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
result_check(result, operation_type.upper(), url)
|
|
302
|
+
return result
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@overload
|
|
306
|
+
def get(path: str, params: QueryParams | None, ok404: Literal[True]) -> Response | None: ...
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@overload
|
|
310
|
+
def get(path: str, params: QueryParams | None, ok404: Literal[False]) -> Response: ...
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@overload
|
|
314
|
+
def get(path: str, params: QueryParams | None = ..., *, ok404: bool) -> Response | None: ...
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@overload
|
|
318
|
+
def get(path: str, params: QueryParams | None = ...) -> Response: ...
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def get(path: str, params: QueryParams | None = None, ok404: bool = False) -> Response | None:
|
|
322
|
+
"""Make a standard get request."""
|
|
323
|
+
if params is None:
|
|
324
|
+
params = {}
|
|
325
|
+
return _request_wrapper("get", path, params=params, ok404=ok404)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def get_list(
|
|
329
|
+
path: str,
|
|
330
|
+
params: QueryParams | None = None,
|
|
331
|
+
ok404: bool = False,
|
|
332
|
+
limit: int | None = 500,
|
|
333
|
+
) -> list[Json]:
|
|
334
|
+
"""Make a get request that produces a list.
|
|
335
|
+
|
|
336
|
+
Will iterate over paginated results and return result as list. If the number of hits is
|
|
337
|
+
greater than limit, the function will raise an exception.
|
|
338
|
+
|
|
339
|
+
:param path: The path to the API endpoint.
|
|
340
|
+
:param params: The parameters to pass to the API endpoint.
|
|
341
|
+
:param ok404: Whether to allow 404 responses.
|
|
342
|
+
:param limit: The maximum number of hits to allow.
|
|
343
|
+
If the number of hits is greater than this, the function will raise an exception.
|
|
344
|
+
Set to None to disable this check.
|
|
345
|
+
:raises CliError: If the result from get_list_generic is not a list.
|
|
346
|
+
|
|
347
|
+
:returns: A list of dictionaries.
|
|
348
|
+
"""
|
|
349
|
+
return get_list_generic(path, params, ok404, limit, expect_one_result=False)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def get_list_in(
|
|
353
|
+
path: str,
|
|
354
|
+
search_field: str,
|
|
355
|
+
search_values: list[int],
|
|
356
|
+
ok404: bool = False,
|
|
357
|
+
) -> list[Json]:
|
|
358
|
+
"""Get a list of items by a key value pair.
|
|
359
|
+
|
|
360
|
+
:param path: The path to the API endpoint.
|
|
361
|
+
:param search_field: The field to search for.
|
|
362
|
+
:param search_values: The values to search for.
|
|
363
|
+
:param ok404: Whether to allow 404 responses.
|
|
364
|
+
|
|
365
|
+
:returns: A list of dictionaries.
|
|
366
|
+
"""
|
|
367
|
+
return get_list(
|
|
368
|
+
path,
|
|
369
|
+
params={f"{search_field}__in": ",".join(str(x) for x in search_values)},
|
|
370
|
+
ok404=ok404,
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def get_item_by_key_value(
|
|
375
|
+
path: str,
|
|
376
|
+
search_field: str,
|
|
377
|
+
search_value: str | int,
|
|
378
|
+
ok404: bool = False,
|
|
379
|
+
) -> None | JsonMapping:
|
|
380
|
+
"""Get an item by a key value pair.
|
|
381
|
+
|
|
382
|
+
:param path: The path to the API endpoint.
|
|
383
|
+
:param search_field: The field to search for.
|
|
384
|
+
:param search_value: The value to search for.
|
|
385
|
+
:param ok404: Whether to allow 404 responses.
|
|
386
|
+
|
|
387
|
+
:raises CliWarning: If no result was found and ok404 is False.
|
|
388
|
+
|
|
389
|
+
:returns: A single dictionary, or None if no result was found and ok404 is True.
|
|
390
|
+
"""
|
|
391
|
+
return get_list_unique(path, params={search_field: search_value}, ok404=ok404)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def get_list_unique(
|
|
395
|
+
path: str,
|
|
396
|
+
params: QueryParams | None = None,
|
|
397
|
+
ok404: bool = False,
|
|
398
|
+
) -> None | JsonMapping:
|
|
399
|
+
"""Do a get request that returns a single result from a search.
|
|
400
|
+
|
|
401
|
+
:param path: The path to the API endpoint.
|
|
402
|
+
:param params: The parameters to pass to the API endpoint.
|
|
403
|
+
:param ok404: Whether to allow 404 responses.
|
|
404
|
+
|
|
405
|
+
:raises CliWarning: If no result was found and ok404 is False.
|
|
406
|
+
|
|
407
|
+
:returns: A single dictionary, or None if no result was found and ok404 is True.
|
|
408
|
+
"""
|
|
409
|
+
ret = get_list_generic(path, params, ok404, expect_one_result=True)
|
|
410
|
+
if not ret:
|
|
411
|
+
return None
|
|
412
|
+
try:
|
|
413
|
+
return JsonMappingValidator.validate_python(ret)
|
|
414
|
+
except ValueError as e:
|
|
415
|
+
raise ValidationError(f"Failed to validate response from {path}: {e}") from e
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
class PaginatedResponse(BaseModel):
|
|
419
|
+
"""Paginated response data from the API."""
|
|
420
|
+
|
|
421
|
+
count: int
|
|
422
|
+
next: str | None # noqa: A003
|
|
423
|
+
previous: str | None
|
|
424
|
+
results: list[Json]
|
|
425
|
+
|
|
426
|
+
@field_validator("count", mode="before")
|
|
427
|
+
@classmethod
|
|
428
|
+
def _none_count_is_0(cls, v: Any) -> Any:
|
|
429
|
+
"""Ensure `count` is never `None`."""
|
|
430
|
+
# Django count doesn't seem to be guaranteed to be an integer.
|
|
431
|
+
# https://github.com/django/django/blob/bcbc4b9b8a4a47c8e045b060a9860a5c038192de/django/core/paginator.py#L105-L111
|
|
432
|
+
# Theoretically any callable can be passed to the "count" attribute of the paginator.
|
|
433
|
+
# Ensures here that None (and any falsey value) is treated as 0.
|
|
434
|
+
return v or 0
|
|
435
|
+
|
|
436
|
+
@classmethod
|
|
437
|
+
def from_response(cls, response: Response) -> PaginatedResponse:
|
|
438
|
+
"""Create a PaginatedResponse from a Response."""
|
|
439
|
+
return cls.model_validate_json(response.text)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
ListResponse = TypeAdapter(list[Json])
|
|
443
|
+
"""JSON list (array) response adapter."""
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
# TODO: Provide better validation error introspection
|
|
447
|
+
def validate_list_response(response: Response) -> list[Json]:
|
|
448
|
+
"""Parse and validate that a response contains a JSON array.
|
|
449
|
+
|
|
450
|
+
:param response: The response to validate.
|
|
451
|
+
:raises ValidationError: If the response does not contain a valid JSON array.
|
|
452
|
+
:returns: Parsed response data as a list of Python objects.
|
|
453
|
+
"""
|
|
454
|
+
try:
|
|
455
|
+
return ListResponse.validate_json(response.text)
|
|
456
|
+
# NOTE: ValueError catches custom Pydantic errors too
|
|
457
|
+
except ValueError as e:
|
|
458
|
+
raise ValidationError(f"{response.url} did not return a valid JSON array") from e
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def validate_paginated_response(response: Response) -> PaginatedResponse:
|
|
462
|
+
"""Validate and parse that a response contains paginated JSON data.
|
|
463
|
+
|
|
464
|
+
:param response: The response to validate.
|
|
465
|
+
:raises ValidationError: If the response does not contain valid paginated JSON.
|
|
466
|
+
:returns: Parsed response data as a PaginatedResponse object.
|
|
467
|
+
"""
|
|
468
|
+
try:
|
|
469
|
+
return PaginatedResponse.from_response(response)
|
|
470
|
+
except ValueError as e:
|
|
471
|
+
raise ValidationError(f"{response.url} did not return valid paginated JSON") from e
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
@overload
|
|
475
|
+
def get_list_generic(
|
|
476
|
+
path: str,
|
|
477
|
+
params: QueryParams | None = ...,
|
|
478
|
+
ok404: bool = ...,
|
|
479
|
+
limit: int | None = ...,
|
|
480
|
+
expect_one_result: Literal[False] = False,
|
|
481
|
+
) -> list[Json]: ...
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
@overload
|
|
485
|
+
def get_list_generic(
|
|
486
|
+
path: str,
|
|
487
|
+
params: QueryParams | None = ...,
|
|
488
|
+
ok404: bool = ...,
|
|
489
|
+
limit: int | None = ...,
|
|
490
|
+
expect_one_result: Literal[True] = True,
|
|
491
|
+
) -> Json: ...
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def get_list_generic(
|
|
495
|
+
path: str,
|
|
496
|
+
params: QueryParams | None = None,
|
|
497
|
+
ok404: bool = False,
|
|
498
|
+
limit: int | None = 500,
|
|
499
|
+
expect_one_result: bool | None = False,
|
|
500
|
+
) -> Json | list[Json]:
|
|
501
|
+
"""Make a get request that produces a list.
|
|
502
|
+
|
|
503
|
+
Will iterate over paginated results and return result as list. If the number of hits is
|
|
504
|
+
greater than limit, the function will raise an exception.
|
|
505
|
+
|
|
506
|
+
:param path: The path to the API endpoint.
|
|
507
|
+
:param params: The parameters to pass to the API endpoint.
|
|
508
|
+
:param ok404: Whether to allow 404 responses.
|
|
509
|
+
:param limit: The maximum number of hits to allow.
|
|
510
|
+
If the number of hits is greater than this, the function will raise an exception.
|
|
511
|
+
Set to None to disable this check.
|
|
512
|
+
:param expect_one_result: If True, expect exactly one result and return it as a list.
|
|
513
|
+
|
|
514
|
+
:raises CliError: If expect_one_result is True and the number of results is not zero or one.
|
|
515
|
+
:raises CliError: If expect_one_result is True and there is a response without a 'results' key.
|
|
516
|
+
:raises CliError: If the number of hits is greater than limit.
|
|
517
|
+
|
|
518
|
+
:returns: A list of dictionaries or a dictionary if expect_one_result is True.
|
|
519
|
+
"""
|
|
520
|
+
response = get(path, params)
|
|
521
|
+
|
|
522
|
+
# Non-paginated results, return them directly
|
|
523
|
+
if "count" not in response.text:
|
|
524
|
+
return validate_list_response(response)
|
|
525
|
+
|
|
526
|
+
resp = validate_paginated_response(response)
|
|
527
|
+
|
|
528
|
+
if limit and resp.count > abs(limit):
|
|
529
|
+
raise TooManyResults(f"Too many hits ({resp.count}), please refine your search criteria.")
|
|
530
|
+
|
|
531
|
+
# Iterate over all pages and collect the results
|
|
532
|
+
ret: list[Json] = resp.results
|
|
533
|
+
while resp.next:
|
|
534
|
+
response = get(resp.next, ok404=ok404)
|
|
535
|
+
if response is None:
|
|
536
|
+
break
|
|
537
|
+
resp = validate_paginated_response(response)
|
|
538
|
+
ret.extend(resp.results)
|
|
539
|
+
if expect_one_result:
|
|
540
|
+
if len(ret) == 0:
|
|
541
|
+
return {}
|
|
542
|
+
if len(ret) != 1:
|
|
543
|
+
raise MultipleEntititesFound(f"Expected exactly one result, got {len(ret)}.")
|
|
544
|
+
return ret[0]
|
|
545
|
+
return ret
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def get_typed(
|
|
549
|
+
path: str,
|
|
550
|
+
type_: type[T],
|
|
551
|
+
params: QueryParams | None = None,
|
|
552
|
+
limit: int | None = 500,
|
|
553
|
+
) -> T:
|
|
554
|
+
"""Fetch and deserialize JSON from an endpoint into a specific type.
|
|
555
|
+
|
|
556
|
+
This function is a wrapper over the `get()` function, adding the additional
|
|
557
|
+
functionality of validating and converting the response data to the specified type.
|
|
558
|
+
|
|
559
|
+
:param path: The path to the API endpoint.
|
|
560
|
+
:param type_: The type to which the response data should be deserialized.
|
|
561
|
+
:param params: The parameters to pass to the API endpoint.
|
|
562
|
+
:param limit: The maximum number of hits to allow for paginated responses.
|
|
563
|
+
|
|
564
|
+
:raises pydantic.ValidationError: If the response cannot be deserialized into the given type.
|
|
565
|
+
|
|
566
|
+
:returns: An instance of `type_` populated with data from the response.
|
|
567
|
+
"""
|
|
568
|
+
adapter = TypeAdapter(type_)
|
|
569
|
+
if type_ is list or get_origin(type_) is list:
|
|
570
|
+
resp = get_list(path, params=params, limit=limit)
|
|
571
|
+
return adapter.validate_python(resp)
|
|
572
|
+
else:
|
|
573
|
+
resp = get(path, params=params)
|
|
574
|
+
return adapter.validate_json(resp.text)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def post(path: str, params: QueryParams | None = None, **kwargs: Any) -> Response | None:
|
|
578
|
+
"""Use requests to make a post request. Assumes that all kwargs are data fields."""
|
|
579
|
+
if params is None:
|
|
580
|
+
params = {}
|
|
581
|
+
return _request_wrapper("post", path, params=params, **kwargs)
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def patch(path: str, params: QueryParams | None = None, **kwargs: Any) -> Response | None:
|
|
585
|
+
"""Use requests to make a patch request. Assumes that all kwargs are data fields."""
|
|
586
|
+
if params is None:
|
|
587
|
+
params = {}
|
|
588
|
+
return _request_wrapper("patch", path, params=params, **kwargs)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def delete(path: str, params: QueryParams | None = None) -> Response | None:
|
|
592
|
+
"""Use requests to make a delete request."""
|
|
593
|
+
if params is None:
|
|
594
|
+
params = {}
|
|
595
|
+
return _request_wrapper("delete", path, params=params)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Shared utilities for the mreg_cli package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from mreg_cli.exceptions import InputFailure
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def string_to_int(value: Any, error_tag: str) -> int:
|
|
11
|
+
"""Convert a string to an integer."""
|
|
12
|
+
try:
|
|
13
|
+
return int(value)
|
|
14
|
+
except ValueError as e:
|
|
15
|
+
raise InputFailure("%s: Not a valid integer" % error_tag) from e
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def convert_wildcard_to_regex(
|
|
19
|
+
param: str, arg: str, autoWildcards: bool = False
|
|
20
|
+
) -> tuple[str, str]:
|
|
21
|
+
"""Convert wildcard filter "foo*bar*" to something DRF will understand.
|
|
22
|
+
|
|
23
|
+
E.g. "foo*bar*" -> "?name__regex=$foo.*bar.*"
|
|
24
|
+
|
|
25
|
+
:param param: The parameter to filter on
|
|
26
|
+
:param arg: The argument to filter on
|
|
27
|
+
:param autoWildcards: If True, add wildcards to the beginning and end of the argument if
|
|
28
|
+
they are not already present.
|
|
29
|
+
"""
|
|
30
|
+
if "*" not in arg:
|
|
31
|
+
if autoWildcards:
|
|
32
|
+
arg = f"*{arg}*"
|
|
33
|
+
else:
|
|
34
|
+
return (param, arg)
|
|
35
|
+
|
|
36
|
+
args = arg.split("*")
|
|
37
|
+
args_len = len(args) - 1
|
|
38
|
+
regex = ""
|
|
39
|
+
for i, piece in enumerate(args):
|
|
40
|
+
if i == 0 and piece:
|
|
41
|
+
regex += f"^{piece}"
|
|
42
|
+
elif i == args_len and piece:
|
|
43
|
+
regex += f"{piece}$"
|
|
44
|
+
elif piece:
|
|
45
|
+
regex += f".*{piece}.*"
|
|
46
|
+
# if i == 0 and piece:
|
|
47
|
+
# parts.append(f'{param}__startswith={piece}')
|
|
48
|
+
# elif i == args_len and piece:
|
|
49
|
+
# parts.append(f'{param}__endswith={piece}')
|
|
50
|
+
# elif piece:
|
|
51
|
+
# parts.append(f'{param}__contains={piece}')
|
|
52
|
+
|
|
53
|
+
if arg == "*":
|
|
54
|
+
regex = "."
|
|
55
|
+
|
|
56
|
+
return (f"{param}__regex", regex)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def sizeof_fmt(num: float, suffix: str = "B"):
|
|
60
|
+
"""Human readable file size."""
|
|
61
|
+
for unit in ("", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"):
|
|
62
|
+
if abs(num) < 1024.0:
|
|
63
|
+
return f"{num:3.1f}{unit}{suffix}"
|
|
64
|
+
num /= 1024.0
|
|
65
|
+
return f"{num:.1f}Yi{suffix}"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Utility functions for mreg_cli.
|
|
2
|
+
|
|
3
|
+
Due to circular dependencies, be very aware of what you import here.
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from mreg_cli.config import MregCliConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def is_valid_location_tag(loc: str) -> bool:
|
|
13
|
+
"""Check if valid location tag."""
|
|
14
|
+
return loc in MregCliConfig().get_location_tags()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def is_valid_category_tag(cat: str) -> bool:
|
|
18
|
+
"""Check if valid location tag."""
|
|
19
|
+
return cat in MregCliConfig().get_category_tags()
|