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.
- elapi/VERSION +1 -1
- elapi/_exceptions.py +1 -0
- elapi/api.py +2 -2
- elapi/cli/__init__.py +1 -1
- elapi/cli/doc.py +6 -1
- elapi/cli/elapi.py +47 -25
- elapi/configuration/config.py +19 -10
- elapi/configuration/log_file.py +1 -1
- elapi/path.py +83 -25
- elapi/plugins/bill_teams/_doc.py +41 -0
- elapi/plugins/bill_teams/bill_teams.py +189 -100
- elapi/plugins/bill_teams/cli.py +278 -35
- 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/commons/__init__.py +3 -0
- elapi/{cli/helpers.py → plugins/commons/cli_helpers.py} +19 -9
- elapi/plugins/{utils → commons}/export.py +37 -3
- elapi/plugins/commons/get_information.py +117 -0
- elapi/plugins/experiments/_doc.py +41 -0
- elapi/plugins/experiments/cli.py +24 -12
- 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 +3 -2
- elapi/validators/base.py +23 -13
- elapi/validators/identity.py +46 -3
- elapi/validators/path.py +50 -16
- elapi/validators/permission.py +84 -32
- {elapi-1.0.7.dev1.dist-info → elapi-1.0.7.dev3.dist-info}/METADATA +83 -27
- elapi-1.0.7.dev3.dist-info/RECORD +58 -0
- elapi/plugins/utils/__init__.py +0 -2
- elapi-1.0.7.dev1.dist-info/RECORD +0 -54
- /elapi/plugins/{utils → commons}/get_location_from_headers.py +0 -0
- {elapi-1.0.7.dev1.dist-info → elapi-1.0.7.dev3.dist-info}/LICENSE +0 -0
- {elapi-1.0.7.dev1.dist-info → elapi-1.0.7.dev3.dist-info}/WHEEL +0 -0
- {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
|
|
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"
|
|
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
|
@@ -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
|
|
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
|
-
|
|
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.")
|
|
@@ -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 .
|
|
223
|
+
from ..plugins.commons.cli_helpers import CLIExport, CLIFormat
|
|
204
224
|
from ..validators import Validate, HostIdentityValidator
|
|
205
|
-
from ..plugins.
|
|
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(
|
|
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
|
|
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.
|
|
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
|
-
] =
|
|
695
|
+
] = False,
|
|
674
696
|
) -> None:
|
|
675
697
|
"""
|
|
676
698
|
Get information about detected configuration values.
|
elapi/configuration/config.py
CHANGED
|
@@ -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,
|
|
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
|
|
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 '
|
|
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([
|
|
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()
|
elapi/configuration/log_file.py
CHANGED
|
@@ -23,7 +23,7 @@ validate_path = Validate(
|
|
|
23
23
|
PathValidator(
|
|
24
24
|
[
|
|
25
25
|
LOG_DIR_ROOT / LOG_FILE_NAME,
|
|
26
|
-
(
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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,
|
|
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
|
elapi/plugins/bill_teams/_doc.py
CHANGED
|
@@ -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
|
}
|