elapi 1.0.7.dev1__py3-none-any.whl → 1.0.7.dev3__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.
Files changed (39) hide show
  1. elapi/VERSION +1 -1
  2. elapi/_exceptions.py +1 -0
  3. elapi/api.py +2 -2
  4. elapi/cli/__init__.py +1 -1
  5. elapi/cli/doc.py +6 -1
  6. elapi/cli/elapi.py +47 -25
  7. elapi/configuration/config.py +19 -10
  8. elapi/configuration/log_file.py +1 -1
  9. elapi/path.py +83 -25
  10. elapi/plugins/bill_teams/_doc.py +41 -0
  11. elapi/plugins/bill_teams/bill_teams.py +189 -100
  12. elapi/plugins/bill_teams/cli.py +278 -35
  13. elapi/plugins/bill_teams/format.py +30 -0
  14. elapi/plugins/bill_teams/invoice.py +8 -19
  15. elapi/plugins/bill_teams/validator.py +221 -0
  16. elapi/plugins/commons/__init__.py +3 -0
  17. elapi/{cli/helpers.py → plugins/commons/cli_helpers.py} +19 -9
  18. elapi/plugins/{utils → commons}/export.py +37 -3
  19. elapi/plugins/commons/get_information.py +117 -0
  20. elapi/plugins/experiments/_doc.py +41 -0
  21. elapi/plugins/experiments/cli.py +24 -12
  22. elapi/plugins/experiments/experiments.py +3 -3
  23. elapi/plugins/show_config.py +4 -4
  24. elapi/styles/__init__.py +2 -2
  25. elapi/styles/format.py +47 -4
  26. elapi/styles/highlight.py +10 -2
  27. elapi/validators/__init__.py +3 -2
  28. elapi/validators/base.py +23 -13
  29. elapi/validators/identity.py +46 -3
  30. elapi/validators/path.py +50 -16
  31. elapi/validators/permission.py +84 -32
  32. {elapi-1.0.7.dev1.dist-info → elapi-1.0.7.dev3.dist-info}/METADATA +83 -27
  33. elapi-1.0.7.dev3.dist-info/RECORD +58 -0
  34. elapi/plugins/utils/__init__.py +0 -2
  35. elapi-1.0.7.dev1.dist-info/RECORD +0 -54
  36. /elapi/plugins/{utils → commons}/get_location_from_headers.py +0 -0
  37. {elapi-1.0.7.dev1.dist-info → elapi-1.0.7.dev3.dist-info}/LICENSE +0 -0
  38. {elapi-1.0.7.dev1.dist-info → elapi-1.0.7.dev3.dist-info}/WHEEL +0 -0
  39. {elapi-1.0.7.dev1.dist-info → elapi-1.0.7.dev3.dist-info}/entry_points.txt +0 -0
