]> git.proxmox.com Git - ceph.git/blame - ceph/src/pybind/mgr/object_format.py
update ceph source to reef 18.2.0
[ceph.git] / ceph / src / pybind / mgr / object_format.py
CommitLineData
33c7a0ef
TL
1# object_format.py provides types and functions for working with
2# requested output formats such as JSON, YAML, etc.
3"""tools for writing formatting-friendly mgr module functions
4
5Currently, the ceph mgr code in python is most commonly written by adding mgr
6modules and corresponding classes and then adding methods to those classes that
7are decorated using `@CLICommand` from `mgr_module.py`. These methods (that
8will be called endpoints subsequently) then implement the logic that is
9executed when the mgr receives a command from a client. These endpoints are
10currently responsible for forming a response tuple of (int, str, str) where the
11int represents a return value (error code) and the first string the "body" of
12the response. The mgr supports a generic `format` parameter (`--format` on the
13ceph cli) that each endpoint must then explicitly handle. At the time of this
14writing, many endpoints do not handle alternate formats and are each
15implementing formatting/serialization of values in various different ways.
16
17The `object_format` module aims to make the process of writing endpoint
18functions easier, more consistent, and (hopefully) better documented. At the
19highest level, the module provides a new decorator `Responder` that must be
20placed below the `CLICommand` decorator (so that it decorates the endpoint
21before `CLICommand`). This decorator helps automatically convert Python objects
22to response tuples expected by the manager, while handling the `format`
23parameter automatically.
24
25In addition to the decorator the module provides a few other types and methods
26that intended to interoperate with the decorator and make small customizations
27and error handling easier.
28
29== Using Responder ==
30
31The simple and intended way to use the decorator is as follows:
32 @CLICommand("command name", perm="r")
33 Responder()
34 def create_something(self, name: str) -> Dict[str, str]:
35 ... # implementation
36 return {"name": name, "id": new_id}
37
38In this case the `create_something` method return a python dict,
39and does not return a response tuple directly. Instead, the
40dict is converted to either JSON or YAML depending on what the
41client requested. Assuming no exception is raised by the
42implementation then the response code is always zero (success).
43
44The object_format module provides an exception type `ErrorResponse`
45that assists in returning "clean" error conditions to the client.
46Extending the previous example to use this exception:
47 @CLICommand("command name", perm="r")
48 Responder()
49 def create_something(self, name: str) -> Dict[str, str]:
50 try:
51 ... # implementation
52 return {"name": name, "id": new_id}
53 except KeyError as kerr:
54 # explicitly set the return value to ENOENT for KeyError
55 raise ErrorResponse.wrap(kerr, return_value=-errno.ENOENT)
56 except (BusinessLogcError, OSError) as err:
57 # return value is based on err when possible
58 raise ErrorResponse.wrap(err)
59
60Most uses of ErrorResponse are expected to use the `wrap` classmethod,
61as it will aid in the handling of an existing exception but `ErrorResponse`
62can be used directly too.
63
64== Customizing Response Formatting ==
65
66The `Responder` is built using two additional mid-layer types. The
67`ObjectFormatAdapter` and the `ReturnValueAdapter` by default. These types
68implement the `CommonFormatter` protocol and `ReturnValueProvider` protocols
69respectively. Most cases will not need to customize the `ReturnValueAdapter` as
70returning zero on success is expected. However, if there's a need to return a
71non-zero error code outside of an exception, you can add the `mgr_return_value`
72function to the returned type of the endpoint function - causing it to meet the
73`ReturnValueProvider` protocol. Whatever integer that function returns will
74then be used in the response tuple.
75
76The `ObjectFormatAdapter` can operate in two modes. By default, any type
77returned from the endpoint function will be checked for a `to_simplified`
78method (the type matches the SimpleDataProvider` protocol) and if it exists
79the method will be called and the result serialized. Example:
80 class CoolStuff:
81 def __init__(self, temperature: int, quantity: int) -> None:
82 self.temperature = temperature
83 self.quantity = quantity
84 def to_simplified(self) -> Dict[str, int]:
85 return {"temp": self.temperature, "qty": self.quantity}
86
87 @CLICommand("command name", perm="r")
88 Responder()
89 def create_something_cool(self) -> CoolStuff:
90 cool_stuff: CoolStuff = self._make_cool_stuff() # implementation
91 return cool_stuff
92
93In order to serialize the result, the object returned from the wrapped
94function must provide the `to_simplified` method (or the compatibility methods,
95see below) or already be a "simplified type". Valid types include lists and
96dicts that contain other lists and dicts and ints, strs, bools -- basic objects
97that can be directly converted to json (via json.dumps) without any additional
98conversions. The `to_simplified` method must always return such types.
99
100To be compatible with many existing types in the ceph mgr codebase one can pass
101`compatible=True` to the `ObjectFormatAdapter`. If the type provides a
102`to_json` and/or `to_yaml` method that returns basic python types (dict, list,
103str, etc...) but *not* already serialized JSON or YAML this flag can be
104enabled. Note that Responder takes as an argument any callable that returns a
105`CommonFormatter`. In this example below we enable the flag using
106`functools.partial`:
107 class MyExistingClass:
108 def to_json(self) -> Dict[str, Any]:
109 return {"name": self.name, "height": self.height}
110
111 @CLICommand("command name", perm="r")
112 Responder(functools.partial(ObjectFormatAdapter, compatible=True))
113 def create_an_item(self) -> MyExistingClass:
114 item: MyExistingClass = self._new_item() # implementation
115 return item
116
117
118For cases that need to return xml or plain text formatted responses one can
119create a new class that matches the `CommonFormatter` protocol (provides a
120valid_formats method) and one or more `format_x` method where x is the name of
121a format ("json", "yaml", "xml", "plain", etc...).
122 class MyCustomFormatAdapter:
123 def __init__(self, obj_to_format: Any) -> None:
124 ...
125 def valid_formats(self) -> Iterable[str]:
126 ...
127 def format_json(self) -> str:
128 ...
129 def format_xml(self) -> str:
130 ...
131
132
133Of course, the Responder itself can be used as a base class and aspects of the
134Responder altered for specific use cases. Inheriting from `Responder` and
135customizing it is an exercise left for those brave enough to read the code in
136`object_format.py` :-).
137"""
138
139import enum
140import errno
141import json
142import sys
143
144from functools import wraps
145from typing import (
146 Any,
147 Callable,
148 Dict,
149 Iterable,
150 List,
151 Optional,
152 TYPE_CHECKING,
153 Tuple,
154 Type,
155 TypeVar,
156 Union,
157)
158
159import yaml
160
161# this uses a version check as opposed to a try/except because this
162# form makes mypy happy and try/except doesn't.
163if sys.version_info >= (3, 8):
164 from typing import Protocol
165elif TYPE_CHECKING:
166 # typing_extensions will not be available for the real mgr server
167 from typing_extensions import Protocol
168else:
169 # fallback type that is acceptable to older python on prod. builds
170 class Protocol: # type: ignore
171 pass
172
173from mgr_module import HandlerFuncType
174
175
176DEFAULT_JSON_INDENT: int = 2
177
178
179class Format(str, enum.Enum):
180 plain = "plain"
181 json = "json"
182 json_pretty = "json-pretty"
183 yaml = "yaml"
184 xml_pretty = "xml-pretty"
185 xml = "xml"
186
187
188# SimpleData is a type alias for Any unless we can determine the
189# exact set of subtypes we want to support. But it is explicit!
190SimpleData = Any
191
192
33c7a0ef
TL
193class SimpleDataProvider(Protocol):
194 def to_simplified(self) -> SimpleData:
195 """Return a simplified representation of the current object.
196 The simplified representation should be trivially serializable.
197 """
198 ... # pragma: no cover
199
200
201class JSONDataProvider(Protocol):
202 def to_json(self) -> Any:
203 """Return a python object that can be serialized into JSON.
204 This function does _not_ return a JSON string.
205 """
206 ... # pragma: no cover
207
208
209class YAMLDataProvider(Protocol):
210 def to_yaml(self) -> Any:
211 """Return a python object that can be serialized into YAML.
212 This function does _not_ return a string of YAML.
213 """
214 ... # pragma: no cover
215
216
217class JSONFormatter(Protocol):
218 def format_json(self) -> str:
219 """Return a JSON formatted representation of an object."""
220 ... # pragma: no cover
221
222
223class YAMLFormatter(Protocol):
224 def format_yaml(self) -> str:
225 """Return a JSON formatted representation of an object."""
226 ... # pragma: no cover
227
228
229class ReturnValueProvider(Protocol):
230 def mgr_return_value(self) -> int:
231 """Return an integer value to provide the Ceph MGR with a error code
232 for the MGR's response tuple. Zero means success. Return an negative
233 errno otherwise.
234 """
235 ... # pragma: no cover
236
237
238class CommonFormatter(Protocol):
239 """A protocol that indicates the type is a formatter for multiple
240 possible formats.
241 """
242
243 def valid_formats(self) -> Iterable[str]:
244 """Return the names of known valid formats."""
245 ... # pragma: no cover
246
247
248# The _is_name_of_protocol_type functions below are here because the production
249# builds of the ceph manager are lower than python 3.8 and do not have
250# typing_extensions available in the resulting images. This means that
251# runtime_checkable is not available and isinstance can not be used with a
252# protocol type. These could be replaced by isinstance in a later version of
253# python. Note that these functions *can not* be methods of the protocol types
254# for neatness - including methods on the protocl types makes mypy consider
255# those methods as part of the protcol & a required method. Using decorators
256# did not change that - I checked.
257
258
259def _is_simple_data_provider(obj: SimpleDataProvider) -> bool:
260 """Return true if obj is usable as a SimpleDataProvider."""
261 return callable(getattr(obj, 'to_simplified', None))
262
263
264def _is_json_data_provider(obj: JSONDataProvider) -> bool:
265 """Return true if obj is usable as a JSONDataProvider."""
266 return callable(getattr(obj, 'to_json', None))
267
268
269def _is_yaml_data_provider(obj: YAMLDataProvider) -> bool:
270 """Return true if obj is usable as a YAMLDataProvider."""
271 return callable(getattr(obj, 'to_yaml', None))
272
273
274def _is_return_value_provider(obj: ReturnValueProvider) -> bool:
275 """Return true if obj is usable as a YAMLDataProvider."""
276 return callable(getattr(obj, 'mgr_return_value', None))
277
278
279class ObjectFormatAdapter:
280 """A format adapater for a single object.
281 Given an input object, this type will adapt the object, or a simplified
282 representation of the object, to either JSON or YAML when the format_json or
283 format_yaml methods are used.
284
285 If the compatible flag is true and the object provided to the adapter has
286 methods such as `to_json` and/or `to_yaml` these methods will be called in
287 order to get a JSON/YAML compatible simplified representation of the
288 object.
289
290 If the above case is not satisfied and the object provided to the adapter
291 has a method `to_simplified`, this method will be called to acquire a
292 simplified representation of the object.
293
294 If none of the above cases is true, the object itself will be used for
295 serialization. If the object can not be safely serialized an exception will
296 be raised.
297
298 NOTE: Some code may use methods named like `to_json` to return a JSON
299 string. If that is the case, you should not use that method with the
300 ObjectFormatAdapter. Do not set compatible=True for objects of this type.
301 """
302
303 def __init__(
304 self,
305 obj: Any,
306 json_indent: Optional[int] = DEFAULT_JSON_INDENT,
307 compatible: bool = False,
308 ) -> None:
309 self.obj = obj
310 self._compatible = compatible
311 self.json_indent = json_indent
312
313 def _fetch_json_data(self) -> Any:
314 # if the data object provides a specific simplified representation for
315 # JSON (and compatible mode is enabled) get the data via that method
316 if self._compatible and _is_json_data_provider(self.obj):
317 return self.obj.to_json()
318 # otherwise we use our specific method `to_simplified` if it exists
319 if _is_simple_data_provider(self.obj):
320 return self.obj.to_simplified()
321 # and fall back to the "raw" object
322 return self.obj
323
324 def format_json(self) -> str:
325 """Return a JSON formatted string representing the input object."""
326 return json.dumps(
327 self._fetch_json_data(), indent=self.json_indent, sort_keys=True
328 )
329
330 def _fetch_yaml_data(self) -> Any:
331 if self._compatible and _is_yaml_data_provider(self.obj):
332 return self.obj.to_yaml()
333 # nothing specific to YAML was found. use the simplified representation
334 # for JSON, as all valid JSON is valid YAML.
335 return self._fetch_json_data()
336
337 def format_yaml(self) -> str:
338 """Return a YAML formatted string representing the input object."""
339 return yaml.safe_dump(self._fetch_yaml_data())
340
341 format_json_pretty = format_json
342
343 def valid_formats(self) -> Iterable[str]:
344 """Return valid format names."""
345 return set(str(v) for v in Format.__members__)
346
347
348class ReturnValueAdapter:
349 """A return-value adapter for an object.
350 Given an input object, this type will attempt to get a mgr return value
351 from the object if provides a `mgr_return_value` function.
352 If not it returns a default return value, typically 0.
353 """
354
355 def __init__(
356 self,
357 obj: Any,
358 default: int = 0,
359 ) -> None:
360 self.obj = obj
361 self.default_return_value = default
362
363 def mgr_return_value(self) -> int:
364 if _is_return_value_provider(self.obj):
365 return int(self.obj.mgr_return_value())
366 return self.default_return_value
367
368
369class ErrorResponseBase(Exception):
370 """An exception that can directly be converted to a mgr reponse."""
371
372 def format_response(self) -> Tuple[int, str, str]:
373 raise NotImplementedError()
374
375
376class UnknownFormat(ErrorResponseBase):
377 """Raised if the format name is unexpected.
378 This can help distinguish typos from formats that are known but
379 not implemented.
380 """
381
382 def __init__(self, format_name: str) -> None:
383 self.format_name = format_name
384
385 def format_response(self) -> Tuple[int, str, str]:
386 return -errno.EINVAL, "", f"Unknown format name: {self.format_name}"
387
388
389class UnsupportedFormat(ErrorResponseBase):
390 """Raised if the format name does not correspond to any valid
391 conversion functions.
392 """
393
394 def __init__(self, format_name: str) -> None:
395 self.format_name = format_name
396
397 def format_response(self) -> Tuple[int, str, str]:
398 return -errno.EINVAL, "", f"Unsupported format: {self.format_name}"
399
400
401class ErrorResponse(ErrorResponseBase):
402 """General exception convertible to a mgr response."""
403
404 E = TypeVar("E", bound="ErrorResponse")
405
406 def __init__(self, status: str, return_value: Optional[int] = None) -> None:
407 self.return_value = (
408 return_value if return_value is not None else -errno.EINVAL
409 )
410 self.status = status
411
412 def format_response(self) -> Tuple[int, str, str]:
413 return (self.return_value, "", self.status)
414
415 def mgr_return_value(self) -> int:
416 return self.return_value
417
418 @property
419 def errno(self) -> int:
420 rv = self.return_value
421 return -rv if rv < 0 else rv
422
423 def __repr__(self) -> str:
424 return f"ErrorResponse({self.status!r}, {self.return_value!r})"
425
426 @classmethod
427 def wrap(
428 cls: Type[E], exc: Exception, return_value: Optional[int] = None
39ae355f
TL
429 ) -> ErrorResponseBase:
430 if isinstance(exc, ErrorResponseBase):
431 return exc
33c7a0ef
TL
432 if return_value is None:
433 try:
39ae355f
TL
434 return_value = int(getattr(exc, "errno"))
435 if return_value > 0:
436 return_value = -return_value
33c7a0ef
TL
437 except (AttributeError, ValueError):
438 pass
439 err = cls(str(exc), return_value=return_value)
440 setattr(err, "__cause__", exc)
441 return err
442
443
39ae355f
TL
444ObjectResponseFuncType = Union[
445 Callable[..., Dict[Any, Any]],
446 Callable[..., List[Any]],
447 Callable[..., SimpleDataProvider],
448 Callable[..., JSONDataProvider],
449 Callable[..., YAMLDataProvider],
450 Callable[..., ReturnValueProvider],
451]
452
453
33c7a0ef
TL
454def _get_requested_format(f: ObjectResponseFuncType, kw: Dict[str, Any]) -> str:
455 # todo: leave 'format' in kw dict iff its part of f's signature
456 return kw.pop("format", None)
457
458
459class Responder:
460 """A decorator type intended to assist in converting Python return types
461 into valid responses for the Ceph MGR.
462
463 A function that returns a Python object will have the object converted into
464 a return value and formatted response body, based on the `format` argument
465 passed to the mgr. When used from the ceph cli tool the `--format=[name]`
466 argument is mapped to a `format` keyword argument. The decorated function
467 may provide a `format` argument (type str). If the decorated function does
468 not provide a `format` argument itself, the Responder decorator will
469 implicitly add one to the MGR's "CLI arguments" handling stack.
470
471 The Responder object is callable and is expected to be used as a decorator.
472 """
473
474 def __init__(
475 self, formatter: Optional[Callable[..., CommonFormatter]] = None
476 ) -> None:
477 self.formatter = formatter
478 self.default_format = "json"
479
480 def _formatter(self, obj: Any) -> CommonFormatter:
481 """Return the formatter/format-adapter for the object."""
482 if self.formatter is not None:
483 return self.formatter(obj)
484 return ObjectFormatAdapter(obj)
485
486 def _retval_provider(self, obj: Any) -> ReturnValueProvider:
487 """Return a ReturnValueProvider for the given object."""
488 return ReturnValueAdapter(obj)
489
490 def _get_format_func(
491 self, obj: Any, format_req: Optional[str] = None
492 ) -> Callable:
493 formatter = self._formatter(obj)
494 if format_req is None:
495 format_req = self.default_format
496 if format_req not in formatter.valid_formats():
497 raise UnknownFormat(format_req)
498 req = str(format_req).replace("-", "_")
499 ffunc = getattr(formatter, f"format_{req}", None)
500 if ffunc is None:
501 raise UnsupportedFormat(format_req)
502 return ffunc
503
504 def _dry_run(self, format_req: Optional[str] = None) -> None:
505 """Raise an exception if the format_req is not supported."""
506 # call with an empty dict to see if format_req is valid and supported
507 self._get_format_func({}, format_req)
508
509 def _formatted(self, obj: Any, format_req: Optional[str] = None) -> str:
510 """Return the object formatted/serialized."""
511 ffunc = self._get_format_func(obj, format_req)
512 return ffunc()
513
514 def _return_value(self, obj: Any) -> int:
515 """Return a mgr return-value for the given object (usually zero)."""
516 return self._retval_provider(obj).mgr_return_value()
517
518 def __call__(self, f: ObjectResponseFuncType) -> HandlerFuncType:
519 """Wrap a python function so that the original function's return value
520 becomes the source for an automatically formatted mgr response.
521 """
522
523 @wraps(f)
524 def _format_response(*args: Any, **kwargs: Any) -> Tuple[int, str, str]:
525 format_req = _get_requested_format(f, kwargs)
526 try:
527 self._dry_run(format_req)
528 robj = f(*args, **kwargs)
529 body = self._formatted(robj, format_req)
530 retval = self._return_value(robj)
531 except ErrorResponseBase as e:
532 return e.format_response()
533 return retval, body, ""
534
535 # set the extra args on our wrapper function. this will be consumed by
536 # the CLICommand decorator and added to the set of optional arguments
537 # on the ceph cli/api
538 setattr(_format_response, "extra_args", {"format": str})
539 return _format_response
39ae355f
TL
540
541
542class ErrorResponseHandler:
543 """ErrorResponseHandler is a very simple decorator that handles functions that
544 raise exceptions inheriting from ErrorResponseBase. If such an exception
545 is raised that exception can and will be converted to a mgr response tuple.
546 This is similar to Responder but error handling is all this decorator does.
547 """
548
549 def __call__(self, f: Callable[..., Tuple[int, str, str]]) -> HandlerFuncType:
550 """Wrap a python function so that if the function raises an exception inheriting
551 ErrorResponderBase the error is correctly converted to a mgr response.
552 """
553
554 @wraps(f)
555 def _format_response(*args: Any, **kwargs: Any) -> Tuple[int, str, str]:
556 try:
557 retval, body, sts = f(*args, **kwargs)
558 except ErrorResponseBase as e:
559 return e.format_response()
560 return retval, body, sts
561
562 return _format_response
563
564
565class ConstantResponderBase:
566 """The constant responder base assumes that a wrapped function should not
567 be passing data back to the manager. It only responds with the default
568 (constant) values provided. The process_response function allows a subclass
569 to handle/log/validate any values that were returned from the wrapped
570 function.
571
572 This class can be used a building block for special decorators that
573 do not normally emit response data.
574 """
575
576 def mgr_return_value(self) -> int:
577 return 0
578
579 def mgr_body_value(self) -> str:
580 return ""
581
582 def mgr_status_value(self) -> str:
583 return ""
584
585 def process_response(self, result: Any) -> None:
586 return None
587
588 def __call__(self, f: Callable) -> HandlerFuncType:
589 """Wrap a python function so that if the function raises an exception
590 inheriting ErrorResponderBase the error is correctly converted to a mgr
591 response. Otherwise, it returns a default set of constant values.
592 """
593
594 @wraps(f)
595 def _format_response(*args: Any, **kwargs: Any) -> Tuple[int, str, str]:
596 try:
597 self.process_response(f(*args, **kwargs))
598 except ErrorResponseBase as e:
599 return e.format_response()
600 return self.mgr_return_value(), self.mgr_body_value(), self.mgr_status_value()
601 return _format_response
602
603
604class EmptyResponder(ConstantResponderBase):
605 """Always respond with an empty (string) body. Checks that the wrapped function
606 returned None in order to ensure it is not being used on functions that
607 return data objects.
608 """
609
610 def process_response(self, result: Any) -> None:
611 if result is not None:
612 raise ValueError("EmptyResponder expects None from wrapped functions")