rucio-clients 35.7.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.

Potentially problematic release.


This version of rucio-clients might be problematic. Click here for more details.

Files changed (88) hide show
  1. rucio/__init__.py +17 -0
  2. rucio/alembicrevision.py +15 -0
  3. rucio/client/__init__.py +15 -0
  4. rucio/client/accountclient.py +433 -0
  5. rucio/client/accountlimitclient.py +183 -0
  6. rucio/client/baseclient.py +974 -0
  7. rucio/client/client.py +76 -0
  8. rucio/client/configclient.py +126 -0
  9. rucio/client/credentialclient.py +59 -0
  10. rucio/client/didclient.py +866 -0
  11. rucio/client/diracclient.py +56 -0
  12. rucio/client/downloadclient.py +1785 -0
  13. rucio/client/exportclient.py +44 -0
  14. rucio/client/fileclient.py +50 -0
  15. rucio/client/importclient.py +42 -0
  16. rucio/client/lifetimeclient.py +90 -0
  17. rucio/client/lockclient.py +109 -0
  18. rucio/client/metaconventionsclient.py +140 -0
  19. rucio/client/pingclient.py +44 -0
  20. rucio/client/replicaclient.py +454 -0
  21. rucio/client/requestclient.py +125 -0
  22. rucio/client/rseclient.py +746 -0
  23. rucio/client/ruleclient.py +294 -0
  24. rucio/client/scopeclient.py +90 -0
  25. rucio/client/subscriptionclient.py +173 -0
  26. rucio/client/touchclient.py +82 -0
  27. rucio/client/uploadclient.py +955 -0
  28. rucio/common/__init__.py +13 -0
  29. rucio/common/cache.py +74 -0
  30. rucio/common/config.py +801 -0
  31. rucio/common/constants.py +159 -0
  32. rucio/common/constraints.py +17 -0
  33. rucio/common/didtype.py +189 -0
  34. rucio/common/exception.py +1151 -0
  35. rucio/common/extra.py +36 -0
  36. rucio/common/logging.py +420 -0
  37. rucio/common/pcache.py +1408 -0
  38. rucio/common/plugins.py +153 -0
  39. rucio/common/policy.py +84 -0
  40. rucio/common/schema/__init__.py +150 -0
  41. rucio/common/schema/atlas.py +413 -0
  42. rucio/common/schema/belleii.py +408 -0
  43. rucio/common/schema/domatpc.py +401 -0
  44. rucio/common/schema/escape.py +426 -0
  45. rucio/common/schema/generic.py +433 -0
  46. rucio/common/schema/generic_multi_vo.py +412 -0
  47. rucio/common/schema/icecube.py +406 -0
  48. rucio/common/stomp_utils.py +159 -0
  49. rucio/common/stopwatch.py +55 -0
  50. rucio/common/test_rucio_server.py +148 -0
  51. rucio/common/types.py +403 -0
  52. rucio/common/utils.py +2238 -0
  53. rucio/rse/__init__.py +96 -0
  54. rucio/rse/protocols/__init__.py +13 -0
  55. rucio/rse/protocols/bittorrent.py +184 -0
  56. rucio/rse/protocols/cache.py +122 -0
  57. rucio/rse/protocols/dummy.py +111 -0
  58. rucio/rse/protocols/gfal.py +703 -0
  59. rucio/rse/protocols/globus.py +243 -0
  60. rucio/rse/protocols/gsiftp.py +92 -0
  61. rucio/rse/protocols/http_cache.py +82 -0
  62. rucio/rse/protocols/mock.py +123 -0
  63. rucio/rse/protocols/ngarc.py +209 -0
  64. rucio/rse/protocols/posix.py +250 -0
  65. rucio/rse/protocols/protocol.py +594 -0
  66. rucio/rse/protocols/rclone.py +364 -0
  67. rucio/rse/protocols/rfio.py +136 -0
  68. rucio/rse/protocols/srm.py +338 -0
  69. rucio/rse/protocols/ssh.py +413 -0
  70. rucio/rse/protocols/storm.py +206 -0
  71. rucio/rse/protocols/webdav.py +550 -0
  72. rucio/rse/protocols/xrootd.py +301 -0
  73. rucio/rse/rsemanager.py +764 -0
  74. rucio/vcsversion.py +11 -0
  75. rucio/version.py +38 -0
  76. rucio_clients-35.7.0.data/data/etc/rse-accounts.cfg.template +25 -0
  77. rucio_clients-35.7.0.data/data/etc/rucio.cfg.atlas.client.template +42 -0
  78. rucio_clients-35.7.0.data/data/etc/rucio.cfg.template +257 -0
  79. rucio_clients-35.7.0.data/data/requirements.client.txt +15 -0
  80. rucio_clients-35.7.0.data/data/rucio_client/merge_rucio_configs.py +144 -0
  81. rucio_clients-35.7.0.data/scripts/rucio +2542 -0
  82. rucio_clients-35.7.0.data/scripts/rucio-admin +2447 -0
  83. rucio_clients-35.7.0.dist-info/METADATA +50 -0
  84. rucio_clients-35.7.0.dist-info/RECORD +88 -0
  85. rucio_clients-35.7.0.dist-info/WHEEL +5 -0
  86. rucio_clients-35.7.0.dist-info/licenses/AUTHORS.rst +97 -0
  87. rucio_clients-35.7.0.dist-info/licenses/LICENSE +201 -0
  88. rucio_clients-35.7.0.dist-info/top_level.txt +1 -0