elapi/VERSION CHANGED
@@ -1 +1 @@
1
- 1.0.7-dev1
1
+ 1.0.7.dev3
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"^\w+$", value := str(value)):
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"^\w+$", value := str(value)):
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 .helpers import CLIExport, CLIFormat, OrderedCommands # noqa: F401
1
+ from ..plugins.commons.cli_helpers import CLIExport, CLIFormat, OrderedCommands # noqa: F401
elapi/cli/doc.py CHANGED
@@ -71,12 +71,17 @@ __PARAMETERS__doc__ = {
71
71
  f"If _--format/-F_ is absent, then {APP_NAME} can use the file extension as the data format. "
72
72
  f"If _--format/-F_ is also present, then file extension is ignored, "
73
73
  f"and --format value takes precedence.\n",
74
+ "export_overwrite": f"If given --export/-e path is a file, but it already **exists**, "
75
+ f"{APP_NAME} will not overwrite the file by default, and will instead use the "
76
+ f"fallback location. _--overwrite_ needs to be passed if {APP_NAME} should overwrite "
77
+ f"an existing file when exporting.",
74
78
  "data_format": f"Format style for the output. Supported values are: {supported_highlighting_formats}. "
75
79
  f"The values are case insensitive. The default format is `{DEFAULT_EXPORT_DATA_FORMAT.upper()}`. "
76
80
  "When 'txt' is used, the response will be sent in *original*, un-formatted (almost), "
77
81
  "without syntax highlighting. This can be utilized if one wishes to pipe the output "
78
82
  " to some external formatting program like `less`. "
79
- "If an unsupported format value is provided then the output format falls back to 'txt'.",
83
+ "If an unsupported format value is provided then the output format "
84
+ f"falls back to `{DEFAULT_EXPORT_DATA_FORMAT.upper()}`.",
80
85
  "no_keys": "Do not show the names of configuration keywords.",
81
86
  "init_host": 'The host URL of your eLabFTW instance. It will look like \"https://demo.elabftw.net/api/v2\".',
82
87
  "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
- with LOCAL_CONFIG_LOC.open(mode="r+") as f:
123
- if f.read():
124
- logger.error(
125
- f"A configuration file '{LOCAL_CONFIG_LOC}' already exists and it's not empty! "
126
- f"It's ambiguous what to do in this situation. {APP_NAME} will abort. "
127
- f"Configuration initialization has failed!"
128
- )
129
- raise typer.Exit(1)
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
- stdin_console.print(
138
- "Configuration file has been successfully created! "
139
- f"Run '{APP_NAME} show-config' to see the configuration path "
140
- "and more configuration details.",
141
- style="green",
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.")
@@ -179,6 +195,10 @@ def get(
179
195
  ),
180
196
  ] = False,
181
197
  _export_dest: Annotated[Optional[str], typer.Argument(hidden=True)] = None,
198
+ export_overwrite: Annotated[
199
+ bool,
200
+ typer.Option("--overwrite", help=docs["export_overwrite"], show_default=False),
201
+ ] = False,
182
202
  ) -> dict:
183
203
  """
184
204
  Make `GET` requests to eLabFTW endpoints as documented in
@@ -200,9 +220,9 @@ def get(
200
220
  import re
201
221
  from .. import APP_NAME
202
222
  from ..api import GETRequest, ElabFTWURLError
203
- from .helpers import CLIExport, CLIFormat
223
+ from ..plugins.commons.cli_helpers import CLIExport, CLIFormat
204
224
  from ..validators import Validate, HostIdentityValidator
205
- from ..plugins.utils import Export
225
+ from ..plugins.commons import Export
206
226
  from ..styles import Highlight
207
227
 
208
228
  validate_config = Validate(HostIdentityValidator())
@@ -219,7 +239,9 @@ def get(
219
239
  style="red",
220
240
  )
221
241
  raise typer.Exit(1)
222
- data_format, export_dest, export_file_ext = CLIExport(data_format, _export_dest)
242
+ data_format, export_dest, export_file_ext = CLIExport(
243
+ data_format, _export_dest, export_overwrite
244
+ )
223
245
  if not query:
224
246
  format = CLIFormat(data_format, export_file_ext)
225
247
  else:
@@ -324,7 +346,7 @@ def post(
324
346
 
325
347
  With `elapi` you can do the following:
326
348
  <br/>
327
- `$ elapi post users --id <user id> -d '{"firstname": "John", "lastname": "Doe", "email": "test_test@itnerd.de"}'`
349
+ `$ elapi post users -d '{"firstname": "John", "lastname": "Doe", "email": "test_test@itnerd.de"}'`
328
350
  will create a new user.
329
351
  """
330
352
  import ast
@@ -332,7 +354,7 @@ def post(
332
354
  from ..api import POSTRequest, ElabFTWURLError
333
355
  from json import JSONDecodeError
334
356
  from ..validators import Validate, HostIdentityValidator
335
- from ..plugins.utils import get_location_from_headers
357
+ from ..plugins.commons import get_location_from_headers
336
358
  from ..styles import Format, Highlight
337
359
  from ..path import ProperPath
338
360
 
@@ -670,7 +692,7 @@ def show_config(
670
692
  no_keys: Annotated[
671
693
  Optional[bool],
672
694
  typer.Option("--no-keys", help=docs["no_keys"], show_default=True),
673
- ] = True,
695
+ ] = False,
674
696
  ) -> None:
