elapi 1.0.7.dev1__py3-none-any.whl → 1.0.7.dev2__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.
- elapi/VERSION +1 -1
- elapi/_exceptions.py +1 -0
- elapi/api.py +2 -2
- elapi/cli/__init__.py +1 -1
- elapi/cli/doc.py +2 -1
- elapi/cli/elapi.py +40 -24
- elapi/path.py +68 -14
- elapi/plugins/bill_teams/_doc.py +27 -0
- elapi/plugins/bill_teams/bill_teams.py +189 -100
- elapi/plugins/bill_teams/cli.py +261 -32
- elapi/plugins/bill_teams/format.py +30 -0
- elapi/plugins/bill_teams/invoice.py +8 -19
- elapi/plugins/bill_teams/validator.py +221 -0
- elapi/plugins/{utils → commons}/__init__.py +1 -0
- elapi/{cli/helpers.py → plugins/commons/cli_helpers.py} +7 -7
- elapi/plugins/commons/get_information.py +117 -0
- elapi/plugins/experiments/_doc.py +29 -0
- elapi/plugins/experiments/cli.py +5 -7
- elapi/plugins/experiments/experiments.py +3 -3
- elapi/plugins/show_config.py +4 -4
- elapi/styles/__init__.py +2 -2
- elapi/styles/format.py +47 -4
- elapi/styles/highlight.py +10 -2
- elapi/validators/__init__.py +2 -1
- elapi/validators/base.py +18 -6
- elapi/validators/identity.py +46 -3
- elapi/validators/path.py +8 -14
- elapi/validators/permission.py +84 -32
- {elapi-1.0.7.dev1.dist-info → elapi-1.0.7.dev2.dist-info}/METADATA +81 -25
- elapi-1.0.7.dev2.dist-info/RECORD +58 -0
- elapi-1.0.7.dev1.dist-info/RECORD +0 -54
- /elapi/plugins/{utils → commons}/export.py +0 -0
- /elapi/plugins/{utils → commons}/get_location_from_headers.py +0 -0
- {elapi-1.0.7.dev1.dist-info → elapi-1.0.7.dev2.dist-info}/LICENSE +0 -0
- {elapi-1.0.7.dev1.dist-info → elapi-1.0.7.dev2.dist-info}/WHEEL +0 -0
- {elapi-1.0.7.dev1.dist-info → elapi-1.0.7.dev2.dist-info}/entry_points.txt +0 -0
elapi/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.7-
|
|
1
|
+
1.0.7-dev2
|
elapi/_exceptions.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
class NoException(Exception): ...
|
elapi/api.py
CHANGED
|
@@ -154,7 +154,7 @@ class ElabFTWURL:
|
|
|
154
154
|
@endpoint_id.setter
|
|
155
155
|
def endpoint_id(self, value):
|
|
156
156
|
if value is not None:
|
|
157
|
-
if not re.match(r"
|
|
157
|
+
if not re.match(r"^(\d+)$|^(me)$", value := str(value)):
|
|
158
158
|
# Although, eLabFTW primarily supports integer-only IDs, there are exceptions, like the alias
|
|
159
159
|
# ID "me" for receiving one's own user information.
|
|
160
160
|
raise ElabFTWURLError("Invalid endpoint ID (or entity ID).")
|
|
@@ -173,7 +173,7 @@ class ElabFTWURL:
|
|
|
173
173
|
"Sub-endpoint ID cannot be defined without first specifying its sub-endpoint name."
|
|
174
174
|
)
|
|
175
175
|
if value is not None:
|
|
176
|
-
if not re.match(r"
|
|
176
|
+
if not re.match(r"^(\d+)$|^(me)$", value := str(value)):
|
|
177
177
|
raise ElabFTWURLError("Invalid sub-endpoint ID (or entity sub-ID).")
|
|
178
178
|
self._sub_endpoint_id = value
|
|
179
179
|
else:
|
elapi/cli/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
from .
|
|
1
|
+
from ..plugins.commons.cli_helpers import CLIExport, CLIFormat, OrderedCommands # noqa: F401
|
elapi/cli/doc.py
CHANGED
|
@@ -76,7 +76,8 @@ __PARAMETERS__doc__ = {
|
|
|
76
76
|
"When 'txt' is used, the response will be sent in *original*, un-formatted (almost), "
|
|
77
77
|
"without syntax highlighting. This can be utilized if one wishes to pipe the output "
|
|
78
78
|
" to some external formatting program like `less`. "
|
|
79
|
-
"If an unsupported format value is provided then the output format
|
|
79
|
+
"If an unsupported format value is provided then the output format "
|
|
80
|
+
f"falls back to `{DEFAULT_EXPORT_DATA_FORMAT.upper()}`.",
|
|
80
81
|
"no_keys": "Do not show the names of configuration keywords.",
|
|
81
82
|
"init_host": 'The host URL of your eLabFTW instance. It will look like \"https://demo.elabftw.net/api/v2\".',
|
|
82
83
|
"init_api_token": 'API token (or API key) of your eLabFTW instance. You can generate it from eLabFTW "User Panel". '
|
elapi/cli/elapi.py
CHANGED
|
@@ -21,10 +21,10 @@ from typing_extensions import Annotated
|
|
|
21
21
|
|
|
22
22
|
from ._plugin_handler import internal_plugin_typer_apps
|
|
23
23
|
from .doc import __PARAMETERS__doc__ as docs
|
|
24
|
-
from .helpers import OrderedCommands
|
|
25
24
|
from .. import APP_NAME
|
|
26
25
|
from ..configuration import EXPORT_DIR
|
|
27
26
|
from ..loggers import Logger
|
|
27
|
+
from ..plugins.commons.cli_helpers import OrderedCommands
|
|
28
28
|
from ..styles import get_custom_help_text
|
|
29
29
|
from ..styles import stdin_console, stderr_console
|
|
30
30
|
|
|
@@ -101,6 +101,7 @@ def init(
|
|
|
101
101
|
from ..configuration import LOCAL_CONFIG_LOC
|
|
102
102
|
from ..validators import Validate, ValidationError
|
|
103
103
|
from ..validators import PathValidator
|
|
104
|
+
from ..path import ProperPath
|
|
104
105
|
from time import sleep
|
|
105
106
|
|
|
106
107
|
with stdin_console.status(
|
|
@@ -119,27 +120,42 @@ def init(
|
|
|
119
120
|
)
|
|
120
121
|
raise typer.Exit(1)
|
|
121
122
|
else:
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
123
|
+
path = ProperPath(LOCAL_CONFIG_LOC)
|
|
124
|
+
try:
|
|
125
|
+
with path.open(mode="r") as f:
|
|
126
|
+
if f.read():
|
|
127
|
+
logger.error(
|
|
128
|
+
f"A configuration file '{LOCAL_CONFIG_LOC}' already exists and it's not empty! "
|
|
129
|
+
f"It's ambiguous what to do in this situation."
|
|
130
|
+
)
|
|
131
|
+
logger.error("Configuration initialization has failed!")
|
|
132
|
+
raise typer.Exit(1)
|
|
133
|
+
except path.PathException as e:
|
|
134
|
+
if isinstance(e, FileNotFoundError):
|
|
135
|
+
path.create()
|
|
130
136
|
else:
|
|
137
|
+
logger.error(e)
|
|
138
|
+
logger.error("Configuration initialization has failed!")
|
|
139
|
+
raise typer.Exit(1)
|
|
140
|
+
try:
|
|
141
|
+
with path.open(mode="w") as f:
|
|
131
142
|
_configuration_yaml_text = f"""host: {host_url}
|
|
132
|
-
api_token: {api_token}
|
|
133
|
-
export_dir: {export_directory}
|
|
134
|
-
unsafe_api_token_warning: yes
|
|
135
|
-
"""
|
|
143
|
+
api_token: {api_token}
|
|
144
|
+
export_dir: {export_directory}
|
|
145
|
+
unsafe_api_token_warning: yes
|
|
146
|
+
"""
|
|
136
147
|
f.write(_configuration_yaml_text)
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
148
|
+
except path.PathException as e:
|
|
149
|
+
logger.error(e)
|
|
150
|
+
logger.error("Configuration initialization has failed!")
|
|
151
|
+
raise typer.Exit(1)
|
|
152
|
+
else:
|
|
153
|
+
stdin_console.print(
|
|
154
|
+
"Configuration file has been successfully created! "
|
|
155
|
+
f"Run '{APP_NAME} show-config' to see the configuration path "
|
|
156
|
+
"and more configuration details.",
|
|
157
|
+
style="green",
|
|
158
|
+
)
|
|
143
159
|
|
|
144
160
|
|
|
145
161
|
@app.command(short_help="Make `GET` requests to eLabFTW endpoints.")
|
|
@@ -200,9 +216,9 @@ def get(
|
|
|
200
216
|
import re
|
|
201
217
|
from .. import APP_NAME
|
|
202
218
|
from ..api import GETRequest, ElabFTWURLError
|
|
203
|
-
from .
|
|
219
|
+
from ..plugins.commons.cli_helpers import CLIExport, CLIFormat
|
|
204
220
|
from ..validators import Validate, HostIdentityValidator
|
|
205
|
-
from ..plugins.
|
|
221
|
+
from ..plugins.commons import Export
|
|
206
222
|
from ..styles import Highlight
|
|
207
223
|
|
|
208
224
|
validate_config = Validate(HostIdentityValidator())
|
|
@@ -324,7 +340,7 @@ def post(
|
|
|
324
340
|
|
|
325
341
|
With `elapi` you can do the following:
|
|
326
342
|
<br/>
|
|
327
|
-
`$ elapi post users
|
|
343
|
+
`$ elapi post users -d '{"firstname": "John", "lastname": "Doe", "email": "test_test@itnerd.de"}'`
|
|
328
344
|
will create a new user.
|
|
329
345
|
"""
|
|
330
346
|
import ast
|
|
@@ -332,7 +348,7 @@ def post(
|
|
|
332
348
|
from ..api import POSTRequest, ElabFTWURLError
|
|
333
349
|
from json import JSONDecodeError
|
|
334
350
|
from ..validators import Validate, HostIdentityValidator
|
|
335
|
-
from ..plugins.
|
|
351
|
+
from ..plugins.commons import get_location_from_headers
|
|
336
352
|
from ..styles import Format, Highlight
|
|
337
353
|
from ..path import ProperPath
|
|
338
354
|
|
|
@@ -670,7 +686,7 @@ def show_config(
|
|
|
670
686
|
no_keys: Annotated[
|
|
671
687
|
Optional[bool],
|
|
672
688
|
typer.Option("--no-keys", help=docs["no_keys"], show_default=True),
|
|
673
|
-
] =
|
|
689
|
+
] = False,
|
|
674
690
|
) -> None:
|
|
675
691
|
"""
|
|
676
692
|
Get information about detected configuration values.
|
elapi/path.py
CHANGED
|
@@ -7,13 +7,14 @@ from pathlib import Path
|
|
|
7
7
|
from shutil import rmtree
|
|
8
8
|
from typing import Union, TextIO, Generator
|
|
9
9
|
|
|
10
|
+
from ._exceptions import NoException
|
|
10
11
|
from .loggers import SimpleLogger
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
class ProperPath:
|
|
14
15
|
def __init__(
|
|
15
16
|
self,
|
|
16
|
-
name: Union[str, Path, None],
|
|
17
|
+
name: Union[str, Path, None, "ProperPath"],
|
|
17
18
|
env_var: bool = False,
|
|
18
19
|
kind: Union[str, None] = "",
|
|
19
20
|
err_logger: logging.Logger = SimpleLogger(),
|
|
@@ -22,6 +23,7 @@ class ProperPath:
|
|
|
22
23
|
self.env_var = env_var
|
|
23
24
|
self.kind = kind
|
|
24
25
|
self.err_logger = err_logger
|
|
26
|
+
self.PathException = NoException
|
|
25
27
|
|
|
26
28
|
def __str__(self):
|
|
27
29
|
return str(self.expanded)
|
|
@@ -62,6 +64,24 @@ class ProperPath:
|
|
|
62
64
|
raise ValueError("'err_logger' must be a logging.Logger instance!")
|
|
63
65
|
self._err_logger = value
|
|
64
66
|
|
|
67
|
+
@property
|
|
68
|
+
def PathException(self) -> type:
|
|
69
|
+
return self._PathException
|
|
70
|
+
|
|
71
|
+
# noinspection PyAttributeOutsideInit
|
|
72
|
+
@PathException.setter
|
|
73
|
+
def PathException(self, value: type) -> None:
|
|
74
|
+
if not issubclass(value, (Exception, BaseException)):
|
|
75
|
+
raise ValueError(
|
|
76
|
+
"Only an instance of Exception or BaseException can be "
|
|
77
|
+
"assigned to descriptor PathException."
|
|
78
|
+
)
|
|
79
|
+
self._PathException = value
|
|
80
|
+
|
|
81
|
+
@PathException.deleter
|
|
82
|
+
def PathException(self):
|
|
83
|
+
raise AttributeError("PathException cannot be deleted!")
|
|
84
|
+
|
|
65
85
|
@property
|
|
66
86
|
def expanded(self) -> Path:
|
|
67
87
|
if self.env_var:
|
|
@@ -134,10 +154,24 @@ class ProperPath:
|
|
|
134
154
|
(path_parent / path_file).touch(exist_ok=True)
|
|
135
155
|
elif self.kind == "dir":
|
|
136
156
|
path.mkdir(parents=True, exist_ok=True)
|
|
137
|
-
except PermissionError as e:
|
|
157
|
+
except (exception := PermissionError) as e:
|
|
138
158
|
message = f"Permission to create {self._error_helper_compare_path_source(self.name, path)} is denied."
|
|
139
159
|
self.err_logger.error(message)
|
|
160
|
+
self.PathException = exception
|
|
161
|
+
raise e
|
|
162
|
+
except (exception := NotADirectoryError) as e:
|
|
163
|
+
# Both "file" and "dir" cases are handled, but when path is under special files like
|
|
164
|
+
# /dev/null/<directory name>, os.mkdir() will throw NotADirectoryError.
|
|
165
|
+
message = f"Couldn't create {self._error_helper_compare_path_source(self.name, path)}."
|
|
166
|
+
self.err_logger.error(message)
|
|
167
|
+
self.PathException = exception
|
|
140
168
|
raise e
|
|
169
|
+
except (exception := OSError) as os_err:
|
|
170
|
+
# When an attempt to create a file or directory inside root (e.g., '/foo')
|
|
171
|
+
# is made, OS can throw OSError with error no. 30 instead of PermissionError.
|
|
172
|
+
self.err_logger.error(os_err)
|
|
173
|
+
self.PathException = exception
|
|
174
|
+
raise os_err
|
|
141
175
|
else:
|
|
142
176
|
return path
|
|
143
177
|
|
|
@@ -153,10 +187,12 @@ class ProperPath:
|
|
|
153
187
|
file.unlink()
|
|
154
188
|
except FileNotFoundError as e:
|
|
155
189
|
# unlink() throws FileNotFoundError when a directory is passed as it expects files only
|
|
156
|
-
|
|
157
|
-
|
|
190
|
+
self.PathException = exception = ValueError
|
|
191
|
+
raise exception(f"{file} doesn't exist or isn't a valid file!") from e
|
|
192
|
+
except (exception := PermissionError) as e:
|
|
158
193
|
message = f"Permission to remove {self._error_helper_compare_path_source(self.name, file)} is denied."
|
|
159
194
|
self.err_logger.warning(message)
|
|
195
|
+
self.PathException = exception
|
|
160
196
|
raise e
|
|
161
197
|
if verbose:
|
|
162
198
|
self.err_logger.info(f"Deleted: {file}")
|
|
@@ -187,42 +223,53 @@ class ProperPath:
|
|
|
187
223
|
|
|
188
224
|
@contextmanager
|
|
189
225
|
def open(
|
|
190
|
-
self,
|
|
226
|
+
self,
|
|
227
|
+
mode="r",
|
|
228
|
+
encoding: Union[str, None] = None,
|
|
229
|
+
**kwargs,
|
|
191
230
|
) -> Generator[TextIO, None, None]:
|
|
192
231
|
path = self.expanded.resolve()
|
|
193
232
|
file: Union[TextIO, None] = None
|
|
194
233
|
try:
|
|
195
234
|
# this try block doesn't yield anything yet. Here, we want to catch possible errors that occur
|
|
196
235
|
# before the file is opened. E.g., FileNotFoundError
|
|
197
|
-
file: TextIO = path.open(mode=mode, encoding=encoding)
|
|
198
|
-
except FileNotFoundError as e:
|
|
236
|
+
file: TextIO = path.open(mode=mode, encoding=encoding, **kwargs)
|
|
237
|
+
except (exception := FileNotFoundError) as e:
|
|
199
238
|
message = f"File in {path} couldn't be found while trying to open it with mode '{mode}'!"
|
|
200
239
|
self.err_logger.warning(message)
|
|
240
|
+
self.PathException = exception
|
|
201
241
|
raise e
|
|
202
242
|
|
|
203
|
-
except PermissionError as e:
|
|
243
|
+
except (exception := PermissionError) as e:
|
|
204
244
|
message = (
|
|
205
245
|
f"Permission denied while trying to open file with mode '{mode}' for "
|
|
206
246
|
f"{self._error_helper_compare_path_source(self.name, path)}."
|
|
207
247
|
)
|
|
208
248
|
self.err_logger.error(message)
|
|
249
|
+
self.PathException = exception
|
|
209
250
|
|
|
210
251
|
try:
|
|
211
252
|
yield # Without yield (yield None) Python throws RuntimeError: generator didn't yield.
|
|
212
253
|
# I.e., contextmanager always expects a yield?
|
|
213
|
-
except AttributeError as attribute_err:
|
|
254
|
+
except (exception := AttributeError) as attribute_err:
|
|
214
255
|
# However, yielding None leads to attribute calls to None
|
|
215
256
|
# (e.g., yield None -> file = None -> file.read() -> None.read()!! So we also catch that error.
|
|
216
257
|
attribute_in_error = str(attribute_err).split()[-1]
|
|
217
258
|
message = (
|
|
218
|
-
f"An attempt to access attribute {attribute_in_error} "
|
|
219
|
-
f"of the file object was made, "
|
|
259
|
+
f"An attempt to access attribute {attribute_in_error} was made,"
|
|
220
260
|
f"but there was a problem opening the file {path}."
|
|
221
261
|
)
|
|
222
262
|
self.err_logger.warning(message)
|
|
263
|
+
self.PathException = exception
|
|
223
264
|
raise attribute_err
|
|
224
265
|
else:
|
|
225
266
|
raise e
|
|
267
|
+
except (exception := OSError) as os_err:
|
|
268
|
+
# When an attempt to create a file or directory inside root (e.g., '/foo')
|
|
269
|
+
# is made, OS can throw OSError with error no. 30 instead of PermissionError.
|
|
270
|
+
self.err_logger.error(os_err)
|
|
271
|
+
self.PathException = exception
|
|
272
|
+
raise os_err
|
|
226
273
|
|
|
227
274
|
else:
|
|
228
275
|
try:
|
|
@@ -238,14 +285,15 @@ class ProperPath:
|
|
|
238
285
|
)
|
|
239
286
|
self.err_logger.warning(message)
|
|
240
287
|
raise e
|
|
241
|
-
except MemoryError as e:
|
|
288
|
+
except (exception := MemoryError) as e:
|
|
242
289
|
message = (
|
|
243
290
|
f"Out of memory while trying to use mode '{mode}' with "
|
|
244
291
|
f"{self._error_helper_compare_path_source(self.name, path)}."
|
|
245
292
|
)
|
|
246
293
|
self.err_logger.critical(message)
|
|
294
|
+
self.PathException = exception
|
|
247
295
|
raise e
|
|
248
|
-
except IOError as io_err:
|
|
296
|
+
except (exception := IOError) as io_err:
|
|
249
297
|
# We catch "No disk space left" error which will likely be triggered during a write attempt on the file
|
|
250
298
|
if io_err.errno == errno.ENOSPC:
|
|
251
299
|
message = (
|
|
@@ -253,7 +301,12 @@ class ProperPath:
|
|
|
253
301
|
f"{self._error_helper_compare_path_source(self.name, path)}."
|
|
254
302
|
)
|
|
255
303
|
self.err_logger.critical(message)
|
|
304
|
+
self.PathException = exception
|
|
256
305
|
raise io_err
|
|
306
|
+
except (exception := OSError) as os_err:
|
|
307
|
+
self.err_logger.error(os_err)
|
|
308
|
+
self.PathException = exception
|
|
309
|
+
raise os_err
|
|
257
310
|
finally:
|
|
258
311
|
if file:
|
|
259
312
|
try:
|
|
@@ -264,7 +317,7 @@ class ProperPath:
|
|
|
264
317
|
# f = open("/dev/full", mode="w");f.write("hello" * 10_000); <- Opening f again.
|
|
265
318
|
# The above will trigger ENOSPC error, and will be captured by previous ENOSPC IOError exception.
|
|
266
319
|
# Because of *, we again need to catch the error during close().
|
|
267
|
-
except IOError as io_err:
|
|
320
|
+
except (exception := IOError) as io_err:
|
|
268
321
|
if io_err.errno == errno.ENOSPC:
|
|
269
322
|
message = (
|
|
270
323
|
f"An 'ENOSPC' error (not enough disk space left) is received while trying to"
|
|
@@ -272,4 +325,5 @@ class ProperPath:
|
|
|
272
325
|
f"{self._error_helper_compare_path_source(self.name, path)}."
|
|
273
326
|
)
|
|
274
327
|
self.err_logger.critical(message)
|
|
328
|
+
self.PathException = exception
|
|
275
329
|
raise io_err
|
elapi/plugins/bill_teams/_doc.py
CHANGED
|
@@ -1,3 +1,30 @@
|
|
|
1
|
+
from ...configuration import DEFAULT_EXPORT_DATA_FORMAT
|
|
2
|
+
|
|
3
|
+
_UNIQUE_SUPPORTED_FORMATS = ["json", "yaml", "txt"]
|
|
4
|
+
supported_format_values = ", ".join(
|
|
5
|
+
f"**{_.upper()}**" for _ in _UNIQUE_SUPPORTED_FORMATS
|
|
6
|
+
)
|
|
7
|
+
|
|
1
8
|
__PARAMETERS__doc__ = {
|
|
2
9
|
"invoice_export": "Export output to a location. Invoices are **always exported** by default.\n",
|
|
10
|
+
"root_directory": "The root of the directory where store-info will create the billing directory structure, "
|
|
11
|
+
"and save teams and owners information in `JSON`. store-info also sorts the JSON keys "
|
|
12
|
+
"before saving.",
|
|
13
|
+
"owners_data_path": "Source path for owners information `CSV` file. Internally referred to as the "
|
|
14
|
+
"`'billing metadata'. --meta-source` is not required when `--teams-info-only` is passed, "
|
|
15
|
+
"otherwise it is required.",
|
|
16
|
+
"target_date": "ISO 8604 'YYYY-MM' for which 'YYYY/MM' directory to store.",
|
|
17
|
+
"teams_info_only": "If passed, then only `teams-info` will be stored."
|
|
18
|
+
"Both _--teams-info-only_ and _--owners-info-only_ cannot be passed.",
|
|
19
|
+
"owners_info_only": "If passed, then only `owners-info` will be stored. "
|
|
20
|
+
"Both _--teams-info-only_ and _--owners-info-only_ cannot be passed.",
|
|
21
|
+
"data_format": f"Format style for the output. Supported values are: {supported_format_values}. "
|
|
22
|
+
f"CSV is **not** a supported format for this command as the "
|
|
23
|
+
f"output data structure is too complex for naive CSV. "
|
|
24
|
+
f"The values are case insensitive. The default format is `{DEFAULT_EXPORT_DATA_FORMAT.upper()}`. "
|
|
25
|
+
"When 'txt' is used, the response will be sent in *original*, un-formatted (almost), "
|
|
26
|
+
"without syntax highlighting. This can be utilized if one wishes to pipe the output "
|
|
27
|
+
" to some external formatting program like `less`. "
|
|
28
|
+
"If an unsupported format value is provided then the output "
|
|
29
|
+
f"format falls back to `{DEFAULT_EXPORT_DATA_FORMAT.upper()}`.",
|
|
3
30
|
}
|