]> git.proxmox.com Git - ceph.git/blob - ceph/src/pybind/mgr/tests/test_object_format.py
import ceph quincy 17.2.6
[ceph.git] / ceph / src / pybind / mgr / tests / test_object_format.py
1 import errno
2 from typing import (
3 Any,
4 Dict,
5 Optional,
6 Tuple,
7 Type,
8 TypeVar,
9 )
10
11 import pytest
12
13 from mgr_module import CLICommand
14 import object_format
15
16
17 T = TypeVar("T", bound="Parent")
18
19
20 class Simpler:
21 def __init__(self, name, val=None):
22 self.name = name
23 self.val = val or {}
24 self.version = 1
25
26 def to_simplified(self) -> Dict[str, Any]:
27 return {
28 "version": self.version,
29 "name": self.name,
30 "value": self.val,
31 }
32
33
34 class JSONer(Simpler):
35 def to_json(self) -> Dict[str, Any]:
36 d = self.to_simplified()
37 d["_json"] = True
38 return d
39
40 @classmethod
41 def from_json(cls: Type[T], data) -> T:
42 o = cls(data.get("name", ""), data.get("value"))
43 o.version = data.get("version", 1) + 1
44 return o
45
46
47 class YAMLer(Simpler):
48 def to_yaml(self) -> Dict[str, Any]:
49 d = self.to_simplified()
50 d["_yaml"] = True
51 return d
52
53
54 @pytest.mark.parametrize(
55 "obj, compatible, json_val",
56 [
57 ({}, False, "{}"),
58 ({"name": "foobar"}, False, '{"name": "foobar"}'),
59 ([1, 2, 3], False, "[1, 2, 3]"),
60 (JSONer("bob"), False, '{"name": "bob", "value": {}, "version": 1}'),
61 (
62 JSONer("betty", 77),
63 False,
64 '{"name": "betty", "value": 77, "version": 1}',
65 ),
66 ({}, True, "{}"),
67 ({"name": "foobar"}, True, '{"name": "foobar"}'),
68 (
69 JSONer("bob"),
70 True,
71 '{"_json": true, "name": "bob", "value": {}, "version": 1}',
72 ),
73 ],
74 )
75 def test_format_json(obj: Any, compatible: bool, json_val: str):
76 assert (
77 object_format.ObjectFormatAdapter(
78 obj, compatible=compatible, json_indent=None
79 ).format_json()
80 == json_val
81 )
82
83
84 @pytest.mark.parametrize(
85 "obj, compatible, yaml_val",
86 [
87 ({}, False, "{}\n"),
88 ({"name": "foobar"}, False, "name: foobar\n"),
89 (
90 {"stuff": [1, 88, 909, 32]},
91 False,
92 "stuff:\n- 1\n- 88\n- 909\n- 32\n",
93 ),
94 (
95 JSONer("zebulon", "999"),
96 False,
97 "name: zebulon\nvalue: '999'\nversion: 1\n",
98 ),
99 ({}, True, "{}\n"),
100 ({"name": "foobar"}, True, "name: foobar\n"),
101 (
102 YAMLer("thingy", "404"),
103 True,
104 "_yaml: true\nname: thingy\nvalue: '404'\nversion: 1\n",
105 ),
106 ],
107 )
108 def test_format_yaml(obj: Any, compatible: bool, yaml_val: str):
109 assert (
110 object_format.ObjectFormatAdapter(
111 obj, compatible=compatible
112 ).format_yaml()
113 == yaml_val
114 )
115
116
117 class Retty:
118 def __init__(self, v) -> None:
119 self.value = v
120
121 def mgr_return_value(self) -> int:
122 return self.value
123
124
125 @pytest.mark.parametrize(
126 "obj, ret",
127 [
128 ({}, 0),
129 ({"fish": "sticks"}, 0),
130 (-55, 0),
131 (Retty(0), 0),
132 (Retty(-55), -55),
133 ],
134 )
135 def test_return_value(obj: Any, ret: int):
136 rva = object_format.ReturnValueAdapter(obj)
137 # a ReturnValueAdapter instance meets the ReturnValueProvider protocol.
138 assert object_format._is_return_value_provider(rva)
139 assert rva.mgr_return_value() == ret
140
141
142 def test_valid_formats():
143 ofa = object_format.ObjectFormatAdapter({"fred": "wilma"})
144 vf = ofa.valid_formats()
145 assert "json" in vf
146 assert "yaml" in vf
147 assert "xml" in vf
148 assert "plain" in vf
149
150
151 def test_error_response_exceptions():
152 err = object_format.ErrorResponseBase()
153 with pytest.raises(NotImplementedError):
154 err.format_response()
155
156 err = object_format.UnsupportedFormat("cheese")
157 assert err.format_response() == (-22, "", "Unsupported format: cheese")
158
159 err = object_format.UnknownFormat("chocolate")
160 assert err.format_response() == (-22, "", "Unknown format name: chocolate")
161
162
163 @pytest.mark.parametrize(
164 "value, format, result",
165 [
166 ({}, None, (0, "{}", "")),
167 ({"blat": True}, "json", (0, '{\n "blat": true\n}', "")),
168 ({"blat": True}, "yaml", (0, "blat: true\n", "")),
169 ({"blat": True}, "toml", (-22, "", "Unknown format name: toml")),
170 ({"blat": True}, "xml", (-22, "", "Unsupported format: xml")),
171 (
172 JSONer("hoop", "303"),
173 "yaml",
174 (0, "name: hoop\nvalue: '303'\nversion: 1\n", ""),
175 ),
176 ],
177 )
178 def test_responder_decorator_default(
179 value: Any, format: Optional[str], result: Tuple[int, str, str]
180 ) -> None:
181 @object_format.Responder()
182 def orf_value(format: Optional[str] = None):
183 return value
184
185 assert orf_value(format=format) == result
186
187
188 class PhonyMultiYAMLFormatAdapter(object_format.ObjectFormatAdapter):
189 """This adapter puts a yaml document/directive separator line
190 before all output. It doesn't actully support multiple documents.
191 """
192 def format_yaml(self):
193 yml = super().format_yaml()
194 return "---\n{}".format(yml)
195
196
197 @pytest.mark.parametrize(
198 "value, format, result",
199 [
200 ({}, None, (0, "{}", "")),
201 ({"blat": True}, "json", (0, '{\n "blat": true\n}', "")),
202 ({"blat": True}, "yaml", (0, "---\nblat: true\n", "")),
203 ({"blat": True}, "toml", (-22, "", "Unknown format name: toml")),
204 ({"blat": True}, "xml", (-22, "", "Unsupported format: xml")),
205 (
206 JSONer("hoop", "303"),
207 "yaml",
208 (0, "---\nname: hoop\nvalue: '303'\nversion: 1\n", ""),
209 ),
210 ],
211 )
212 def test_responder_decorator_custom(
213 value: Any, format: Optional[str], result: Tuple[int, str, str]
214 ) -> None:
215 @object_format.Responder(PhonyMultiYAMLFormatAdapter)
216 def orf_value(format: Optional[str] = None):
217 return value
218
219 assert orf_value(format=format) == result
220
221
222 class FancyDemoAdapter(PhonyMultiYAMLFormatAdapter):
223 """This adapter demonstrates adding formatting for other formats
224 like xml and plain text.
225 """
226 def format_xml(self) -> str:
227 name = self.obj.get("name")
228 size = self.obj.get("size")
229 return f'<object name="{name}" size="{size}" />'
230
231 def format_plain(self) -> str:
232 name = self.obj.get("name")
233 size = self.obj.get("size")
234 es = 'es' if size != 1 else ''
235 return f"{size} box{es} of {name}"
236
237
238 class DecoDemo:
239 """Class to stand in for a mgr module, used to test CLICommand integration."""
240
241 @CLICommand("alpha one", perm="rw")
242 @object_format.Responder()
243 def alpha_one(self, name: str = "default") -> Dict[str, str]:
244 return {
245 "alpha": "one",
246 "name": name,
247 "weight": 300,
248 }
249
250 @CLICommand("beta two", perm="r")
251 @object_format.Responder()
252 def beta_two(
253 self, name: str = "default", format: Optional[str] = None
254 ) -> Dict[str, str]:
255 return {
256 "beta": "two",
257 "name": name,
258 "weight": 72,
259 }
260
261 @CLICommand("gamma three", perm="rw")
262 @object_format.Responder(FancyDemoAdapter)
263 def gamma_three(self, size: int = 0) -> Dict[str, Any]:
264 return {"name": "funnystuff", "size": size}
265
266 @CLICommand("z_err", perm="rw")
267 @object_format.ErrorResponseHandler()
268 def z_err(self, name: str = "default") -> Tuple[int, str, str]:
269 if "z" in name:
270 raise object_format.ErrorResponse(f"{name} bad")
271 return 0, name, ""
272
273 @CLICommand("empty one", perm="rw")
274 @object_format.EmptyResponder()
275 def empty_one(self, name: str = "default", retval: Optional[int] = None) -> None:
276 # in real code, this would be making some sort of state change
277 # but we need to handle erors still
278 if retval is None:
279 retval = -5
280 if name in ["pow"]:
281 raise object_format.ErrorResponse(name, return_value=retval)
282 return
283
284 @CLICommand("empty bad", perm="rw")
285 @object_format.EmptyResponder()
286 def empty_bad(self, name: str = "default") -> int:
287 # in real code, this would be making some sort of state change
288 return 5
289
290
291 @pytest.mark.parametrize(
292 "prefix, can_format, args, response",
293 [
294 (
295 "alpha one",
296 True,
297 {"name": "moonbase"},
298 (
299 0,
300 '{\n "alpha": "one",\n "name": "moonbase",\n "weight": 300\n}',
301 "",
302 ),
303 ),
304 # ---
305 (
306 "alpha one",
307 True,
308 {"name": "moonbase2", "format": "yaml"},
309 (
310 0,
311 "alpha: one\nname: moonbase2\nweight: 300\n",
312 "",
313 ),
314 ),
315 # ---
316 (
317 "alpha one",
318 True,
319 {"name": "moonbase2", "format": "chocolate"},
320 (
321 -22,
322 "",
323 "Unknown format name: chocolate",
324 ),
325 ),
326 # ---
327 (
328 "beta two",
329 True,
330 {"name": "blocker"},
331 (
332 0,
333 '{\n "beta": "two",\n "name": "blocker",\n "weight": 72\n}',
334 "",
335 ),
336 ),
337 # ---
338 (
339 "beta two",
340 True,
341 {"name": "test", "format": "yaml"},
342 (
343 0,
344 "beta: two\nname: test\nweight: 72\n",
345 "",
346 ),
347 ),
348 # ---
349 (
350 "beta two",
351 True,
352 {"name": "test", "format": "plain"},
353 (
354 -22,
355 "",
356 "Unsupported format: plain",
357 ),
358 ),
359 # ---
360 (
361 "gamma three",
362 True,
363 {},
364 (
365 0,
366 '{\n "name": "funnystuff",\n "size": 0\n}',
367 "",
368 ),
369 ),
370 # ---
371 (
372 "gamma three",
373 True,
374 {"size": 1, "format": "json"},
375 (
376 0,
377 '{\n "name": "funnystuff",\n "size": 1\n}',
378 "",
379 ),
380 ),
381 # ---
382 (
383 "gamma three",
384 True,
385 {"size": 1, "format": "plain"},
386 (
387 0,
388 "1 box of funnystuff",
389 "",
390 ),
391 ),
392 # ---
393 (
394 "gamma three",
395 True,
396 {"size": 2, "format": "plain"},
397 (
398 0,
399 "2 boxes of funnystuff",
400 "",
401 ),
402 ),
403 # ---
404 (
405 "gamma three",
406 True,
407 {"size": 2, "format": "xml"},
408 (
409 0,
410 '<object name="funnystuff" size="2" />',
411 "",
412 ),
413 ),
414 # ---
415 (
416 "gamma three",
417 True,
418 {"size": 2, "format": "toml"},
419 (
420 -22,
421 "",
422 "Unknown format name: toml",
423 ),
424 ),
425 # ---
426 (
427 "z_err",
428 False,
429 {"name": "foobar"},
430 (
431 0,
432 "foobar",
433 "",
434 ),
435 ),
436 # ---
437 (
438 "z_err",
439 False,
440 {"name": "zamboni"},
441 (
442 -22,
443 "",
444 "zamboni bad",
445 ),
446 ),
447 # ---
448 (
449 "empty one",
450 False,
451 {"name": "zucchini"},
452 (
453 0,
454 "",
455 "",
456 ),
457 ),
458 # ---
459 (
460 "empty one",
461 False,
462 {"name": "pow"},
463 (
464 -5,
465 "",
466 "pow",
467 ),
468 ),
469 # Ensure setting return_value to zero even on an exception is honored
470 (
471 "empty one",
472 False,
473 {"name": "pow", "retval": 0},
474 (
475 0,
476 "",
477 "pow",
478 ),
479 ),
480 ],
481 )
482 def test_cli_with_decorators(prefix, can_format, args, response):
483 dd = DecoDemo()
484 cmd = CLICommand.COMMANDS[prefix]
485 assert cmd.call(dd, args, None) == response
486 # slighly hacky way to check that the CLI "knows" about a --format option
487 # checking the extra_args feature of the Decorators that provide them (Responder)
488 if can_format:
489 assert 'name=format,' in cmd.args
490
491
492 def test_error_response():
493 e1 = object_format.ErrorResponse("nope")
494 assert e1.format_response() == (-22, "", "nope")
495 assert e1.return_value == -22
496 assert e1.errno == 22
497 assert "ErrorResponse" in repr(e1)
498 assert "nope" in repr(e1)
499 assert e1.mgr_return_value() == -22
500
501 try:
502 open("/this/is_/extremely_/unlikely/_to/exist.txt")
503 except Exception as e:
504 e2 = object_format.ErrorResponse.wrap(e)
505 r = e2.format_response()
506 assert r[0] == -errno.ENOENT
507 assert r[1] == ""
508 assert "No such file or directory" in r[2]
509 assert "ErrorResponse" in repr(e2)
510 assert "No such file or directory" in repr(e2)
511 assert r[0] == e2.mgr_return_value()
512
513 e3 = object_format.ErrorResponse.wrap(RuntimeError("blat"))
514 r = e3.format_response()
515 assert r[0] == -errno.EINVAL
516 assert r[1] == ""
517 assert "blat" in r[2]
518 assert r[0] == e3.mgr_return_value()
519
520 # A custom exception type with an errno property
521
522 class MyCoolException(Exception):
523 def __init__(self, err_msg: str, errno: int = 0) -> None:
524 super().__init__(errno, err_msg)
525 self.errno = errno
526 self.err_msg = err_msg
527
528 def __str__(self) -> str:
529 return self.err_msg
530
531 e4 = object_format.ErrorResponse.wrap(MyCoolException("beep", -17))
532 r = e4.format_response()
533 assert r[0] == -17
534 assert r[1] == ""
535 assert r[2] == "beep"
536 assert e4.mgr_return_value() == -17
537
538 e5 = object_format.ErrorResponse.wrap(MyCoolException("ok, fine", 0))
539 r = e5.format_response()
540 assert r[0] == 0
541 assert r[1] == ""
542 assert r[2] == "ok, fine"
543
544 e5 = object_format.ErrorResponse.wrap(MyCoolException("no can do", 8))
545 r = e5.format_response()
546 assert r[0] == -8
547 assert r[1] == ""
548 assert r[2] == "no can do"
549
550 # A custom exception type that inherits from ErrorResponseBase
551
552 class MyErrorResponse(object_format.ErrorResponseBase):
553 def __init__(self, err_msg: str, return_value: int):
554 super().__init__(self, err_msg)
555 self.msg = err_msg
556 self.return_value = return_value
557
558 def format_response(self):
559 return self.return_value, "", self.msg
560
561
562 e6 = object_format.ErrorResponse.wrap(MyErrorResponse("yeah, sure", 0))
563 r = e6.format_response()
564 assert r[0] == 0
565 assert r[1] == ""
566 assert r[2] == "yeah, sure"
567 assert isinstance(e5, object_format.ErrorResponseBase)
568 assert isinstance(e6, MyErrorResponse)
569
570 e7 = object_format.ErrorResponse.wrap(MyErrorResponse("no can do", -8))
571 r = e7.format_response()
572 assert r[0] == -8
573 assert r[1] == ""
574 assert r[2] == "no can do"
575 assert isinstance(e7, object_format.ErrorResponseBase)
576 assert isinstance(e7, MyErrorResponse)
577
578
579 def test_empty_responder_return_check():
580 dd = DecoDemo()
581 with pytest.raises(ValueError):
582 CLICommand.COMMANDS["empty bad"].call(dd, {}, None)