Skip to content

Commit 909a742

Browse files
authored
Merge pull request #144 from uhd-urz/dev
2 parents a26d2c2 + e5100a9 commit 909a742

27 files changed

+2083
-461
lines changed

.gitignore

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,3 +274,17 @@ Icon
274274
Network Trash Folder
275275
Temporary Items
276276
.apdisk
277+
278+
# VSCode related metadata files/directories
279+
.vscode/
280+
!.vscode/settings.json
281+
!.vscode/tasks.json
282+
!.vscode/launch.json
283+
!.vscode/extensions.json
284+
!.vscode/*.code-snippets
285+
286+
# Local History for Visual Studio Code
287+
.history/
288+
289+
# Built Visual Studio Code Extensions
290+
*.vsix

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [2.3.0] - 2024-12-21
9+
10+
This release mainly revamps and completes the `bill-teams` plugin that is used for billing eLabFTW usage at the
11+
[University of Heidelberg](https://www.uni-heidelberg.de/en). It also addresses one important issue with third-party
12+
plugins about version Python version mismatch.
13+
14+
### Added
15+
16+
- Revamped `bill-teams` plugins with `generate-table` and `registry` commands
17+
- Add `TXT` format support for `--format` wherever `--format` is supported
18+
19+
### Fixed
20+
21+
- Fix response format being incorrectly fallback-ing to `TXT` (GH #143)
22+
- Fix plugins using packages that use binary builds failing (GH #145)
23+
24+
### Changes
25+
26+
- Improved how internal logic for `--export` acting as a both boolean and a string (e.g., a file path)
27+
- A third-party plugin that uses a virtual environment must use the same Python version as elAPI's own (GH #146)
28+
829
## [2.2.0] - 2024-09-04
930

1031
This release brings some general bugfixes, improvements, and new library APIs.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ If both `host` and `api_token` are detected, you are good to go!
156156
### Overriding configuration
157157

158158
elAPI now supports `--override/--OC` as a global option that can be used to override the configuration parameters
159-
as detected by `elapi show-cofig`. All plugins will also automatically listen to the overridden configuration. This can
159+
as detected by `elapi show-config`. All plugins will also automatically listen to the overridden configuration. This can
160160
be useful to set certain configurations temporarily. E.g., `elapi --OC '{"timeout": 300"}' get info` will override
161161
the `timeout` from the configuration files.
162162

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "elapi"
3-
version = "2.2.0"
3+
version = "2.3.0"
44
description = "elAPI is a powerful, extensible API client for eLabFTW."
55
authors = [
66
"Alexander Haller, Mahadi Xion <[email protected]>",

src/elapi/VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
2.2.0
1+
2.3.0

src/elapi/cli/elapi.py

Lines changed: 94 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@
1212
$ elapi get users --id <id>
1313
"""
1414

15+
import platform
1516
import sys
1617
from functools import partial
18+
from json import JSONDecodeError
1719
from typing import Optional
1820

1921
import typer
@@ -27,7 +29,7 @@
2729
)
2830
from .doc import __PARAMETERS__doc__ as docs
2931
from .. import APP_NAME
30-
from ..configuration import FALLBACK_EXPORT_DIR
32+
from ..configuration import FALLBACK_EXPORT_DIR, get_active_export_dir
3133
from ..loggers import Logger, FileLogger
3234
from ..plugins.commons.cli_helpers import Typer
3335
from ..styles import get_custom_help_text
@@ -37,11 +39,13 @@
3739
rich_format_help_with_callback,
3840
__PACKAGE_IDENTIFIER__ as styles_package_identifier,
3941
)
42+
from ..utils import get_external_python_version, PythonVersionCheckFailed
43+
from ..utils.typer_patches import patch_typer_flag_value
4044

4145
logger = Logger()
4246
file_logger = FileLogger()
4347
pretty.install()
44-
48+
patch_typer_flag_value()
4549

4650
app = Typer()
4751
SENSITIVE_PLUGIN_NAMES: tuple[str, str, str] = (
@@ -262,7 +266,13 @@ def cli_cleanup_for_third_party_plugins(*args, override_config=None):
262266

263267

264268
def disable_plugin(
265-
main_app: Typer, /, *, plugin_name: str, err_msg: str, panel_name: str
269+
main_app: Typer,
270+
/,
271+
*,
272+
plugin_name: str,
273+
err_msg: str,
274+
panel_name: str,
275+
short_reason: Optional[str] = None,
266276
):
267277
import logging
268278
from ..utils import add_message
@@ -272,11 +282,15 @@ def disable_plugin(
272282
if plugin_name == registered_app.typer_instance.info.name:
273283
main_app.registered_groups.pop(i)
274284
break
285+
help_message = (
286+
f"🚫️ Disabled{' due to ' + short_reason if short_reason is not None else ''}. "
287+
f"See `--help` or log file to know more."
288+
)
275289

276290
@main_app.command(
277291
name=plugin_name,
278292
rich_help_panel=panel_name,
279-
help="🚫️ Disabled due to name conflict. See `--help` or log file to know more.",
293+
help=help_message,
280294
)
281295
def name_conflict_error():
282296
from ..core_validators import Exit
@@ -512,17 +526,16 @@ def get(
512526
),
513527
] = False,
514528
export: Annotated[
515-
Optional[bool],
529+
Optional[str],
516530
typer.Option(
517531
"--export",
518532
"-e",
519533
help=docs["export"] + docs["export_details"],
520-
is_flag=True,
521-
is_eager=True,
534+
is_flag=False,
535+
flag_value="",
522536
show_default=False,
523537
),
524-
] = False,
525-
_export_dest: Annotated[Optional[str], typer.Argument(hidden=True)] = None,
538+
] = None,
526539
export_overwrite: Annotated[
527540
bool,
528541
typer.Option("--overwrite", help=docs["export_overwrite"], show_default=False),
@@ -564,8 +577,8 @@ def get(
564577
validate_identity = Validate(HostIdentityValidator())
565578
validate_identity()
566579

567-
if export is False:
568-
_export_dest = None
580+
if export == "":
581+
export = get_active_export_dir()
569582
try:
570583
query: dict = get_structured_data(query, option_name="--query")
571584
except ValueError:
@@ -575,17 +588,16 @@ def get(
575588
except ValueError:
576589
raise Exit(1)
577590
data_format, export_dest, export_file_ext = CLIExport(
578-
data_format, _export_dest, export_overwrite
591+
data_format, export, export_overwrite
579592
)
580593
if not query:
581594
format = CLIFormat(data_format, styles_package_identifier, export_file_ext)
582595
else:
583596
logger.info(
584597
"When --query is not empty, formatting with '--format/-F' and highlighting are disabled."
585598
)
586-
format = CLIFormat(
587-
"txt", styles_package_identifier, None
588-
) # Use "txt" formatting to show binary
599+
highlight_syntax = False
600+
format = CLIFormat("txt", styles_package_identifier, None)
589601

590602
try:
591603
session = GETRequest()
@@ -627,13 +639,29 @@ def get(
627639
raise Exit(1) from e
628640
try:
629641
formatted_data = format(response_data := raw_response.json())
642+
# Because we prioritize the fact that most responses are sent as JSON
630643
except UnicodeDecodeError:
631644
logger.info(
632645
"Response data is in binary (or not UTF-8 encoded). "
633646
"--export/-e will not be able to infer the data format if export path is a directory."
634647
)
635648
formatted_data = format(response_data := raw_response.content)
636-
if export:
649+
except JSONDecodeError as e:
650+
if raw_response.status_code == 200:
651+
logger.info(
652+
f"Request was successful, but response data could not be parsed as JSON. "
653+
f"Response will be read as binary."
654+
)
655+
formatted_data = format(response_data := raw_response.content)
656+
else:
657+
logger.error(
658+
f"Request for '{endpoint_name}' data was received by the server but "
659+
f"request was not successful. Response status: {raw_response.status_code}. "
660+
f"Exception details: '{e!r}'. "
661+
f"Response: '{raw_response.text}'"
662+
)
663+
raise Exit(1) from e
664+
if export is not None:
637665
if isinstance(response_data, bytes):
638666
format.name = "binary"
639667
format.convention = "bin"
@@ -643,25 +671,25 @@ def get(
643671
_query_params = "_".join(map(lambda x: f"{x[0]}={x[1]}", query.items()))
644672
file_name_stub += f"_query_{_query_params}" if query else ""
645673
file_name_stub = re.sub(r"_{2,}", "_", file_name_stub).rstrip("_")
646-
export = Export(
674+
export_response = Export(
647675
export_dest,
648676
file_name_stub=file_name_stub,
649677
file_extension=format.convention,
650678
format_name=format.name,
651679
)
652680
if not raw_response.is_success:
653-
export(data=formatted_data, verbose=False)
681+
export_response(data=formatted_data, verbose=False)
654682
logger.warning(
655683
"Request was not successful. "
656-
f"Response for '{export.file_name_stub}' is exported to "
657-
f"{export.destination} anyway in {export.format_name} format."
684+
f"Response for '{export_response.file_name_stub}' is exported to "
685+
f"{export_response.destination} anyway in {export_response.format_name} format."
658686
)
659687
raise Exit(1)
660688
else:
661-
export(data=formatted_data, verbose=False)
689+
export_response(data=formatted_data, verbose=False)
662690
logger.info(
663-
f"Response for '{export.file_name_stub}' is successfully exported to "
664-
f"{export.destination} in {export.format_name} format."
691+
f"Response for '{export_response.file_name_stub}' is successfully exported to "
692+
f"{export_response.destination} in {export_response.format_name} format."
665693
)
666694
else:
667695
if highlight_syntax is True:
@@ -673,6 +701,8 @@ def get(
673701
raise Exit(1)
674702
stdout_console.print(highlight(formatted_data))
675703
else:
704+
if isinstance(response_data, bytes):
705+
formatted_data = response_data
676706
if not raw_response.is_success:
677707
typer.echo(formatted_data, file=sys.stderr)
678708
raise Exit(1)
@@ -755,7 +785,6 @@ def post(
755785
from ssl import SSLError
756786
from .. import APP_NAME
757787
from ..api import GlobalSharedSession, POSTRequest, ElabFTWURLError
758-
from json import JSONDecodeError
759788
from ..core_validators import Validate
760789
from ..api.validators import HostIdentityValidator
761790
from ..plugins.commons import get_location_from_headers
@@ -975,7 +1004,6 @@ def patch(
9751004
from httpx import ConnectError
9761005
from ssl import SSLError
9771006
from ..api import GlobalSharedSession, PATCHRequest, ElabFTWURLError
978-
from json import JSONDecodeError
9791007
from ..core_validators import Validate
9801008
from ..api.validators import HostIdentityValidator
9811009
from ..styles import Format, Highlight, NoteText, print_typer_error
@@ -1138,7 +1166,6 @@ def delete(
11381166
from httpx import ConnectError
11391167
from ssl import SSLError
11401168
from ..api import GlobalSharedSession, DELETERequest, ElabFTWURLError
1141-
from json import JSONDecodeError
11421169
from ..core_validators import Validate
11431170
from ..api.validators import HostIdentityValidator
11441171
from ..styles import Format, Highlight, NoteText, print_typer_error
@@ -1303,6 +1330,7 @@ def cleanup() -> None:
13031330
plugin_name=app_name,
13041331
err_msg=error_message,
13051332
panel_name=THIRD_PARTY_PLUGIN_PANEL_NAME,
1333+
short_reason="naming conflict",
13061334
)
13071335
elif app_name in INTERNAL_PLUGIN_NAME_REGISTRY:
13081336
error_message = (
@@ -1320,6 +1348,7 @@ def cleanup() -> None:
13201348
plugin_name=app_name,
13211349
err_msg=error_message,
13221350
panel_name=INTERNAL_PLUGIN_PANEL_NAME,
1351+
short_reason="naming conflict",
13231352
)
13241353
elif app_name in RESERVED_PLUGIN_NAMES:
13251354
error_message = (
@@ -1337,8 +1366,47 @@ def cleanup() -> None:
13371366
plugin_name=app_name,
13381367
err_msg=error_message,
13391368
panel_name=THIRD_PARTY_PLUGIN_PANEL_NAME,
1369+
short_reason="naming conflict",
13401370
)
13411371
else:
1372+
if _venv is not None:
1373+
try:
1374+
external_plugin_python_version = get_external_python_version(
1375+
venv_dir=_venv
1376+
)[:2]
1377+
except PythonVersionCheckFailed as e:
1378+
error_message = (
1379+
f"Plugin name '{original_name}' from {_path} uses virtual environment "
1380+
f"{_venv} whose own Python version could not "
1381+
f"be determined for the following reason: {e}. Plugin will be disabled."
1382+
)
1383+
disable_plugin(
1384+
app,
1385+
plugin_name=app_name,
1386+
err_msg=error_message,
1387+
panel_name=THIRD_PARTY_PLUGIN_PANEL_NAME,
1388+
short_reason="undetermined .venv Python version",
1389+
)
1390+
continue
1391+
else:
1392+
if external_plugin_python_version != (
1393+
own_python_version := platform.python_version_tuple()[:2]
1394+
):
1395+
error_message = (
1396+
f"Plugin name '{original_name}' from {_path} uses virtual environment "
1397+
f"{_venv} whose Python version (major and minor) "
1398+
f"'{'.'.join(external_plugin_python_version)}' "
1399+
f"does not match {APP_NAME}'s own Python version "
1400+
f"'{'.'.join(own_python_version)}'. Plugin will be disabled."
1401+
)
1402+
disable_plugin(
1403+
app,
1404+
plugin_name=app_name,
1405+
err_msg=error_message,
1406+
panel_name=THIRD_PARTY_PLUGIN_PANEL_NAME,
1407+
short_reason=".venv Python version conflict",
1408+
)
1409+
continue
13421410
EXTERNAL_LOCAL_PLUGIN_NAME_REGISTRY[app_name] = PluginInfo(
13431411
ext_app_obj, _path, _venv, _proj_dir
13441412
)

src/elapi/configuration/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
get_development_mode,
7171
get_active_plugin_configs,
7272
)
73+
from .validators import ConfigurationValidation
7374
from .validators import PluginConfigurationValidator as _PluginConfigurationValidator
7475

7576
validate_configuration(limited_to=[_PluginConfigurationValidator])

0 commit comments

Comments
 (0)