rucio/common/extra.py ADDED
@@ -0,0 +1,36 @@
1
+ # Copyright European Organization for Nuclear Research (CERN) since 2012
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import importlib
16
+ import warnings
17
+ from typing import TYPE_CHECKING
18
+
19
+ if TYPE_CHECKING:
20
+ from typing import Any
21
+
22
+
23
+ def import_extras(module_list: list[str]) -> "dict[str, Any]":
24
+ out = dict()
25
+ for mod in module_list:
26
+ out[mod] = None
27
+ try:
28
+ with warnings.catch_warnings():
29
+ # TODO: remove when https://github.com/paramiko/paramiko/issues/2038 is fixed
30
+ warnings.filterwarnings('ignore', 'Blowfish has been deprecated', module='paramiko')
31
+ # TODO: deprecated python 2 and 3.6 too ...
32
+ warnings.filterwarnings('ignore', 'Python .* is no longer supported', module='paramiko')
33
+ out[mod] = importlib.import_module(mod)
34
+ except ImportError:
35
+ pass
36
+ return out
@@ -0,0 +1,420 @@
1
+ # Copyright European Organization for Nuclear Research (CERN) since 2012
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ import datetime
15
+ import functools
16
+ import itertools
17
+ import json
18
+ import logging
19
+ import re
20
+ import sys
21
+ from collections.abc import Callable, Iterator, Mapping, Sequence
22
+ from traceback import format_tb
23
+ from typing import TYPE_CHECKING, Any, Literal, Optional, Union, get_args
24
+
25
+ from rucio.common.config import config_get, config_get_bool
26
+
27
+ if TYPE_CHECKING:
28
+ from logging import LogRecord, _SysExcInfoType
29
+
30
+ from _typeshed import OptExcInfo
31
+ from flask import Flask
32
+
33
+
34
+ # Mapping from ECS field paths
35
+ # https://www.elastic.co/guide/en/ecs-logging/overview/current/intro.html#_field_mapping
36
+ # https://www.elastic.co/guide/en/ecs/8.5/ecs-field-reference.html
37
+ # to python log record attributes:
38
+ # https://docs.python.org/3/library/logging.html#logrecord-attributes
39
+ ECS_FIELDS = Literal[
40
+ '@timestamp',
41
+ 'message',
42
+ 'log.level',
43
+ 'log.origin.function',
44
+ 'log.origin.file.line',
45
+ 'log.origin.file.name',
46
+ 'log.logger',
47
+ 'process.pid',
48
+ 'process.name',
49
+ 'process.thread.id',
50
+ 'process.thread.name'
51
+ ]
52
+
53
+ LOG_RECORDS = Literal[
54
+ 'asctime',
55
+ 'message',
56
+ 'levelname',
57
+ 'funcName',
58
+ 'lineno',
59
+ 'filename',
60
+ 'name',
61
+ 'process',
62
+ 'processName',
63
+ 'thread',
64
+ 'threadName'
65
+ ]
66
+
67
+ BUILTIN_FIELDS: tuple[tuple[ECS_FIELDS, LOG_RECORDS], ...] = tuple((x, y) for x, y in zip(get_args(ECS_FIELDS), get_args(LOG_RECORDS)))
68
+ ECS_TO_LOG_RECORD_MAP: dict[ECS_FIELDS, LOG_RECORDS] = dict(BUILTIN_FIELDS)
69
+ LOG_RECORD_TO_ECS_MAP: dict[LOG_RECORDS, ECS_FIELDS] = dict((f[1], f[0]) for f in BUILTIN_FIELDS)
70
+
71
+
72
+ def _json_serializable(obj: Any) -> Union[dict[Any, Any], str]:
73
+ try:
74
+ return obj.__dict__
75
+ except AttributeError:
76
+ return str(obj)
77
+
78
+
79
+ def _navigate_path(obj: Any, path: Sequence[str]) -> Optional[Any]:
80
+ """
81
+ Traverse the path in the given object either via attributes or via dict-like subscriptions.
82
+ Returns the found value; None if navigation fails
83
+
84
+ For example, for an input
85
+ obj = request # flask "request" object https://flask.palletsprojects.com/en/2.1.x/api/#flask.Request
86
+ path = ['headers', 'X-Rucio-Auth-Token']
87
+ returns the value found in
88
+ request.headers['X-Rucio-Auth-Token']
89
+ """
90
+ value = obj
91
+ i = 0
92
+ while value and i < len(path):
93
+ p = path[i]
94
+ try:
95
+ value = getattr(value, p)
96
+ except AttributeError:
97
+ try:
98
+ # Allow integers for access into arrays
99
+ p = int(p)
100
+ except ValueError:
101
+ pass
102
+ try:
103
+ value = value[p]
104
+ except (TypeError, KeyError):
105
+ value = None
106
+ i += 1
107
+ if value is obj:
108
+ return None
109
+ return value
110
+
111
+
112
+ def _unflatten_dict(dictionary: dict[str, Any]) -> dict[str, Any]:
113
+ """
114
+ Transform a dict of the form
115
+ {'a.b.c': value1, 'a.b.d': value2, 'z': value3}
116
+ into
117
+ {'a': {'b': {'c': value1, 'd': value2}}, 'z': value3}
118
+
119
+ On incompatible input keys (for example: 'a.b.c', 'a', 'a.d'), the last key wins
120
+ """
121
+ ret = {}
122
+ for k, v in dictionary.items():
123
+ path = k.split('.')
124
+ d = ret
125
+ i = 0
126
+ while i < len(path) - 1:
127
+ existing_v = d.get(path[i])
128
+ if isinstance(existing_v, dict):
129
+ d = existing_v
130
+ else:
131
+ d[path[i]] = {}
132
+ d = d[path[i]]
133
+ i += 1
134
+ if i == len(path) - 1:
135
+ d[path[i]] = v
136
+ return ret
137
+
138
+
139
+ def _get_request_data(request_path: Sequence[str]) -> "Callable[[LogDataSource, LogRecord], Iterator[tuple[str, Optional[Any]]]]":
140
+ """
141
+ Returns a function which, when called, will resolve the value
142
+ in the flask request object at request_path
143
+ """
144
+
145
+ # The import fails if imported inside a client due to rsemanager.
146
+ # TODO: move to top of file once we got rid of/refactored rsemanager
147
+ from flask import has_request_context, request
148
+
149
+ def _request_data_formatter(record_formatter: "LogDataSource", record: "LogRecord") -> Iterator[tuple[str, Optional[Any]]]:
150
+ value = None
151
+ if has_request_context() and request_path:
152
+ value = _navigate_path(request, request_path)
153
+ yield record_formatter.ecs_fields[0], str(value) if value is not None else None
154
+
155
+ return _request_data_formatter
156
+
157
+
158
+ def _get_record_attribute(attribute: str) -> "Callable[[LogDataSource, LogRecord], Iterator[tuple[str, Optional[Any]]]]":
159
+ """
160
+ Returns a function which, when called, will generate the value of the desired attribute from
161
+ the record passed in argument.
162
+ """
163
+
164
+ def _record_attribute_formatter(record_formatter: "LogDataSource", record: "LogRecord") -> Iterator[tuple[str, Optional[Any]]]:
165
+ value = None
166
+ try:
167
+ value = getattr(record, attribute)
168
+ except AttributeError:
169
+ pass
170
+ yield record_formatter.ecs_fields[0], value
171
+
172
+ return _record_attribute_formatter
173
+
174
+
175
+ def _timestamp_formatter(record_formatter: "LogDataSource", record: "LogRecord") -> Iterator[tuple[str, Optional[Any]]]:
176
+ """
177
+ Format a timestamp
178
+ """
179
+ yield record_formatter.ecs_fields[0], datetime.datetime.utcfromtimestamp(record.created).isoformat(timespec='milliseconds') + 'Z'
180
+
181
+
182
+ def _ecs_field_to_record_attribute(field_name: Union[ECS_FIELDS, str]) -> Union[LOG_RECORDS, str]:
183
+ """
184
+ Sanitize the path-like field name into a symbol which can be the name of an object attribute.
185
+ """
186
+ record = ECS_TO_LOG_RECORD_MAP.get(field_name) # type: ignore
187
+ if record:
188
+ return record
189
+ return field_name.replace('-', '_').replace('.', '_')
190
+
191
+
192
+ class LogDataSource:
193
+ """
194
+ Represents one log data source and allows to format it into one or more json fields
195
+ """
196
+ def __init__(
197
+ self,
198
+ ecs_fields: tuple[str, ...],
199
+ formatter: "Optional[Callable[[LogDataSource, LogRecord], Iterator[tuple[str, Optional[Any]]]]]" = None,
200
+ dst_record_attr: Optional[str] = None
201
+ ):
202
+ self.ecs_fields = ecs_fields
203
+ self._formatter = formatter
204
+ self._dst_record_attr = dst_record_attr
205
+
206
+ def __hash__(self):
207
+ return hash(self.ecs_fields)
208
+
209
+ def __eq__(self, other: Any):
210
+ if not other or not isinstance(other, self.__class__):
211
+ return False
212
+ return self.ecs_fields == other.ecs_fields
213
+
214
+ def __str__(self):
215
+ return self.__class__.__name__ + '(' + ', '.join(self.ecs_fields) + ')'
216
+
217
+ def format(self, record: "LogRecord") -> Optional[Iterator[tuple[str, Any]]]:
218
+ if not self._formatter:
219
+ return
220
+ for field_name, field_value in self._formatter(self, record):
221
+ if self._dst_record_attr:
222
+ setattr(record, self._dst_record_attr, field_value)
223
+ yield field_name, field_value
224
+
225
+
226
+ class MessageLogDataSource(LogDataSource):
227
+ def __init__(self):
228
+ super().__init__(
229
+ ecs_fields=('message', 'error.type', 'error.message', 'error.stack_trace'),
230
+ formatter=None,
231
+ )
232
+
233
+ @staticmethod
234
+ def _get_exc_info(record: "LogRecord") -> Optional[Union["OptExcInfo", "_SysExcInfoType"]]:
235
+ exc_info = record.exc_info
236
+ if not exc_info:
237
+ return None
238
+ if isinstance(exc_info, bool):
239
+ exc_info = sys.exc_info()
240
+ if isinstance(exc_info, (list, tuple)):
241
+ return exc_info
242
+ return None
243
+
244
+ def format(self, record: "LogRecord") -> Iterator[tuple[str, Optional[str]]]:
245
+ exc_info = self._get_exc_info(record)
246
+ message = record.getMessage()
247
+ error_type, error_message, stack_trace = None, None, None
248
+ if exc_info:
249
+ error_type = exc_info[0].__name__ if exc_info[0] else None
250
+ error_message = str(exc_info[1]) if exc_info[1] else None
251
+ stack_trace = "".join(format_tb(record.exc_info[2])) or None if exc_info[2] else None
252
+ if not stack_trace:
253
+ stack_trace = str(getattr(record, "stack_info", '')) or None
254
+
255
+ # Set the message into the record field
256
+ s = message
257
+ if error_message:
258
+ if s[-1:] != "\n":
259
+ s = s + "\n"
260
+ s = s + error_message
261
+ if stack_trace:
262
+ if s[-1:] != "\n":
263
+ s = s + "\n"
264
+ s = s + stack_trace
265
+ record.message = s
266
+
267
+ yield from zip(self.ecs_fields, (record.message, error_type, error_message, stack_trace))
268
+
269
+
270
+ class ConstantStrDataSource(LogDataSource):
271
+ """
272
+ Prints a constant string for the given ECS field.
273
+ """
274
+
275
+ def __init__(self, ecs_field: ECS_FIELDS, _str: str):
276
+ log_record = ECS_TO_LOG_RECORD_MAP.get(ecs_field, None)
277
+ self._str = _str
278
+
279
+ def _formatter(data_source: LogDataSource, record: "LogRecord") -> Iterator[tuple[str, str]]:
280
+ yield self.ecs_fields[0], self._str
281
+
282
+ super().__init__(ecs_fields=(ecs_field,), formatter=_formatter, dst_record_attr=log_record)
283
+
284
+
285
+ class RucioFormatter(logging.Formatter):
286
+ """
287
+ The formatter should be a drop-in replacement to the python builtin
288
+ formatter, with two additional additions:
289
+ - it can output directly to json
290
+ - it can include data from the flask 'request' object into the format
291
+
292
+ When the logger writes to a json format, it tries to respect the
293
+ Elastic Common Schema (ECS) specification, but without getting too
294
+ strict about it.
295
+
296
+ When the format string contains a dot-separated "path" starting with
297
+ `http.request.`, the rucio formatter will try to extract the given
298
+ path from the flask `request` object.
299
+ """
300
+
301
+ def __init__(
302
+ self,
303
+ fmt: Optional[str] = None,
304
+ validate: Optional[bool] = None,
305
+ output_json: bool = False,
306
+ additional_fields: Optional[Mapping[ECS_FIELDS, str]] = None
307
+ ):
308
+ _kwargs = {}
309
+ if validate is not None:
310
+ _kwargs["validate"] = validate
311
+
312
+ data_sources: dict[str, LogDataSource] = dict(
313
+ (ecs_field, LogDataSource((ecs_field,), formatter=_get_record_attribute(log_record)))
314
+ for ecs_field, log_record in BUILTIN_FIELDS
315
+ )
316
+ data_sources.update({
317
+ '@timestamp': LogDataSource(('@timestamp',), formatter=_timestamp_formatter),
318
+ 'message': MessageLogDataSource(), # ('message', 'error.type', 'error.message', 'error.stack_trace'),
319
+ })
320
+ data_sources.update(
321
+ (ecs_field, LogDataSource((ecs_field,),
322
+ dst_record_attr=_ecs_field_to_record_attribute(ecs_field),
323
+ formatter=_get_request_data(request_path=request_path.split('.'))))
324
+ for ecs_field, request_path in (
325
+ ('client.account.name', 'headers.X-Rucio-Account'), # this field is rucio-specific, not from the ECS specification
326
+ ('network.forwarded_ip', 'access_route.0'),
327
+ ('source.ip', 'remote_addr'),
328
+ ('url.full', 'url'),
329
+ ('user_agent.original', 'user_agent'),
330
+ )
331
+ )
332
+ if additional_fields:
333
+ data_sources.update({
334
+ ecs_field: ConstantStrDataSource(ecs_field, field_value)
335
+ for ecs_field, field_value in additional_fields.items()
336
+ })
337
+
338
+ self._desired_data_sources = []
339
+ if fmt:
340
+ # extract of field1, field2 from the printf format-string "%(field1)s %(field2)i"
341
+ # Allow simple path-like structures in fields (words separated with dots):
342
+ # - http.request.headers.X-Rucio-Auth-Token
343
+ # - http.request.url
344
+ _format_string_fields = set(t[0] for t in re.findall(r'%\((\w+(.\w+(-\w+)*)*)\)', fmt))
345
+
346
+ for field_name in _format_string_fields:
347
+ data_source = data_sources.get(LOG_RECORD_TO_ECS_MAP.get(field_name, field_name), None)
348
+
349
+ if '.' in field_name:
350
+ dst_record_attr = _ecs_field_to_record_attribute(field_name)
351
+ fmt = fmt.replace(f'%({field_name})', f'%({dst_record_attr})')
352
+ if field_name.startswith('http.request.'):
353
+ path = field_name.replace('http.request.', '', 1).split('.')
354
+ data_source = LogDataSource((field_name,), dst_record_attr=dst_record_attr, formatter=_get_request_data(path))
355
+ elif not data_source:
356
+ data_source = LogDataSource((field_name,), formatter=_get_record_attribute(field_name))
357
+
358
+ if data_source:
359
+ self._desired_data_sources.append(data_source)
360
+ else:
361
+ self._desired_data_sources = [data_sources['message']]
362
+
363
+ self.output_json = output_json
364
+ super().__init__(fmt=fmt, style='%', **_kwargs)
365
+
366
+ def format(self, record: "LogRecord") -> str:
367
+ json_record = dict(itertools.chain.from_iterable(f.format(record) for f in self._desired_data_sources)) # type: ignore
368
+ if self.output_json:
369
+ return self._to_json(_unflatten_dict(json_record))
370
+ else:
371
+ return super().format(record)
372
+
373
+ @staticmethod
374
+ def _to_json(record: dict[str, Any]) -> str:
375
+ try:
376
+ return json.dumps(record, default=_json_serializable)
377
+ except (TypeError, ValueError, OverflowError):
378
+ try:
379
+ return json.dumps(record)
380
+ except (TypeError, ValueError, OverflowError):
381
+ return '{}'
382
+
383
+
384
+ def rucio_log_formatter(process_name: Optional[str] = None) -> RucioFormatter:
385
+ config_logformat = config_get('common', 'logformat', raise_exception=False, default='%(asctime)s\t%(name)s\t%(process)d\t%(levelname)s\t%(message)s')
386
+ output_json = config_get_bool('common', 'logjson', default=False)
387
+ additional_fields = {}
388
+ if process_name:
389
+ additional_fields['process.name'] = process_name
390
+ return RucioFormatter(fmt=config_logformat, output_json=output_json, additional_fields=additional_fields)
391
+
392
+
393
+ def setup_logging(application: Optional["Flask"] = None, process_name: Optional[str] = None) -> None:
394
+ """
395
+ Configures the logging by setting the output stream to stdout and
396
+ configures log level and log format.
397
+ """
398
+ config_loglevel = getattr(logging, config_get('common', 'loglevel', raise_exception=False, default='DEBUG').upper())
399
+
400
+ stdouthandler = logging.StreamHandler(stream=sys.stdout)
401
+ stdouthandler.setFormatter(rucio_log_formatter(process_name=process_name))
402
+ stdouthandler.setLevel(config_loglevel)
403
+ logging.basicConfig(level=config_loglevel, handlers=[stdouthandler])
404
+
405
+ if application:
406
+ application.logger.addHandler(stdouthandler)
407
+
408
+
409
+ def formatted_logger(innerfunc: Callable, formatstr: str = "%s") -> Callable:
410
+ """
411
+ Decorates the passed function, formatting log input by
412
+ the passed formatstr. The format string must always include a %s.
413
+
414
+ :param innerfunc: function to be decorated. Must take (level, msg) arguments.
415
+ :param formatstr: format string with %s as placeholder.
416
+ """
417
+ @functools.wraps(innerfunc)
418
+ def log_format(level: int, msg: object, *args, **kwargs) -> Callable:
419
+ return innerfunc(level, formatstr % msg, *args, **kwargs)
420
+ return log_format