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.
Files changed (36) 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 +2 -1
  6. elapi/cli/elapi.py +40 -24
  7. elapi/path.py +68 -14
  8. elapi/plugins/bill_teams/_doc.py +27 -0
  9. elapi/plugins/bill_teams/bill_teams.py +189 -100
  10. elapi/plugins/bill_teams/cli.py +261 -32
  11. elapi/plugins/bill_teams/format.py +30 -0
  12. elapi/plugins/bill_teams/invoice.py +8 -19
  13. elapi/plugins/bill_teams/validator.py +221 -0
  14. elapi/plugins/{utils → commons}/__init__.py +1 -0
  15. elapi/{cli/helpers.py → plugins/commons/cli_helpers.py} +7 -7
  16. elapi/plugins/commons/get_information.py +117 -0
  17. elapi/plugins/experiments/_doc.py +29 -0
  18. elapi/plugins/experiments/cli.py +5 -7
  19. elapi/plugins/experiments/experiments.py +3 -3
  20. elapi/plugins/show_config.py +4 -4
  21. elapi/styles/__init__.py +2 -2
  22. elapi/styles/format.py +47 -4
  23. elapi/styles/highlight.py +10 -2
  24. elapi/validators/__init__.py +2 -1
  25. elapi/validators/base.py +18 -6
  26. elapi/validators/identity.py +46 -3
  27. elapi/validators/path.py +8 -14
  28. elapi/validators/permission.py +84 -32
  29. {elapi-1.0.7.dev1.dist-info → elapi-1.0.7.dev2.dist-info}/METADATA +81 -25
  30. elapi-1.0.7.dev2.dist-info/RECORD +58 -0
  31. elapi-1.0.7.dev1.dist-info/RECORD +0 -54
  32. /elapi/plugins/{utils → commons}/export.py +0 -0
  33. /elapi/plugins/{utils → commons}/get_location_from_headers.py +0 -0
  34. {elapi-1.0.7.dev1.dist-info → elapi-1.0.7.dev2.dist-info}/LICENSE +0 -0
  35. {elapi-1.0.7.dev1.dist-info → elapi-1.0.7.dev2.dist-info}/WHEEL +0 -0
  36. {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-dev1
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"^\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
@@ -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 falls back to 'txt'.",
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
- 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.")
@@ -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 .helpers import CLIExport, CLIFormat
219
+ from ..plugins.commons.cli_helpers import CLIExport, CLIFormat
204
220
  from ..validators import Validate, HostIdentityValidator
205
- from ..plugins.utils import Export
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 --id <user id> -d '{"firstname": "John", "lastname": "Doe", "email": "test_test@itnerd.de"}'`
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.utils import get_location_from_headers
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
- ] = True,
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
- raise ValueError(f"{file} doesn't exist or isn't a valid file!") from e
157
- except PermissionError as e:
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, mode="r", encoding: Union[str, None] = None
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
@@ -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
  }