675
697
  """
676
698
  Get information about detected configuration values.
@@ -1,10 +1,12 @@
1
+ import errno
1
2
  import os
2
3
  from pathlib import Path
3
4
 
4
5
  from dynaconf import Dynaconf
5
6
 
6
7
  from ._config_history import ConfigHistory, InspectConfigHistory
7
- from .log_file import LOG_FILE_PATH, XDG_DATA_HOME
8
+ from .log_file import LOG_FILE_PATH, _XDG_DATA_HOME
9
+
8
10
  # noinspection PyUnresolvedReferences
9
11
  from .._names import (
10
12
  APP_NAME,
@@ -25,11 +27,13 @@ from .._names import (
25
27
  )
26
28
  from ..loggers import Logger
27
29
  from ..path import ProperPath
30
+ from ..styles import stdin_console, NoteText
28
31
  from ..validators import (
29
32
  Validate,
30
33
  ValidationError,
31
34
  CriticalValidationError,
32
35
  PathValidator,
36
+ PathValidationError,
33
37
  )
34
38
 
35
39
  logger = Logger()
@@ -116,19 +120,24 @@ TOKEN_BEARER: str = "Authorization"
116
120
  # Export location
117
121
  CONFIG_EXPORT_DIR = ProperPath(
118
122
  (_CONFIG_EXPORT_DIR_ORIGINAL := settings.get(KEY_EXPORT_DIR)) or os.devnull,
123
+ kind=(_CONFIG_EXPORT_DIR_ORIGINAL and "dir") or "file",
119
124
  err_logger=logger,
120
125
  ) # the default "os.devnull" saves ProperPath from TypeError, ValueError
121
126
  # for when settings.get(KEY_EXPORT_DIR) is None/"".
122
- if _CONFIG_EXPORT_DIR_ORIGINAL and CONFIG_EXPORT_DIR.kind != "dir":
123
- logger.warning(
124
- f"{KEY_EXPORT_DIR}: {_CONFIG_EXPORT_DIR_ORIGINAL} is not a directory!"
125
- )
126
- logger.debug("If you want to export to a file use '--export <path-to-file>'.")
127
- CONFIG_EXPORT_DIR = None
128
127
  try:
129
128
  EXPORT_DIR = Validate(PathValidator(CONFIG_EXPORT_DIR)).get()
130
- except ValidationError:
129
+ except PathValidationError as e:
131
130
  if _CONFIG_EXPORT_DIR_ORIGINAL:
131
+ if e.errno == errno.EEXIST:
132
+ logger.warning(
133
+ f"{KEY_EXPORT_DIR}: {_CONFIG_EXPORT_DIR_ORIGINAL} from configuration file is not a directory!"
134
+ )
135
+ stdin_console.print(
136
+ NoteText(
137
+ "If you want to export to a file use '--export <path-to-file>'.\n",
138
+ stem="Note",
139
+ )
140
+ )
132
141
  logger.warning(
133
142
  f"{KEY_EXPORT_DIR}: {_CONFIG_EXPORT_DIR_ORIGINAL} from configuration file couldn't be validated! "
134
143
  )
@@ -149,7 +158,7 @@ except ValidationError:
149
158
  logger.critical(
150
159
  f"{APP_NAME} couldn't validate {FALLBACK_EXPORT_DIR} to store exported data. "
151
160
  f"This is a fatal error. To quickly fix this error define an export directory "
152
- f"with 'export_dir' in configuration file. {APP_NAME} will not run!"
161
+ f"with '{KEY_EXPORT_DIR}' in configuration file. {APP_NAME} will not run!"
153
162
  )
154
163
  raise CriticalValidationError
155
164
  # Falls back to ~/Downloads if $XDG_DOWNLOAD_DIR isn't found
@@ -159,7 +168,7 @@ if LOG_FILE_PATH.parent != LOG_DIR_ROOT:
159
168
  APP_DATA_DIR = LOG_FILE_PATH.parent
160
169
  else:
161
170
  validate_app_dir = Validate(
162
- PathValidator([XDG_DATA_HOME / APP_NAME, FALLBACK_DIR / APP_NAME])
171
+ PathValidator([_XDG_DATA_HOME / APP_NAME, FALLBACK_DIR / APP_NAME])
163
172
  )
164
173
  try:
165
174
  APP_DATA_DIR = validate_app_dir.get()
@@ -23,7 +23,7 @@ validate_path = Validate(
23
23
  PathValidator(
24
24
  [
25
25
  LOG_DIR_ROOT / LOG_FILE_NAME,
26
- (XDG_DATA_HOME := ProperPath(os.getenv(ENV_XDG_DATA_HOME, os.devnull)))
26
+ (_XDG_DATA_HOME := ProperPath(os.getenv(ENV_XDG_DATA_HOME, os.devnull)))
27
27
  / APP_NAME
28
28
  / LOG_FILE_NAME,
29
29
  FALLBACK_DIR / LOG_FILE_NAME,
elapi/path.py CHANGED
@@ -5,23 +5,25 @@ import re
5
5
  from contextlib import contextmanager
6
6
  from pathlib import Path
7
7
  from shutil import rmtree
8
- from typing import Union, TextIO, Generator
8
+ from typing import Union, TextIO, Generator, Optional
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
- kind: Union[str, None] = "",
19
+ kind: Optional[str] = None, # Here, None => Undefined/unknown
19
20
  err_logger: logging.Logger = SimpleLogger(),
20
21
  ):
21
22
  self.name = name
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:
@@ -83,7 +103,7 @@ class ProperPath:
83
103
 
84
104
  @kind.setter
85
105
  def kind(self, value) -> None:
86
- if not value:
106
+ if value is None:
87
107
  self._kind = (
88
108
  "dir"
89
109
  if self.expanded.is_dir()
@@ -119,25 +139,43 @@ class ProperPath:
119
139
  else f"PATH={target}"
120
140
  )
121
141
 
122
- def create(self) -> Union[Path, None]:
123
- if not (path := self.expanded.resolve(strict=False)).exists():
124
- # except FileNotFoundError:
125
- message = (
126
- f"{self._error_helper_compare_path_source(self.name, path)} could not be found. "
127
- f"An attempt to create PATH={path} will be made."
128
- )
129
- self.err_logger.warning(message)
142
+ def create(self, verbose: bool = True) -> Union[Path, None]:
143
+ path = self.expanded.resolve(strict=False)
130
144
  try:
131
145
  if self.kind == "file":
132
146
  path_parent, path_file = path.parent, path.name
147
+ if not path_parent.exists() and verbose:
148
+ self.err_logger.info(
149
+ f"Directory {self._error_helper_compare_path_source(self.name, path_parent)} could not be "
150
+ f"found. An attempt to create directory {path_parent} will be made."
151
+ )
133
152
  path_parent.mkdir(parents=True, exist_ok=True)
134
153
  (path_parent / path_file).touch(exist_ok=True)
135
154
  elif self.kind == "dir":
155
+ if not path.exists() and verbose:
156
+ self.err_logger.info(
157
+ f"Directory {self._error_helper_compare_path_source(self.name, path)} could not be found. "
158
+ f"An attempt to create directory {path} will be made."
159
+ )
136
160
  path.mkdir(parents=True, exist_ok=True)
137
- except PermissionError as e:
161
+ except (exception := PermissionError) as e:
138
162
  message = f"Permission to create {self._error_helper_compare_path_source(self.name, path)} is denied."
139
163
  self.err_logger.error(message)
164
+ self.PathException = exception
140
165
  raise e
166
+ except (exception := NotADirectoryError) as e:
167
+ # Both "file" and "dir" cases are handled, but when path is under special files like
168
+ # /dev/null/<directory name>, os.mkdir() will throw NotADirectoryError.
169
+ message = f"Couldn't create {self._error_helper_compare_path_source(self.name, path)}."
170
+ self.err_logger.error(message)
171
+ self.PathException = exception
172
+ raise e
173
+ except (exception := OSError) as os_err:
174
+ # When an attempt to create a file or directory inside root (e.g., '/foo')
175
+ # is made, OS can throw OSError with error no. 30 instead of PermissionError.
176
+ self.err_logger.error(os_err)
177
+ self.PathException = exception
178
+ raise os_err
141
179
  else:
142
180
  return path
143
181
 
@@ -153,10 +191,12 @@ class ProperPath:
153
191
  file.unlink()
154
192
  except FileNotFoundError as e:
155
193
  # unlink() throws FileNotFoundError when a directory is passed as it expects files only
156
- raise ValueError(f"{file} doesn't exist or isn't a valid file!") from e
157
- except PermissionError as e:
194
+ self.PathException = exception = ValueError
195
+ raise exception(f"{file} doesn't exist or isn't a valid file!") from e
196
+ except (exception := PermissionError) as e:
158
197
  message = f"Permission to remove {self._error_helper_compare_path_source(self.name, file)} is denied."
159
198
  self.err_logger.warning(message)
199
+ self.PathException = exception
160
200
  raise e
161
201
  if verbose:
162
202
  self.err_logger.info(f"Deleted: {file}")
@@ -187,42 +227,53 @@ class ProperPath:
187
227
 
188
228
  @contextmanager
189
229
  def open(
190
- self, mode="r", encoding: Union[str, None] = None
230
+ self,
231
+ mode="r",
232
+ encoding: Union[str, None] = None,
233
+ **kwargs,
191
234
  ) -> Generator[TextIO, None, None]:
192
235
  path = self.expanded.resolve()
193
236
  file: Union[TextIO, None] = None
194
237
  try:
195
238
  # this try block doesn't yield anything yet. Here, we want to catch possible errors that occur
196
239
  # before the file is opened. E.g., FileNotFoundError
197
- file: TextIO = path.open(mode=mode, encoding=encoding)
198
- except FileNotFoundError as e:
240
+ file: TextIO = path.open(mode=mode, encoding=encoding, **kwargs)
241
+ except (exception := FileNotFoundError) as e:
199
242
  message = f"File in {path} couldn't be found while trying to open it with mode '{mode}'!"
200
243
  self.err_logger.warning(message)
244
+ self.PathException = exception
201
245
  raise e
202
246
 
203
- except PermissionError as e:
247
+ except (exception := PermissionError) as e:
204
248
  message = (
205
249
  f"Permission denied while trying to open file with mode '{mode}' for "
206
250
  f"{self._error_helper_compare_path_source(self.name, path)}."
207
251
  )
208
252
  self.err_logger.error(message)
253
+ self.PathException = exception
209
254
 
210
255
  try:
211
256
  yield # Without yield (yield None) Python throws RuntimeError: generator didn't yield.
212
257
  # I.e., contextmanager always expects a yield?
213
- except AttributeError as attribute_err:
258
+ except (exception := AttributeError) as attribute_err:
214
259
  # However, yielding None leads to attribute calls to None
215
260
  # (e.g., yield None -> file = None -> file.read() -> None.read()!! So we also catch that error.
216
261
  attribute_in_error = str(attribute_err).split()[-1]
217
262
  message = (
218
- f"An attempt to access attribute {attribute_in_error} "
219
- f"of the file object was made, "
263
+ f"An attempt to access attribute {attribute_in_error} was made,"
220
264
  f"but there was a problem opening the file {path}."
221
265
  )
222
266
  self.err_logger.warning(message)
267
+ self.PathException = exception
223
268
  raise attribute_err
224
269
  else:
225
270
  raise e
271
+ except (exception := OSError) as os_err:
272
+ # When an attempt to create a file or directory inside root (e.g., '/foo')
273
+ # is made, OS can throw OSError with error no. 30 instead of PermissionError.
274
+ self.err_logger.error(os_err)
275
+ self.PathException = exception
276
+ raise os_err
226
277
 
227
278
  else:
228
279
  try:
@@ -238,14 +289,15 @@ class ProperPath:
238
289
  )
239
290
  self.err_logger.warning(message)
240
291
  raise e
241
- except MemoryError as e:
292
+ except (exception := MemoryError) as e:
242
293
  message = (
243
294
  f"Out of memory while trying to use mode '{mode}' with "
244
295
  f"{self._error_helper_compare_path_source(self.name, path)}."
245
296
  )
246
297
  self.err_logger.critical(message)
298
+ self.PathException = exception
247
299
  raise e
248
- except IOError as io_err:
300
+ except (exception := IOError) as io_err:
249
301
  # We catch "No disk space left" error which will likely be triggered during a write attempt on the file
250
302
  if io_err.errno == errno.ENOSPC:
251
303
  message = (
@@ -253,7 +305,12 @@ class ProperPath:
253
305
  f"{self._error_helper_compare_path_source(self.name, path)}."
254
306
  )
255
307
  self.err_logger.critical(message)
308
+ self.PathException = exception
256
309
  raise io_err
310
+ except (exception := OSError) as os_err:
311
+ self.err_logger.error(os_err)
312
+ self.PathException = exception
313
+ raise os_err
257
314
  finally:
258
315
  if file:
259
316
  try:
@@ -264,7 +321,7 @@ class ProperPath:
264
321
  # f = open("/dev/full", mode="w");f.write("hello" * 10_000); <- Opening f again.
265
322
  # The above will trigger ENOSPC error, and will be captured by previous ENOSPC IOError exception.
266
323
  # Because of *, we again need to catch the error during close().
267
- except IOError as io_err:
324
+ except (exception := IOError) as io_err:
268
325
  if io_err.errno == errno.ENOSPC:
269
326
  message = (
270
327
  f"An 'ENOSPC' error (not enough disk space left) is received while trying to"
@@ -272,4 +329,5 @@ class ProperPath:
272
329
  f"{self._error_helper_compare_path_source(self.name, path)}."
273
330
  )
274
331
  self.err_logger.critical(message)
332
+ self.PathException = exception
275
333
  raise io_err
@@ -1,3 +1,44 @@
1
+ from ... import APP_NAME
2
+ from ...configuration import DEFAULT_EXPORT_DATA_FORMAT
3
+
4
+ _UNIQUE_SUPPORTED_FORMATS = ["json", "yaml", "txt"]
5
+ supported_format_values = ", ".join(
6
+ f"**{_.upper()}**" for _ in _UNIQUE_SUPPORTED_FORMATS
7
+ )
8
+
1
9
  __PARAMETERS__doc__ = {
2
10
  "invoice_export": "Export output to a location. Invoices are **always exported** by default.\n",
11
+ "root_directory": "The root of the directory where store-info will create the billing directory structure, "
12
+ "and save teams and owners information in `JSON`. store-info also sorts the JSON keys "
13
+ "before saving.",
14
+ "owners_data_path": "Source path for owners information `CSV` file. Internally referred to as the "
15
+ "`'billing metadata'. --meta-source` is not required when `--teams-info-only` is passed, "
16
+ "otherwise it is required.",
17
+ "target_date": "ISO 8604 'YYYY-MM' for which 'YYYY/MM' directory to store.",
18
+ "teams_info_only": "If passed, then only `teams-info` will be stored."
19
+ "Both _--teams-info-only_ and _--owners-info-only_ cannot be passed.",
20
+ "owners_info_only": "If passed, then only `owners-info` will be stored. "
21
+ "Both _--teams-info-only_ and _--owners-info-only_ cannot be passed.",
22
+ "data_format": f"Format style for the output. Supported values are: {supported_format_values}. "
23
+ f"CSV is **not** a supported format for this command as the "
24
+ f"output data structure is too complex for naive CSV. "
25
+ f"The values are case insensitive. The default format is `{DEFAULT_EXPORT_DATA_FORMAT.upper()}`. "
26
+ "When 'txt' is used, the response will be sent in *original*, un-formatted (almost), "
27
+ "without syntax highlighting. This can be utilized if one wishes to pipe the output "
28
+ " to some external formatting program like `less`. "
29
+ "If an unsupported format value is provided then the output "
30
+ f"format falls back to `{DEFAULT_EXPORT_DATA_FORMAT.upper()}`.",
31
+ "export_details": f"- If _'--export'_ is passed without any following value, then it acts as a flag, and "
32
+ f"`export_dir` value from configuration file is used. "
33
+ f"It should be clear that `export_dir` in a configuration file only accepts a directory path.\n"
34
+ f"- If a directory path is provided as a value, "
35
+ f"i.e., _'--export \<path/to/directory\>'_, then that path is used instead. "
36
+ f"When the path is a directory, "
37
+ f"the file name is auto-generated using the following scheme: *'DATE_HHMMSS_\<FUNCTION\>.EXT'*.\n"
38
+ f"- If a file path is passed, i.e., _'--export <path/to/file.json>'_, "
39
+ f"then data is simply exported to that file. This allows custom file name scheme. "
40
+ f"If _--format/-F_ is absent, then {APP_NAME} can use the file extension as the data format. "
41
+ f"If _--format/-F_ is also present, then file extension is ignored, "
42
+ f"and --format value takes precedence.\n"
43
+
3
44
  }