From d45ff55b8d957b5e8424e82f6fccad3ff525d119 Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Fri, 21 Apr 2023 17:09:01 +0200 Subject: [PATCH 01/39] Move all non-main service files into a sub-module --- dlite_entities_service/main.py | 8 ++++---- dlite_entities_service/service/__init__.py | 0 dlite_entities_service/{ => service}/backend.py | 2 +- dlite_entities_service/{ => service}/config.py | 0 dlite_entities_service/{ => service}/logger.py | 0 dlite_entities_service/{ => service}/models.py | 2 +- dlite_entities_service/{ => service}/uvicorn.py | 0 7 files changed, 6 insertions(+), 6 deletions(-) create mode 100644 dlite_entities_service/service/__init__.py rename dlite_entities_service/{ => service}/backend.py (89%) rename dlite_entities_service/{ => service}/config.py (100%) rename dlite_entities_service/{ => service}/logger.py (100%) rename dlite_entities_service/{ => service}/models.py (98%) rename dlite_entities_service/{ => service}/uvicorn.py (100%) diff --git a/dlite_entities_service/main.py b/dlite_entities_service/main.py index a8a704bd..2039fefb 100644 --- a/dlite_entities_service/main.py +++ b/dlite_entities_service/main.py @@ -5,10 +5,10 @@ from fastapi import FastAPI, HTTPException, Path, status from dlite_entities_service import __version__ -from dlite_entities_service.backend import ENTITIES_COLLECTION -from dlite_entities_service.config import CONFIG -from dlite_entities_service.logger import LOGGER -from dlite_entities_service.models import Entity +from dlite_entities_service.service.backend import ENTITIES_COLLECTION +from dlite_entities_service.service.config import CONFIG +from dlite_entities_service.service.logger import LOGGER +from dlite_entities_service.service.models import Entity if TYPE_CHECKING: # pragma: no cover from typing import Any diff --git a/dlite_entities_service/service/__init__.py b/dlite_entities_service/service/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dlite_entities_service/backend.py b/dlite_entities_service/service/backend.py similarity index 89% rename from dlite_entities_service/backend.py rename to dlite_entities_service/service/backend.py index 8e009a6d..fe051970 100644 --- a/dlite_entities_service/backend.py +++ b/dlite_entities_service/service/backend.py @@ -1,7 +1,7 @@ """Backend implementation.""" from pymongo import MongoClient -from dlite_entities_service.config import CONFIG +from dlite_entities_service.service.config import CONFIG client_kwargs = { "username": CONFIG.mongo_user, diff --git a/dlite_entities_service/config.py b/dlite_entities_service/service/config.py similarity index 100% rename from dlite_entities_service/config.py rename to dlite_entities_service/service/config.py diff --git a/dlite_entities_service/logger.py b/dlite_entities_service/service/logger.py similarity index 100% rename from dlite_entities_service/logger.py rename to dlite_entities_service/service/logger.py diff --git a/dlite_entities_service/models.py b/dlite_entities_service/service/models.py similarity index 98% rename from dlite_entities_service/models.py rename to dlite_entities_service/service/models.py index cc8347a9..3a5a25fb 100644 --- a/dlite_entities_service/models.py +++ b/dlite_entities_service/service/models.py @@ -4,7 +4,7 @@ from pydantic import BaseModel, Field, validator from pydantic.networks import AnyHttpUrl -from dlite_entities_service.config import CONFIG +from dlite_entities_service.service.config import CONFIG class DLiteProperty(BaseModel): diff --git a/dlite_entities_service/uvicorn.py b/dlite_entities_service/service/uvicorn.py similarity index 100% rename from dlite_entities_service/uvicorn.py rename to dlite_entities_service/service/uvicorn.py From 6bf9261b1a4299e723ff735a5fdb4c1fefa41967 Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Fri, 21 Apr 2023 17:11:20 +0200 Subject: [PATCH 02/39] Move back uvicorn file as it is referenced in prod --- dlite_entities_service/{service => }/uvicorn.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename dlite_entities_service/{service => }/uvicorn.py (100%) diff --git a/dlite_entities_service/service/uvicorn.py b/dlite_entities_service/uvicorn.py similarity index 100% rename from dlite_entities_service/service/uvicorn.py rename to dlite_entities_service/uvicorn.py From 47fda2b0c12dcd6229627148b0b34c4cd375922c Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Fri, 21 Apr 2023 17:15:40 +0200 Subject: [PATCH 03/39] Start of utilities CLI --- dlite_entities_service/utils_cli/__init__.py | 5 +++++ dlite_entities_service/utils_cli/main.py | 0 2 files changed, 5 insertions(+) create mode 100644 dlite_entities_service/utils_cli/__init__.py create mode 100644 dlite_entities_service/utils_cli/main.py diff --git a/dlite_entities_service/utils_cli/__init__.py b/dlite_entities_service/utils_cli/__init__.py new file mode 100644 index 00000000..de7dcfd6 --- /dev/null +++ b/dlite_entities_service/utils_cli/__init__.py @@ -0,0 +1,5 @@ +"""Utility CLI + +This module contains a CLI with utilities that may be useful when dealing with the +DLite entities service, it's data (backend), and possibly more. +""" diff --git a/dlite_entities_service/utils_cli/main.py b/dlite_entities_service/utils_cli/main.py new file mode 100644 index 00000000..e69de29b From c204b347e0007c770c605563118628efdecf2f73 Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Sat, 22 Apr 2023 21:47:02 +0200 Subject: [PATCH 04/39] Start of typer CLI module --- dlite_entities_service/utils_cli/main.py | 15 +++++++++++++++ pyproject.toml | 6 ++++++ 2 files changed, 21 insertions(+) diff --git a/dlite_entities_service/utils_cli/main.py b/dlite_entities_service/utils_cli/main.py index e69de29b..733bb7c4 100644 --- a/dlite_entities_service/utils_cli/main.py +++ b/dlite_entities_service/utils_cli/main.py @@ -0,0 +1,15 @@ +"""Typer CLI for doing DLite entities service stuff.""" +from pathlib import Path + +try: + import typer +except ImportError as exc: + raise ImportError( + "Please install the DLite entities service utility CLI with " + f"'pip install {Path(__file__).resolve().parent.parent.parent.resolve()}[cli]'" + ) from exc + +APP = typer.Typer( + name="entities-service", + help="DLite entities service utility CLI", +) diff --git a/pyproject.toml b/pyproject.toml index 57b29cd9..41f6e2db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,11 +45,17 @@ dependencies = [ # "pytest ~=7.2", # "pytest-cov ~=4.0", # ] +cli = [ + "typer[all] ~=0.7.0", +] dev = [ "pre-commit ~=2.21", "pylint ~=2.17", ] +[project.scripts] +entities-service = "dlite_entities_service.utils_cli.main:APP" + [project.urls] Home = "https://github.com/CasperWA/dlite-entities-service" Documentation = "https://CasperWA.github.io/dlite-entities-service" From 5a6bb3dee05e34a31b523216e785e8ac9f46bbaf Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Tue, 25 Apr 2023 11:44:10 +0200 Subject: [PATCH 05/39] Add typer to `dev` optional dependency --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 41f6e2db..24c1eef9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,7 @@ cli = [ dev = [ "pre-commit ~=2.21", "pylint ~=2.17", + "typer[all] ~=0.7.0", ] [project.scripts] From 773b488c4ad554043722a3b860be7272d35893be Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Wed, 26 Apr 2023 00:41:42 +0200 Subject: [PATCH 06/39] Flesh out the CLI Map out the initial commands and (almost) finish upload and config. --- .pre-commit-config.yaml | 1 + dlite_entities_service/utils_cli/config.py | 189 +++++++++++++++++++++ dlite_entities_service/utils_cli/main.py | 171 +++++++++++++++++++ pyproject.toml | 2 + 4 files changed, 363 insertions(+) create mode 100644 dlite_entities_service/utils_cli/config.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e7f73189..3590d46e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -56,6 +56,7 @@ repos: additional_dependencies: - pydantic - types-requests + - types-pyyaml - repo: local hooks: diff --git a/dlite_entities_service/utils_cli/config.py b/dlite_entities_service/utils_cli/config.py new file mode 100644 index 00000000..944cbc0a --- /dev/null +++ b/dlite_entities_service/utils_cli/config.py @@ -0,0 +1,189 @@ +"""config subcommand for dlite-entities-service CLI.""" +# pylint: disable=duplicate-code +from enum import Enum +from pathlib import Path +from typing import Generator, Optional + +try: + import typer +except ImportError as exc: + raise ImportError( + "Please install the DLite entities service utility CLI with " + f"'pip install {Path(__file__).resolve().parent.parent.parent.resolve()}[cli]'" + ) from exc + +from dotenv import dotenv_values, set_key, unset_key +from rich import print # pylint: disable=redefined-builtin +from rich.console import Console + +from dlite_entities_service.service.config import CONFIG + +ERROR_CONSOLE = Console(stderr=True) +CLI_DOTENV_FILE = Path(__file__).resolve().parent / ".env" +SERVICE_DOTENV_FILE = Path(__file__).resolve().parent.parent.parent / ".env" + +APP = typer.Typer( + name=__file__.rsplit("/", 1)[-1].replace(".py", ""), + no_args_is_help=True, + invoke_without_command=True, +) + +STATUS = {"use_service_dotenv": False} + + +class ConfigFields(str, Enum): + """Configuration options.""" + + BASE_URL = "base_url" + MONGO_URI = "mongo_uri" + MONGO_USER = "mongo_user" + MONGO_PASSWORD = "mongo_password" # nosec + + @classmethod + def autocomplete(cls, incomplete: str) -> Generator[tuple[str, str], None, None]: + """Return a list of valid configuration options.""" + for member in cls: + if member.value.startswith(incomplete): + if member.value not in CONFIG.__fields__: + raise typer.BadParameter( + f"Invalid configuration option: {member.value!r}" + ) + yield member.value, CONFIG.__fields__[ + member.value + ].field_info.description + + def is_sensitive(self) -> bool: + """Return True if this is a sensitive configuration option.""" + return self in [ConfigFields.MONGO_PASSWORD] + + +@APP.command(name="set") +def set_config( + key: ConfigFields = typer.Argument( + ..., + help=( + "Configuration option to set. These can also be set as an environment " + f"variable by prefixing with {CONFIG.Config.env_prefix!r}." + ), + show_choices=True, + autocompletion=ConfigFields.autocomplete, + case_sensitive=False, + show_default=False, + ), + value: Optional[str] = typer.Argument( + None, + help=( + "Value to set. For sensitive values, this will be prompted for if not " + "provided." + ), + show_default=False, + ), +) -> None: + """Set a configuration option.""" + if not value: + value = typer.prompt(f"Enter value for {key}", hide_input=key.is_sensitive()) + if STATUS["use_service_dotenv"]: + dotenv_file = SERVICE_DOTENV_FILE + else: + dotenv_file = CLI_DOTENV_FILE + if not dotenv_file.exists(): + dotenv_file.touch() + set_key(dotenv_file, key, value) + print( + f"Set {key} to sensitive value." + if key.is_sensitive() + else f"Set {key} to {value}." + ) + + +@APP.command() +def unset( + key: ConfigFields = typer.Argument( + ..., + help="Configuration option to unset.", + show_choices=True, + autocompletion=ConfigFields.autocomplete, + case_sensitive=False, + show_default=False, + ), +) -> None: + """Unset a configuration option.""" + if STATUS["use_service_dotenv"]: + dotenv_file = SERVICE_DOTENV_FILE + else: + dotenv_file = CLI_DOTENV_FILE + if dotenv_file.exists(): + unset_key(dotenv_file, key) + print(f"Unset {key}.") + + +@APP.command() +def show( + reveal_sensitive: bool = typer.Option( + False, + "--reveal-sensitive", + help="Reveal sensitive values. (DANGEROUS! Use with caution.)", + is_flag=True, + show_default=False, + ), +) -> None: + """Show the current configuration.""" + if STATUS["use_service_dotenv"]: + dotenv_file = SERVICE_DOTENV_FILE + else: + dotenv_file = CLI_DOTENV_FILE + if dotenv_file.exists(): + values = { + ConfigFields(key): value + for key, value in dotenv_values(dotenv_file).items() + if key in ConfigFields.__members__ + } + else: + ERROR_CONSOLE.print(f"No {dotenv_file} file found.") + raise typer.Exit(1) + + for key, value in values.items(): + if not reveal_sensitive and key.is_sensitive(): + value = "***" + print(f"[bold]{key}[/bold]: {value}") + + +@APP.callback() +def main( + use_service_dotenv: bool = typer.Option( + False, + "--use-service-dotenv/--use-cli-dotenv", + help=( + "Use the .env file also used for the DLite Entities Service or one only " + "for the CLI." + ), + is_flag=True, + ), + unset_all: bool = typer.Option( + False, + "--unset-all", + help="Unset (remove) all configuration options in dotenv file.", + show_default=False, + is_flag=True, + ), +) -> None: + """Set DLite entities service configuration options.""" + STATUS["use_service_dotenv"] = use_service_dotenv + + if unset_all: + typer.confirm( + "Are you sure you want to unset (remove) all configuration options in " + f"{'Service' if use_service_dotenv else 'CLI'}-specific .env file?", + abort=True, + ) + + if use_service_dotenv: + dotenv_file = SERVICE_DOTENV_FILE + else: + dotenv_file = CLI_DOTENV_FILE + + if dotenv_file.exists(): + dotenv_file.unlink() + print(f"Removed {dotenv_file}.") + else: + print(f"No {dotenv_file} file found.") diff --git a/dlite_entities_service/utils_cli/main.py b/dlite_entities_service/utils_cli/main.py index 733bb7c4..229ece37 100644 --- a/dlite_entities_service/utils_cli/main.py +++ b/dlite_entities_service/utils_cli/main.py @@ -1,5 +1,10 @@ """Typer CLI for doing DLite entities service stuff.""" +# pylint: disable=duplicate-code +import json +import os +from enum import Enum from pathlib import Path +from typing import TYPE_CHECKING, Optional try: import typer @@ -9,7 +14,173 @@ f"'pip install {Path(__file__).resolve().parent.parent.parent.resolve()}[cli]'" ) from exc +import dlite +import yaml +from rich import print # pylint: disable=redefined-builtin +from rich.console import Console + +from dlite_entities_service import __version__ +from dlite_entities_service.service.backend import ENTITIES_COLLECTION +from dlite_entities_service.utils_cli.config import APP as config_APP + +if TYPE_CHECKING: # pragma: no cover + from typing import Any + + +ERROR_CONSOLE = Console(stderr=True) + + +class EntityFileFormats(str, Enum): + """Supported entity file formats.""" + + JSON = "json" + YAML = "yaml" + YML = "yml" + + APP = typer.Typer( name="entities-service", help="DLite entities service utility CLI", + no_args_is_help=True, + pretty_exceptions_show_locals=False, ) +APP.add_typer(config_APP) + + +def _print_version(value: bool) -> None: + """Print version and exit.""" + if value: + print(f"dlite-entities-service version: {__version__}") + raise typer.Exit() + + +@APP.callback() +def main( + _: Optional[bool] = typer.Option( + None, + "--version", + help="Show version and exit", + is_eager=True, + callback=_print_version, + ), +) -> None: + """DLite entities service utility CLI.""" + + +@APP.command(no_args_is_help=True) +def upload( + filepaths: Optional[list[Path]] = typer.Option( + None, + "--file", + "-f", + exists=True, + file_okay=True, + dir_okay=False, + readable=True, + resolve_path=True, + help="Path to DLite entity file.", + show_default=False, + ), + directories: Optional[list[Path]] = typer.Option( + None, + "--dir", + "-d", + exists=True, + file_okay=False, + dir_okay=True, + readable=True, + resolve_path=True, + help=( + "Path to directory with DLite entities. All files matching the given " + "format(s) in the directory will be uploaded. Subdirectories will be " + "ignored." + ), + show_default=False, + ), + file_formats: Optional[list[EntityFileFormats]] = typer.Option( + [EntityFileFormats.JSON], + "--format", + help="Format of DLite entity file.", + show_choices=True, + show_default=True, + case_sensitive=False, + ), +) -> None: + """Upload a DLite entity.""" + unique_filepaths = set(filepaths or []) + directories = list(set(directories or [])) + file_formats = list(set(file_formats or [])) + + if not filepaths and not directories: + ERROR_CONSOLE.print( + "[bold red]Error[/bold red]: Either a --file/-f or --dir/-d must be given." + ) + raise typer.Exit(1) + + for directory in directories: + for root, _, files in os.walk(directory): + unique_filepaths |= set( + Path(root) / file + for file in files + if file.lower().endswith(tuple(file_formats)) + ) + + successes = [] + for filepath in unique_filepaths: + if filepath.suffix[1:].lower() not in file_formats: + ERROR_CONSOLE.print( + "[bold yellow]Warning[/bold yellow]: File format " + f"{filepath.suffix[1:].lower()!r} not supported. Skipping file: " + f"{filepath}" + ) + continue + + entity: "dict[str, Any]" = ( + json.loads(filepath.read_bytes()) + if filepath.suffix[1:].lower() == "json" + else yaml.safe_load(filepath.read_bytes()) + ) + + try: + dlite.Instance.from_dict(entity, single=True, check_storages=False) + except dlite.DLiteError as exc: # pylint: disable=redefined-outer-name + ERROR_CONSOLE.print( + f"[bold red]Error[/bold red]: {filepath} cannot be loaded with DLite. " + f"DLite exception: {exc}" + ) + raise typer.Exit(1) from exc + + ENTITIES_COLLECTION.insert_one(entity) + successes.append(filepath) + + print(f"Successfully uploaded {len(successes)} entities: {successes}") + + +@APP.command() +def update(): + """Update an existing DLite entity.""" + print("Not implemented yet") + + +@APP.command() +def delete(): + """Delete an existing DLite entity.""" + print("Not implemented yet") + + +@APP.command() +def get(): + """Get an existing DLite entity.""" + print("Not implemented yet") + + +@APP.command() +def search(): + """Search for DLite entities.""" + print("Not implemented yet") + + +@APP.command() +def validate(): + """Validate a DLite entity.""" + print("Not implemented yet") diff --git a/pyproject.toml b/pyproject.toml index 24c1eef9..8dba164e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,11 +46,13 @@ dependencies = [ # "pytest-cov ~=4.0", # ] cli = [ + "DLite-Python ~=0.3.19", "typer[all] ~=0.7.0", ] dev = [ "pre-commit ~=2.21", "pylint ~=2.17", + "DLite-Python ~=0.3.19", "typer[all] ~=0.7.0", ] From c7a9f2b36c9a5c163fde77962c96babe370b9015 Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Wed, 26 Apr 2023 11:33:33 +0200 Subject: [PATCH 07/39] Add testing Add testing dependencies (pytest). Add a special pylint run for tests and update workflows accordingly. Start the first test file for the `entities-service upload` CLI command. Also, fix filesuffix error for a workflow. --- .github/workflows/ci_tests.yml | 3 ++- ...ependencies => ci_update_dependencies.yml} | 2 +- .pre-commit-config.yaml | 20 ++++++++-------- pyproject.toml | 23 ++++++++++--------- tests/utils_cli/test_upload.py | 0 5 files changed, 25 insertions(+), 23 deletions(-) rename .github/workflows/{ci_update_dependencies => ci_update_dependencies.yml} (94%) create mode 100644 tests/utils_cli/test_upload.py diff --git a/.github/workflows/ci_tests.yml b/.github/workflows/ci_tests.yml index b626c122..d2a01d8e 100644 --- a/.github/workflows/ci_tests.yml +++ b/.github/workflows/ci_tests.yml @@ -17,12 +17,13 @@ jobs: # pre-commit python_version_pre-commit: "3.10" - skip_pre-commit_hooks: pylint + skip_pre-commit_hooks: pylint,pylint-tests # pylint & safety python_version_pylint_safety: "3.10" pylint_runs: | --rcfile=pyproject.toml --extension-pkg-whitelist='pydantic' dlite_entities_service + --rcfile=pyproject.toml --disable=import-outside-toplevel,redefined-outer-name --recursive=yes tests # Build dist python_version_package: "3.10" diff --git a/.github/workflows/ci_update_dependencies b/.github/workflows/ci_update_dependencies.yml similarity index 94% rename from .github/workflows/ci_update_dependencies rename to .github/workflows/ci_update_dependencies.yml index 05c68547..c4eb6b85 100644 --- a/.github/workflows/ci_update_dependencies +++ b/.github/workflows/ci_update_dependencies.yml @@ -24,6 +24,6 @@ jobs: update_pre-commit: true python_version: "3.10" install_extras: "[dev]" - skip_pre-commit_hooks: "pylint" + skip_pre-commit_hooks: "pylint,pylint-tests" secrets: PAT: ${{ secrets.TEAM40_PAT }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3590d46e..7d50406f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -78,13 +78,13 @@ repos: # imported without issue. # For more information about pylint see its documentation at: # https://pylint.pycqa.org/en/latest/ - # - id: pylint-tests - # name: pylint - tests - # entry: pylint - # args: - # - "--rcfile=pyproject.toml" - # - "--disable=import-outside-toplevel,redefined-outer-name" - # language: python - # types: [python] - # require_serial: true - # files: ^tests/.*$ + - id: pylint-tests + name: pylint - tests + entry: pylint + args: + - "--rcfile=pyproject.toml" + - "--disable=import-outside-toplevel,redefined-outer-name" + language: python + types: [python] + require_serial: true + files: ^tests/.*$ diff --git a/pyproject.toml b/pyproject.toml index 8dba164e..194bf567 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,10 +41,10 @@ dependencies = [ # "mkdocs-material ~=9.1", # "mkdocstrings[python-legacy] ~=0.20.0", # ] -# testing = [ -# "pytest ~=7.2", -# "pytest-cov ~=4.0", -# ] +testing = [ + "pytest ~=7.3", + "pytest-cov ~=4.0", +] cli = [ "DLite-Python ~=0.3.19", "typer[all] ~=0.7.0", @@ -52,6 +52,8 @@ cli = [ dev = [ "pre-commit ~=2.21", "pylint ~=2.17", + "pytest ~=7.3", + "pytest-cov ~=4.0", "DLite-Python ~=0.3.19", "typer[all] ~=0.7.0", ] @@ -86,10 +88,9 @@ disable = [ max-args = 15 max-branches = 15 -# [tool.pytest.ini_options] -# minversion = "7.0" -# filterwarnings = [ -# "ignore:.*imp module.*:DeprecationWarning", -# # Remove when invoke updates to `inspect.signature()` or similar: -# "ignore:.*inspect.getargspec().*:DeprecationWarning", -# ] +[tool.pytest.ini_options] +minversion = "7.3" +addopts = "-rs --cov=dlite_entities_service --cov-report=term" +filterwarnings = [ + # "ignore:.*imp module.*:DeprecationWarning", +] diff --git a/tests/utils_cli/test_upload.py b/tests/utils_cli/test_upload.py new file mode 100644 index 00000000..e69de29b From e4f247e1edf960da51f103a81adcc3d11b20c140 Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Wed, 26 Apr 2023 13:34:15 +0200 Subject: [PATCH 08/39] Add tests for entities-service upload --- dlite_entities_service/utils_cli/config.py | 4 + dlite_entities_service/utils_cli/main.py | 18 +++- pyproject.toml | 6 +- tests/conftest.py | 25 +++++ tests/samples/invalid_entities/Person.json | 20 ++++ tests/samples/valid_entities/Cat.json | 20 ++++ tests/samples/valid_entities/Dog.json | 25 +++++ tests/samples/valid_entities/Person.json | 21 ++++ tests/utils_cli/test_upload.py | 119 +++++++++++++++++++++ 9 files changed, 253 insertions(+), 5 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/samples/invalid_entities/Person.json create mode 100644 tests/samples/valid_entities/Cat.json create mode 100644 tests/samples/valid_entities/Dog.json create mode 100644 tests/samples/valid_entities/Person.json diff --git a/dlite_entities_service/utils_cli/config.py b/dlite_entities_service/utils_cli/config.py index 944cbc0a..746f68f5 100644 --- a/dlite_entities_service/utils_cli/config.py +++ b/dlite_entities_service/utils_cli/config.py @@ -66,6 +66,8 @@ def set_config( f"variable by prefixing with {CONFIG.Config.env_prefix!r}." ), show_choices=True, + # Start using shell_complete once tiangolo/typer#334 is resolved. + # shell_complete=ConfigFields.autocomplete, autocompletion=ConfigFields.autocomplete, case_sensitive=False, show_default=False, @@ -102,6 +104,8 @@ def unset( ..., help="Configuration option to unset.", show_choices=True, + # Start using shell_complete once tiangolo/typer#334 is resolved. + # shell_complete=ConfigFields.autocomplete, autocompletion=ConfigFields.autocomplete, case_sensitive=False, show_default=False, diff --git a/dlite_entities_service/utils_cli/main.py b/dlite_entities_service/utils_cli/main.py index 229ece37..c5d1971d 100644 --- a/dlite_entities_service/utils_cli/main.py +++ b/dlite_entities_service/utils_cli/main.py @@ -113,7 +113,8 @@ def upload( if not filepaths and not directories: ERROR_CONSOLE.print( - "[bold red]Error[/bold red]: Either a --file/-f or --dir/-d must be given." + "[bold red]Error[/bold red]: Missing either option '--file' / '-f' or " + "'--dir' / '-d'." ) raise typer.Exit(1) @@ -130,7 +131,7 @@ def upload( if filepath.suffix[1:].lower() not in file_formats: ERROR_CONSOLE.print( "[bold yellow]Warning[/bold yellow]: File format " - f"{filepath.suffix[1:].lower()!r} not supported. Skipping file: " + f"{filepath.suffix[1:].lower()!r} is not supported. Skipping file: " f"{filepath}" ) continue @@ -143,7 +144,10 @@ def upload( try: dlite.Instance.from_dict(entity, single=True, check_storages=False) - except dlite.DLiteError as exc: # pylint: disable=redefined-outer-name + except ( # pylint: disable=redefined-outer-name + dlite.DLiteError, + KeyError, + ) as exc: ERROR_CONSOLE.print( f"[bold red]Error[/bold red]: {filepath} cannot be loaded with DLite. " f"DLite exception: {exc}" @@ -153,7 +157,13 @@ def upload( ENTITIES_COLLECTION.insert_one(entity) successes.append(filepath) - print(f"Successfully uploaded {len(successes)} entities: {successes}") + if successes: + print( + f"Successfully uploaded {len(successes)} entities: " + f"{[str(_) for _ in successes]}" + ) + else: + print("No entities were uploaded.") @APP.command() diff --git a/pyproject.toml b/pyproject.toml index 194bf567..255e5c03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ dependencies = [ # "mkdocstrings[python-legacy] ~=0.20.0", # ] testing = [ + "mongomock ~=4.1", "pytest ~=7.3", "pytest-cov ~=4.0", ] @@ -52,6 +53,7 @@ cli = [ dev = [ "pre-commit ~=2.21", "pylint ~=2.17", + "mongomock ~=4.1", "pytest ~=7.3", "pytest-cov ~=4.0", "DLite-Python ~=0.3.19", @@ -92,5 +94,7 @@ max-branches = 15 minversion = "7.3" addopts = "-rs --cov=dlite_entities_service --cov-report=term" filterwarnings = [ - # "ignore:.*imp module.*:DeprecationWarning", + # Remove warning filter once tiangolo/typer#334 is resolved. + "ignore:.*'autocompletion' is renamed to 'shell_complete'.*:DeprecationWarning", + "ignore:.*pkg_resources is deprecated as an API.*:DeprecationWarning", ] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..8d0b3bda --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,25 @@ +"""Configuration and fixtures for all pytest tests.""" +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from pathlib import Path + + from typer.testing import CliRunner + + +@pytest.fixture(scope="session") +def cli() -> "CliRunner": + """Fixture for CLI runner.""" + from typer.testing import CliRunner + + return CliRunner(mix_stderr=False) + + +@pytest.fixture(scope="session") +def samples() -> "Path": + """Fixture for samples directory.""" + from pathlib import Path + + return Path(__file__).resolve().parent / "samples" diff --git a/tests/samples/invalid_entities/Person.json b/tests/samples/invalid_entities/Person.json new file mode 100644 index 00000000..0ea09b7c --- /dev/null +++ b/tests/samples/invalid_entities/Person.json @@ -0,0 +1,20 @@ +{ + "uri": "http://onto-ns.com/meta/0.1/Person", + "meta": "http://onto-ns.com/meta/0.3/EntitySchema", + "description": "A person, living or dead", + "properties": { + "skills": { + "type": "string", + "shape": ["n_skills"], + "description": "Skills of the person." + }, + "name": { + "type": "string", + "description": "Name of the person." + }, + "age": { + "type": "int", + "description": "Age of the person." + } + } +} diff --git a/tests/samples/valid_entities/Cat.json b/tests/samples/valid_entities/Cat.json new file mode 100644 index 00000000..89ccb178 --- /dev/null +++ b/tests/samples/valid_entities/Cat.json @@ -0,0 +1,20 @@ +{ + "uri": "http://onto-ns.com/meta/0.1/Cat", + "meta": "http://onto-ns.com/meta/0.3/EntitySchema", + "description": "A cat.", + "dimensions": [], + "properties": { + "name": { + "type": "string", + "description": "Name of the cat." + }, + "age": { + "type": "int", + "description": "Age of the cat." + }, + "color": { + "type": "string", + "description": "Color of the cat." + } + } +} diff --git a/tests/samples/valid_entities/Dog.json b/tests/samples/valid_entities/Dog.json new file mode 100644 index 00000000..85420a79 --- /dev/null +++ b/tests/samples/valid_entities/Dog.json @@ -0,0 +1,25 @@ +{ + "uri": "http://onto-ns.com/meta/0.1/Dog", + "meta": "http://onto-ns.com/meta/0.3/EntitySchema", + "description": "A good dog.", + "dimensions": {"n_tricks": "Number of tricks."}, + "properties": { + "tricks": { + "type": "string", + "shape": ["n_tricks"], + "description": "Tricks the dog can do." + }, + "name": { + "type": "string", + "description": "Name of the dog." + }, + "age": { + "type": "int", + "description": "Age of the dog." + }, + "breed": { + "type": "string", + "description": "Breed of the dog." + } + } +} diff --git a/tests/samples/valid_entities/Person.json b/tests/samples/valid_entities/Person.json new file mode 100644 index 00000000..1e9990e1 --- /dev/null +++ b/tests/samples/valid_entities/Person.json @@ -0,0 +1,21 @@ +{ + "uri": "http://onto-ns.com/meta/0.1/Person", + "meta": "http://onto-ns.com/meta/0.3/EntitySchema", + "description": "A person, living or dead", + "dimensions": {"n_skills": "Number of skills."}, + "properties": { + "skills": { + "type": "string", + "shape": ["n_skills"], + "description": "Skills of the person." + }, + "name": { + "type": "string", + "description": "Name of the person." + }, + "age": { + "type": "int", + "description": "Age of the person." + } + } +} diff --git a/tests/utils_cli/test_upload.py b/tests/utils_cli/test_upload.py index e69de29b..0374eb53 100644 --- a/tests/utils_cli/test_upload.py +++ b/tests/utils_cli/test_upload.py @@ -0,0 +1,119 @@ +"""Tests for `entities-service upload` CLI command.""" +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from pathlib import Path + from typing import Any + + from typer.testing import CliRunner + + +def test_upload_no_args(cli: "CliRunner") -> None: + """Test `entities-service upload` CLI command.""" + from dlite_entities_service.utils_cli.main import APP, upload + + result = cli.invoke(APP, "upload") + assert result.exit_code == 0 + assert "Usage: entities-service upload [OPTIONS]" in result.stdout + assert upload.__doc__ in result.stdout + + assert result.stdout == cli.invoke(APP, "upload --help").stdout + + +def test_upload_filepath( + cli: "CliRunner", samples: "Path", monkeypatch: pytest.MonkeyPatch +) -> None: + """Test upload with a filepath.""" + import json + + from mongomock import MongoClient + + from dlite_entities_service.service.config import CONFIG + from dlite_entities_service.utils_cli import main + + mongo_client = MongoClient(CONFIG.mongo_uri) + mock_entities_collection = mongo_client["dlite"]["entities"] + + monkeypatch.setattr(main, "ENTITIES_COLLECTION", mock_entities_collection) + + result = cli.invoke( + main.APP, f"upload --file {samples / 'valid_entities' / 'Person.json'}" + ) + assert result.exit_code == 0 + + assert mock_entities_collection.count_documents({}) == 1 + stored_entity: "dict[str, Any]" = mock_entities_collection.find_one({}) + stored_entity.pop("_id") + assert stored_entity == json.loads( + (samples / "valid_entities" / "Person.json").read_bytes() + ) + + assert "Successfully uploaded 1 entities:" in result.stdout + + +def test_upload_filepath_invalid(cli: "CliRunner", samples: "Path") -> None: + """Test upload with an invalid filepath.""" + from dlite_entities_service.utils_cli.main import APP + + result = cli.invoke( + APP, f"upload --file {samples / 'invalid_entities' / 'Person.json'}" + ) + assert result.exit_code == 1 + assert "cannot be loaded with DLite." in result.stderr + assert not result.stdout + + +def test_upload_filepath_invalid_format(cli: "CliRunner", tmp_path: "Path") -> None: + """Test upload with an invalid file format.""" + from dlite_entities_service.utils_cli.main import APP + + (tmp_path / "Person.txt").touch() + + result = cli.invoke(APP, f"upload --file {tmp_path / 'Person.txt'}") + assert result.exit_code == 0 + assert "File format 'txt' is not supported." in result.stderr + assert "No entities were uploaded." in result.stdout + + +def test_upload_no_file_or_dir(cli: "CliRunner") -> None: + """Test error when no file or directory is provided.""" + from dlite_entities_service.utils_cli.main import APP + + result = cli.invoke(APP, "upload --format json") + assert result.exit_code == 1 + assert "Missing either option '--file' / '-f'" in result.stderr + assert not result.stdout + + +def test_upload_directory( + cli: "CliRunner", samples: "Path", monkeypatch: pytest.MonkeyPatch +) -> None: + """Test upload with a directory.""" + import json + + from mongomock import MongoClient + + from dlite_entities_service.service.config import CONFIG + from dlite_entities_service.utils_cli import main + + mongo_client = MongoClient(CONFIG.mongo_uri) + mock_entities_collection = mongo_client["dlite"]["entities"] + + monkeypatch.setattr(main, "ENTITIES_COLLECTION", mock_entities_collection) + + result = cli.invoke(main.APP, f"upload --dir {samples / 'valid_entities'}") + assert result.exit_code == 0 + + assert mock_entities_collection.count_documents({}) == 3 + stored_entities = list(mock_entities_collection.find({})) + for stored_entity in stored_entities: + stored_entity.pop("_id") + for sample_file in ("Person.json", "Dog.json", "Cat.json"): + assert ( + json.loads((samples / "valid_entities" / sample_file).read_bytes()) + in stored_entities + ) + + assert "Successfully uploaded 3 entities:" in result.stdout From 55259b4152c0f0575d2b5abd534c9d60ee0d8024 Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Wed, 26 Apr 2023 13:39:01 +0200 Subject: [PATCH 09/39] Add pytest CI job --- .github/workflows/ci_tests.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/.github/workflows/ci_tests.yml b/.github/workflows/ci_tests.yml index d2a01d8e..0d68a60e 100644 --- a/.github/workflows/ci_tests.yml +++ b/.github/workflows/ci_tests.yml @@ -108,3 +108,30 @@ jobs: exit 1 } + + pytest: + name: pytest + runs-on: ubuntu-latest + + steps: + - name: Checkout ${{ github.repository }} + uses: actions/checkout@v3 + + - name: Setup Python 3.10 + uses: actions/setup-python@v4 + with: + python-version: "3.10" + + - name: Install test dependencies + run: | + python -m pip install -U pip + pip install -U setuptools wheel flit + pip install -U -e .[testing] + + - name: Run tests + run: pytest -vvv --cov-report=xml + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + files: coverage.xml From 8afd6d89718153fb02b02d0d444d73747c922835 Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Wed, 26 Apr 2023 22:11:35 +0200 Subject: [PATCH 10/39] Fix root dir variable for logger.py --- dlite_entities_service/service/logger.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlite_entities_service/service/logger.py b/dlite_entities_service/service/logger.py index aa41a0db..fd9a365b 100644 --- a/dlite_entities_service/service/logger.py +++ b/dlite_entities_service/service/logger.py @@ -45,7 +45,7 @@ def disable_logging(): LOGGER.setLevel(logging.DEBUG) # Save a file with all messages (DEBUG level) -ROOT_DIR = Path(__file__).parent.parent.resolve() +ROOT_DIR = Path(__file__).parent.parent.parent.resolve() LOGS_DIR = ROOT_DIR.joinpath("logs/") LOGS_DIR.mkdir(exist_ok=True) From d0cbae724ec9ecd5944a982c8311d6c421adbe3d Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Wed, 26 Apr 2023 22:12:03 +0200 Subject: [PATCH 11/39] Add cli optional dependency to pytest CI job --- .github/workflows/ci_tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci_tests.yml b/.github/workflows/ci_tests.yml index 0d68a60e..cde402ee 100644 --- a/.github/workflows/ci_tests.yml +++ b/.github/workflows/ci_tests.yml @@ -126,7 +126,7 @@ jobs: run: | python -m pip install -U pip pip install -U setuptools wheel flit - pip install -U -e .[testing] + pip install -U -e .[testing,cli] - name: Run tests run: pytest -vvv --cov-report=xml From 491ce06f68ecb0096992e3e8707eff75994a1b91 Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Wed, 26 Apr 2023 22:42:22 +0200 Subject: [PATCH 12/39] Try to make pytest CI job run --- tests/utils_cli/test_upload.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/utils_cli/test_upload.py b/tests/utils_cli/test_upload.py index 0374eb53..31221436 100644 --- a/tests/utils_cli/test_upload.py +++ b/tests/utils_cli/test_upload.py @@ -16,7 +16,6 @@ def test_upload_no_args(cli: "CliRunner") -> None: result = cli.invoke(APP, "upload") assert result.exit_code == 0 - assert "Usage: entities-service upload [OPTIONS]" in result.stdout assert upload.__doc__ in result.stdout assert result.stdout == cli.invoke(APP, "upload --help").stdout From 856ac292cd470dc4d7c6114c788b3066043a7a79 Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Wed, 26 Apr 2023 23:15:54 +0200 Subject: [PATCH 13/39] Use .env files for utility CLI Prefer CLI-specific .env before a service-specific .env, and otherwise fallback to CONFIG defaults. --- dlite_entities_service/service/backend.py | 48 ++++++++++++++++------- dlite_entities_service/utils_cli/main.py | 37 ++++++++++++++++- tests/utils_cli/test_upload.py | 6 +++ 3 files changed, 74 insertions(+), 17 deletions(-) diff --git a/dlite_entities_service/service/backend.py b/dlite_entities_service/service/backend.py index fe051970..d6142f19 100644 --- a/dlite_entities_service/service/backend.py +++ b/dlite_entities_service/service/backend.py @@ -1,23 +1,41 @@ """Backend implementation.""" +from typing import TYPE_CHECKING + from pymongo import MongoClient +from pymongo.errors import WriteConcernError, WriteError from dlite_entities_service.service.config import CONFIG -client_kwargs = { - "username": CONFIG.mongo_user, - "password": CONFIG.mongo_password.get_secret_value() - if CONFIG.mongo_password is not None - else None, -} -for key, value in list(client_kwargs.items()): - if value is None: - client_kwargs.pop(key, None) +if TYPE_CHECKING: # pragma: no cover + from pymongo.collection import Collection + + +AnyWriteError = (WriteError, WriteConcernError) +"""Any write error exception.""" + + +def get_collection( + uri: str | None = None, username: str | None = None, password: str | None = None +) -> "Collection": + """Get the MongoDB collection for entities.""" + client_kwargs = { + "username": username or CONFIG.mongo_user, + "password": password + or ( + CONFIG.mongo_password.get_secret_value() + if CONFIG.mongo_password is not None + else None + ), + } + for key, value in list(client_kwargs.items()): + if value is None: + client_kwargs.pop(key, None) + mongo_client = MongoClient( + uri or CONFIG.mongo_uri, + **client_kwargs, + ) + return mongo_client.dlite.entities -MONGO_CLIENT = MongoClient( - CONFIG.mongo_uri, - **client_kwargs, -) -MONGO_DB = MONGO_CLIENT.dlite -ENTITIES_COLLECTION = MONGO_DB.entities +ENTITIES_COLLECTION = get_collection() diff --git a/dlite_entities_service/utils_cli/main.py b/dlite_entities_service/utils_cli/main.py index c5d1971d..a648a2bd 100644 --- a/dlite_entities_service/utils_cli/main.py +++ b/dlite_entities_service/utils_cli/main.py @@ -16,11 +16,16 @@ import dlite import yaml +from dotenv import find_dotenv, get_key from rich import print # pylint: disable=redefined-builtin from rich.console import Console from dlite_entities_service import __version__ -from dlite_entities_service.service.backend import ENTITIES_COLLECTION +from dlite_entities_service.service.backend import ( + ENTITIES_COLLECTION, + AnyWriteError, + get_collection, +) from dlite_entities_service.utils_cli.config import APP as config_APP if TYPE_CHECKING: # pragma: no cover @@ -126,6 +131,26 @@ def upload( if file.lower().endswith(tuple(file_formats)) ) + if not unique_filepaths: + ERROR_CONSOLE.print( + "[bold red]Error[/bold red]: No files found with the given options." + ) + raise typer.Exit(1) + + config_file = find_dotenv() + if config_file: + backend_options = { + "uri": get_key(config_file, "entity_service_mongo_uri"), + "username": get_key(config_file, "entity_service_mongo_user"), + "password": get_key(config_file, "entity_service_mongo_password"), + } + if all(_ is None for _ in backend_options.values()): + backend = ENTITIES_COLLECTION + else: + backend = get_collection(**backend_options) + else: + backend = ENTITIES_COLLECTION + successes = [] for filepath in unique_filepaths: if filepath.suffix[1:].lower() not in file_formats: @@ -154,7 +179,15 @@ def upload( ) raise typer.Exit(1) from exc - ENTITIES_COLLECTION.insert_one(entity) + try: + backend.insert_one(entity) + except AnyWriteError as exc: + ERROR_CONSOLE.print( + f"[bold red]Error[/bold red]: {filepath} cannot be uploaded. " + f"Backend exception: {exc}" + ) + raise typer.Exit(1) from exc + successes.append(filepath) if successes: diff --git a/tests/utils_cli/test_upload.py b/tests/utils_cli/test_upload.py index 31221436..126777b5 100644 --- a/tests/utils_cli/test_upload.py +++ b/tests/utils_cli/test_upload.py @@ -36,6 +36,9 @@ def test_upload_filepath( mock_entities_collection = mongo_client["dlite"]["entities"] monkeypatch.setattr(main, "ENTITIES_COLLECTION", mock_entities_collection) + monkeypatch.setattr( + main, "get_collection", lambda *args, **kwargs: mock_entities_collection + ) result = cli.invoke( main.APP, f"upload --file {samples / 'valid_entities' / 'Person.json'}" @@ -101,6 +104,9 @@ def test_upload_directory( mock_entities_collection = mongo_client["dlite"]["entities"] monkeypatch.setattr(main, "ENTITIES_COLLECTION", mock_entities_collection) + monkeypatch.setattr( + main, "get_collection", lambda *args, **kwargs: mock_entities_collection + ) result = cli.invoke(main.APP, f"upload --dir {samples / 'valid_entities'}") assert result.exit_code == 0 From 683c5eb2b168b28824b3c23c9e7010b6b7427d6d Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Wed, 26 Apr 2023 23:52:34 +0200 Subject: [PATCH 14/39] Ensure proper env var prefix --- dlite_entities_service/utils_cli/config.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/dlite_entities_service/utils_cli/config.py b/dlite_entities_service/utils_cli/config.py index 746f68f5..ebb48898 100644 --- a/dlite_entities_service/utils_cli/config.py +++ b/dlite_entities_service/utils_cli/config.py @@ -90,11 +90,11 @@ def set_config( dotenv_file = CLI_DOTENV_FILE if not dotenv_file.exists(): dotenv_file.touch() - set_key(dotenv_file, key, value) + set_key(dotenv_file, f"{CONFIG.Config.env_prefix}{key}", value) print( - f"Set {key} to sensitive value." + f"Set {CONFIG.Config.env_prefix}{key} to sensitive value." if key.is_sensitive() - else f"Set {key} to {value}." + else f"Set {CONFIG.Config.env_prefix}{key} to {value}." ) @@ -117,8 +117,8 @@ def unset( else: dotenv_file = CLI_DOTENV_FILE if dotenv_file.exists(): - unset_key(dotenv_file, key) - print(f"Unset {key}.") + unset_key(dotenv_file, f"{CONFIG.Config.env_prefix}{key}") + print(f"Unset {CONFIG.Config.env_prefix}{key}.") @APP.command() @@ -138,9 +138,10 @@ def show( dotenv_file = CLI_DOTENV_FILE if dotenv_file.exists(): values = { - ConfigFields(key): value + ConfigFields(key[len(CONFIG.Config.env_prefix) :]): value for key, value in dotenv_values(dotenv_file).items() - if key in ConfigFields.__members__ + if key + in [f"{CONFIG.Config.env_prefix}{_}" for _ in ConfigFields.__members__] } else: ERROR_CONSOLE.print(f"No {dotenv_file} file found.") @@ -149,7 +150,7 @@ def show( for key, value in values.items(): if not reveal_sensitive and key.is_sensitive(): value = "***" - print(f"[bold]{key}[/bold]: {value}") + print(f"[bold]{CONFIG.Config.env_prefix}{key}[/bold]: {value}") @APP.callback() From c8e447aa504d817cbbb631b49ea66de328f5a1b3 Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Wed, 26 Apr 2023 23:53:11 +0200 Subject: [PATCH 15/39] Implement `entities-service delete` CLI command --- dlite_entities_service/utils_cli/main.py | 73 ++++++++++++++++++------ 1 file changed, 54 insertions(+), 19 deletions(-) diff --git a/dlite_entities_service/utils_cli/main.py b/dlite_entities_service/utils_cli/main.py index a648a2bd..0a8835de 100644 --- a/dlite_entities_service/utils_cli/main.py +++ b/dlite_entities_service/utils_cli/main.py @@ -16,7 +16,7 @@ import dlite import yaml -from dotenv import find_dotenv, get_key +from dotenv import dotenv_values, find_dotenv from rich import print # pylint: disable=redefined-builtin from rich.console import Console @@ -31,6 +31,8 @@ if TYPE_CHECKING: # pragma: no cover from typing import Any + from pymongo.collection import Collection + ERROR_CONSOLE = Console(stderr=True) @@ -59,6 +61,22 @@ def _print_version(value: bool) -> None: raise typer.Exit() +def _get_backend() -> "Collection": + """Return the backend.""" + config_file = find_dotenv() + if config_file: + config = dotenv_values(config_file) + backend_options = { + "uri": config.get("entity_service_mongo_uri"), + "username": config.get("entity_service_mongo_user"), + "password": config.get("entity_service_mongo_password"), + } + if all(_ is None for _ in backend_options.values()): + return ENTITIES_COLLECTION + return get_collection(**backend_options) + return ENTITIES_COLLECTION + + @APP.callback() def main( _: Optional[bool] = typer.Option( @@ -137,20 +155,6 @@ def upload( ) raise typer.Exit(1) - config_file = find_dotenv() - if config_file: - backend_options = { - "uri": get_key(config_file, "entity_service_mongo_uri"), - "username": get_key(config_file, "entity_service_mongo_user"), - "password": get_key(config_file, "entity_service_mongo_password"), - } - if all(_ is None for _ in backend_options.values()): - backend = ENTITIES_COLLECTION - else: - backend = get_collection(**backend_options) - else: - backend = ENTITIES_COLLECTION - successes = [] for filepath in unique_filepaths: if filepath.suffix[1:].lower() not in file_formats: @@ -180,7 +184,7 @@ def upload( raise typer.Exit(1) from exc try: - backend.insert_one(entity) + _get_backend().insert_one(entity) except AnyWriteError as exc: ERROR_CONSOLE.print( f"[bold red]Error[/bold red]: {filepath} cannot be uploaded. " @@ -199,16 +203,47 @@ def upload( print("No entities were uploaded.") +@APP.command() +def iterate(): + """Iterate on an existing DLite entity. + + This means uploading a new version of an existing entity. + """ + print("Not implemented yet") + + @APP.command() def update(): """Update an existing DLite entity.""" print("Not implemented yet") -@APP.command() -def delete(): +@APP.command(no_args_is_help=True) +def delete( + uri: str = typer.Argument( + ..., + help="URI of the DLite entity to delete.", + show_default=False, + ), +): """Delete an existing DLite entity.""" - print("Not implemented yet") + backend = _get_backend() + + if not backend.count_documents({"uri": uri}): + print(f"Already no entity found with URI {uri!r}.") + raise typer.Exit() + + typer.confirm( + f"Are you sure you want to delete entity with URI {uri!r}?", abort=True + ) + + backend.delete_one({"uri": uri}) + if backend.count_documents({"uri": uri}): + ERROR_CONSOLE.print( + f"[bold red]Error[/bold red]: Failed to delete entity with URI {uri!r}." + ) + raise typer.Exit(1) + print(f"Successfully deleted entity with URI {uri!r}.") @APP.command() From d1e3c5d6f9356667ce07875e798310f001c3d607 Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Wed, 26 Apr 2023 23:58:27 +0200 Subject: [PATCH 16/39] Implement `entities-service get` CLI command --- dlite_entities_service/utils_cli/main.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/dlite_entities_service/utils_cli/main.py b/dlite_entities_service/utils_cli/main.py index 0a8835de..2ea0a95f 100644 --- a/dlite_entities_service/utils_cli/main.py +++ b/dlite_entities_service/utils_cli/main.py @@ -246,10 +246,27 @@ def delete( print(f"Successfully deleted entity with URI {uri!r}.") -@APP.command() -def get(): +@APP.command(no_args_is_help=True) +def get( + uri: str = typer.Argument( + ..., + help="URI of the DLite entity to get.", + show_default=False, + ), +): """Get an existing DLite entity.""" - print("Not implemented yet") + backend = _get_backend() + + if not backend.count_documents({"uri": uri}): + ERROR_CONSOLE.print( + f"[bold red]Error[/bold red]: No entity found with URI {uri!r}." + ) + raise typer.Exit(1) + + entity_dict: "dict[str, Any]" = backend.find_one({"uri": uri}) + entity_dict.pop("_id") + entity = dlite.Instance.from_dict(entity_dict, single=True, check_storages=False) + print(entity) @APP.command() From d886034c5ff8857f57ca0c8bbcdb7b1fdb57c6cb Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Thu, 27 Apr 2023 00:11:43 +0200 Subject: [PATCH 17/39] Implement `entities-service validate` CLI command --- dlite_entities_service/utils_cli/main.py | 97 ++++++++++++++++++++++-- 1 file changed, 91 insertions(+), 6 deletions(-) diff --git a/dlite_entities_service/utils_cli/main.py b/dlite_entities_service/utils_cli/main.py index 2ea0a95f..93edbe1a 100644 --- a/dlite_entities_service/utils_cli/main.py +++ b/dlite_entities_service/utils_cli/main.py @@ -115,15 +115,15 @@ def upload( resolve_path=True, help=( "Path to directory with DLite entities. All files matching the given " - "format(s) in the directory will be uploaded. Subdirectories will be " - "ignored." + "format(s) in the directory will be uploaded. " + "Subdirectories will be ignored." ), show_default=False, ), file_formats: Optional[list[EntityFileFormats]] = typer.Option( [EntityFileFormats.JSON], "--format", - help="Format of DLite entity file.", + help="Format of DLite entity file(s).", show_choices=True, show_default=True, case_sensitive=False, @@ -275,7 +275,92 @@ def search(): print("Not implemented yet") -@APP.command() -def validate(): +@APP.command(no_args_is_help=True) +def validate( + filepaths: Optional[list[Path]] = typer.Option( + None, + "--file", + "-f", + exists=True, + file_okay=True, + dir_okay=False, + readable=True, + resolve_path=True, + help="Path to DLite entity file.", + show_default=False, + ), + directories: Optional[list[Path]] = typer.Option( + None, + "--dir", + "-d", + exists=True, + file_okay=False, + dir_okay=True, + readable=True, + resolve_path=True, + help=( + "Path to directory with DLite entities. All files matching the given " + "format(s) in the directory will be validated. " + "Subdirectories will be ignored." + ), + show_default=False, + ), + file_formats: Optional[list[EntityFileFormats]] = typer.Option( + [EntityFileFormats.JSON], + "--format", + help="Format of DLite entity file(s).", + show_choices=True, + show_default=True, + case_sensitive=False, + ), +): """Validate a DLite entity.""" - print("Not implemented yet") + unique_filepaths = set(filepaths or []) + directories = list(set(directories or [])) + file_formats = list(set(file_formats or [])) + + if not filepaths and not directories: + ERROR_CONSOLE.print( + "[bold red]Error[/bold red]: Missing either option '--file' / '-f' or " + "'--dir' / '-d'." + ) + raise typer.Exit(1) + + for directory in directories: + for root, _, files in os.walk(directory): + unique_filepaths |= set( + Path(root) / file + for file in files + if file.lower().endswith(tuple(file_formats)) + ) + + if not unique_filepaths: + ERROR_CONSOLE.print( + "[bold red]Error[/bold red]: No files found with the given options." + ) + raise typer.Exit(1) + + for filepath in unique_filepaths: + if filepath.suffix[1:].lower() not in file_formats: + ERROR_CONSOLE.print( + "[bold yellow]Warning[/bold yellow]: File format " + f"{filepath.suffix[1:].lower()!r} is not supported. Skipping file: " + f"{filepath}" + ) + continue + + entity: "dict[str, Any]" = ( + json.loads(filepath.read_bytes()) + if filepath.suffix[1:].lower() == "json" + else yaml.safe_load(filepath.read_bytes()) + ) + + try: + dlite.Instance.from_dict(entity, single=True, check_storages=False) + except ( # pylint: disable=redefined-outer-name + dlite.DLiteError, + KeyError, + ): + print(f"{filepath} [bold red]invalid[/bold red]") + else: + print(f"{filepath} [bold green]valid[/bold green]") From c39abb32a7c4d7b83b7f60b5818e6c6e802610b5 Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Thu, 27 Apr 2023 00:15:38 +0200 Subject: [PATCH 18/39] Hide not yet implemented CLI commands --- dlite_entities_service/utils_cli/main.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dlite_entities_service/utils_cli/main.py b/dlite_entities_service/utils_cli/main.py index 93edbe1a..6e9d2233 100644 --- a/dlite_entities_service/utils_cli/main.py +++ b/dlite_entities_service/utils_cli/main.py @@ -203,7 +203,7 @@ def upload( print("No entities were uploaded.") -@APP.command() +@APP.command(hidden=True) def iterate(): """Iterate on an existing DLite entity. @@ -212,7 +212,7 @@ def iterate(): print("Not implemented yet") -@APP.command() +@APP.command(hidden=True) def update(): """Update an existing DLite entity.""" print("Not implemented yet") @@ -269,7 +269,7 @@ def get( print(entity) -@APP.command() +@APP.command(hidden=True) def search(): """Search for DLite entities.""" print("Not implemented yet") From 331ba331aa865da17ca547ff7bed3312585e6ac0 Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Thu, 27 Apr 2023 00:20:26 +0200 Subject: [PATCH 19/39] Minor doc string improvements --- dlite_entities_service/utils_cli/main.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dlite_entities_service/utils_cli/main.py b/dlite_entities_service/utils_cli/main.py index 6e9d2233..c67500f5 100644 --- a/dlite_entities_service/utils_cli/main.py +++ b/dlite_entities_service/utils_cli/main.py @@ -129,7 +129,7 @@ def upload( case_sensitive=False, ), ) -> None: - """Upload a DLite entity.""" + """Upload (local) DLite entities.""" unique_filepaths = set(filepaths or []) directories = list(set(directories or [])) file_formats = list(set(file_formats or [])) @@ -226,7 +226,7 @@ def delete( show_default=False, ), ): - """Delete an existing DLite entity.""" + """Delete an existing (remote) DLite entity.""" backend = _get_backend() if not backend.count_documents({"uri": uri}): @@ -254,7 +254,7 @@ def get( show_default=False, ), ): - """Get an existing DLite entity.""" + """Get an existing (remote) DLite entity.""" backend = _get_backend() if not backend.count_documents({"uri": uri}): @@ -314,7 +314,7 @@ def validate( case_sensitive=False, ), ): - """Validate a DLite entity.""" + """Validate (local) DLite entities.""" unique_filepaths = set(filepaths or []) directories = list(set(directories or [])) file_formats = list(set(file_formats or [])) From 332b69e750872f8bdbf3d43baf3c2012a3010849 Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Fri, 28 Apr 2023 21:02:09 +0200 Subject: [PATCH 20/39] Create a global settings file for the CLI --- dlite_entities_service/utils_cli/config.py | 67 +++++++------------ .../utils_cli/global_settings.py | 47 +++++++++++++ dlite_entities_service/utils_cli/main.py | 24 +------ 3 files changed, 73 insertions(+), 65 deletions(-) create mode 100644 dlite_entities_service/utils_cli/global_settings.py diff --git a/dlite_entities_service/utils_cli/config.py b/dlite_entities_service/utils_cli/config.py index ebb48898..23db9ebd 100644 --- a/dlite_entities_service/utils_cli/config.py +++ b/dlite_entities_service/utils_cli/config.py @@ -17,6 +17,7 @@ from rich.console import Console from dlite_entities_service.service.config import CONFIG +from dlite_entities_service.utils_cli.global_settings import STATUS ERROR_CONSOLE = Console(stderr=True) CLI_DOTENV_FILE = Path(__file__).resolve().parent / ".env" @@ -24,12 +25,11 @@ APP = typer.Typer( name=__file__.rsplit("/", 1)[-1].replace(".py", ""), + help="Manage configuration options.", no_args_is_help=True, invoke_without_command=True, ) -STATUS = {"use_service_dotenv": False} - class ConfigFields(str, Enum): """Configuration options.""" @@ -111,7 +111,7 @@ def unset( show_default=False, ), ) -> None: - """Unset a configuration option.""" + """Unset a single configuration option.""" if STATUS["use_service_dotenv"]: dotenv_file = SERVICE_DOTENV_FILE else: @@ -121,6 +121,26 @@ def unset( print(f"Unset {CONFIG.Config.env_prefix}{key}.") +@APP.command() +def unset_all() -> None: + """Unset all configuration options.""" + typer.confirm( + "Are you sure you want to unset (remove) all configuration options in " + f"{'Service' if STATUS['use_service_dotenv'] else 'CLI'}-specific .env file?", + abort=True, + ) + + if STATUS["use_service_dotenv"]: + dotenv_file = SERVICE_DOTENV_FILE + else: + dotenv_file = CLI_DOTENV_FILE + if dotenv_file.exists(): + dotenv_file.unlink() + print(f"Unset all configuration options. (Removed {dotenv_file}.)") + else: + print(f"Unset all configuration options. ({dotenv_file} file not found.)") + + @APP.command() def show( reveal_sensitive: bool = typer.Option( @@ -151,44 +171,3 @@ def show( if not reveal_sensitive and key.is_sensitive(): value = "***" print(f"[bold]{CONFIG.Config.env_prefix}{key}[/bold]: {value}") - - -@APP.callback() -def main( - use_service_dotenv: bool = typer.Option( - False, - "--use-service-dotenv/--use-cli-dotenv", - help=( - "Use the .env file also used for the DLite Entities Service or one only " - "for the CLI." - ), - is_flag=True, - ), - unset_all: bool = typer.Option( - False, - "--unset-all", - help="Unset (remove) all configuration options in dotenv file.", - show_default=False, - is_flag=True, - ), -) -> None: - """Set DLite entities service configuration options.""" - STATUS["use_service_dotenv"] = use_service_dotenv - - if unset_all: - typer.confirm( - "Are you sure you want to unset (remove) all configuration options in " - f"{'Service' if use_service_dotenv else 'CLI'}-specific .env file?", - abort=True, - ) - - if use_service_dotenv: - dotenv_file = SERVICE_DOTENV_FILE - else: - dotenv_file = CLI_DOTENV_FILE - - if dotenv_file.exists(): - dotenv_file.unlink() - print(f"Removed {dotenv_file}.") - else: - print(f"No {dotenv_file} file found.") diff --git a/dlite_entities_service/utils_cli/global_settings.py b/dlite_entities_service/utils_cli/global_settings.py new file mode 100644 index 00000000..ba898b86 --- /dev/null +++ b/dlite_entities_service/utils_cli/global_settings.py @@ -0,0 +1,47 @@ +"""Global settings for the CLI.""" +from pathlib import Path +from typing import Optional + +try: + import typer +except ImportError as exc: + raise ImportError( + "Please install the DLite entities service utility CLI with " + f"'pip install {Path(__file__).resolve().parent.parent.parent.resolve()}[cli]'" + ) from exc + +from rich import print # pylint: disable=redefined-builtin + +from dlite_entities_service import __version__ + +STATUS = {"use_service_dotenv": False} + + +def _print_version(value: bool) -> None: + """Print version and exit.""" + if value: + print(f"dlite-entities-service version: {__version__}") + raise typer.Exit() + + +def global_options( + _: Optional[bool] = typer.Option( + None, + "--version", + help="Show version and exit", + is_eager=True, + callback=_print_version, + ), + use_service_dotenv: bool = typer.Option( + False, + "--use-service-dotenv/--use-cli-dotenv", + help=( + "Use the .env file also used for the DLite Entities Service or one only " + "for the CLI." + ), + is_flag=True, + rich_help_panel="Global options", + ), +) -> None: + """Global options for the CLI.""" + STATUS["use_service_dotenv"] = use_service_dotenv diff --git a/dlite_entities_service/utils_cli/main.py b/dlite_entities_service/utils_cli/main.py index c67500f5..fd8bbd99 100644 --- a/dlite_entities_service/utils_cli/main.py +++ b/dlite_entities_service/utils_cli/main.py @@ -27,6 +27,7 @@ get_collection, ) from dlite_entities_service.utils_cli.config import APP as config_APP +from dlite_entities_service.utils_cli.global_settings import global_options if TYPE_CHECKING: # pragma: no cover from typing import Any @@ -50,15 +51,9 @@ class EntityFileFormats(str, Enum): help="DLite entities service utility CLI", no_args_is_help=True, pretty_exceptions_show_locals=False, + callback=global_options, ) -APP.add_typer(config_APP) - - -def _print_version(value: bool) -> None: - """Print version and exit.""" - if value: - print(f"dlite-entities-service version: {__version__}") - raise typer.Exit() +APP.add_typer(config_APP, callback=global_options) def _get_backend() -> "Collection": @@ -77,19 +72,6 @@ def _get_backend() -> "Collection": return ENTITIES_COLLECTION -@APP.callback() -def main( - _: Optional[bool] = typer.Option( - None, - "--version", - help="Show version and exit", - is_eager=True, - callback=_print_version, - ), -) -> None: - """DLite entities service utility CLI.""" - - @APP.command(no_args_is_help=True) def upload( filepaths: Optional[list[Path]] = typer.Option( From b18b2e5e0da0237cf0db4c8449b59928cbb52839 Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Fri, 28 Apr 2023 21:21:52 +0200 Subject: [PATCH 21/39] Create _utils subfolder in CLI for CLI utils --- dlite_entities_service/utils_cli/_utils/__init__.py | 0 .../utils_cli/{ => _utils}/global_settings.py | 8 ++++---- dlite_entities_service/utils_cli/config.py | 2 +- dlite_entities_service/utils_cli/main.py | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) create mode 100644 dlite_entities_service/utils_cli/_utils/__init__.py rename dlite_entities_service/utils_cli/{ => _utils}/global_settings.py (86%) diff --git a/dlite_entities_service/utils_cli/_utils/__init__.py b/dlite_entities_service/utils_cli/_utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dlite_entities_service/utils_cli/global_settings.py b/dlite_entities_service/utils_cli/_utils/global_settings.py similarity index 86% rename from dlite_entities_service/utils_cli/global_settings.py rename to dlite_entities_service/utils_cli/_utils/global_settings.py index ba898b86..726cc976 100644 --- a/dlite_entities_service/utils_cli/global_settings.py +++ b/dlite_entities_service/utils_cli/_utils/global_settings.py @@ -6,8 +6,8 @@ import typer except ImportError as exc: raise ImportError( - "Please install the DLite entities service utility CLI with " - f"'pip install {Path(__file__).resolve().parent.parent.parent.resolve()}[cli]'" + "Please install the DLite entities service utility CLI with 'pip install " + f"{Path(__file__).resolve().parent.parent.parent.parent.resolve()}[cli]'" ) from exc from rich import print # pylint: disable=redefined-builtin @@ -17,7 +17,7 @@ STATUS = {"use_service_dotenv": False} -def _print_version(value: bool) -> None: +def print_version(value: bool) -> None: """Print version and exit.""" if value: print(f"dlite-entities-service version: {__version__}") @@ -30,7 +30,7 @@ def global_options( "--version", help="Show version and exit", is_eager=True, - callback=_print_version, + callback=print_version, ), use_service_dotenv: bool = typer.Option( False, diff --git a/dlite_entities_service/utils_cli/config.py b/dlite_entities_service/utils_cli/config.py index 23db9ebd..aca23066 100644 --- a/dlite_entities_service/utils_cli/config.py +++ b/dlite_entities_service/utils_cli/config.py @@ -17,7 +17,7 @@ from rich.console import Console from dlite_entities_service.service.config import CONFIG -from dlite_entities_service.utils_cli.global_settings import STATUS +from dlite_entities_service.utils_cli._utils.global_settings import STATUS ERROR_CONSOLE = Console(stderr=True) CLI_DOTENV_FILE = Path(__file__).resolve().parent / ".env" diff --git a/dlite_entities_service/utils_cli/main.py b/dlite_entities_service/utils_cli/main.py index fd8bbd99..89897156 100644 --- a/dlite_entities_service/utils_cli/main.py +++ b/dlite_entities_service/utils_cli/main.py @@ -26,8 +26,8 @@ AnyWriteError, get_collection, ) +from dlite_entities_service.utils_cli._utils.global_settings import global_options from dlite_entities_service.utils_cli.config import APP as config_APP -from dlite_entities_service.utils_cli.global_settings import global_options if TYPE_CHECKING: # pragma: no cover from typing import Any From 161c87e35be7cdbbc175ae0ea66ea5cbacf4219f Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Fri, 28 Apr 2023 22:02:11 +0200 Subject: [PATCH 22/39] Implement the `entity-service search` command --- dlite_entities_service/utils_cli/main.py | 82 +++++++++++++++++++++--- 1 file changed, 73 insertions(+), 9 deletions(-) diff --git a/dlite_entities_service/utils_cli/main.py b/dlite_entities_service/utils_cli/main.py index 89897156..d7a60675 100644 --- a/dlite_entities_service/utils_cli/main.py +++ b/dlite_entities_service/utils_cli/main.py @@ -186,7 +186,7 @@ def upload( @APP.command(hidden=True) -def iterate(): +def iterate() -> None: """Iterate on an existing DLite entity. This means uploading a new version of an existing entity. @@ -195,7 +195,7 @@ def iterate(): @APP.command(hidden=True) -def update(): +def update() -> None: """Update an existing DLite entity.""" print("Not implemented yet") @@ -207,7 +207,7 @@ def delete( help="URI of the DLite entity to delete.", show_default=False, ), -): +) -> None: """Delete an existing (remote) DLite entity.""" backend = _get_backend() @@ -235,7 +235,7 @@ def get( help="URI of the DLite entity to get.", show_default=False, ), -): +) -> None: """Get an existing (remote) DLite entity.""" backend = _get_backend() @@ -251,10 +251,74 @@ def get( print(entity) -@APP.command(hidden=True) -def search(): - """Search for DLite entities.""" - print("Not implemented yet") +@APP.command(no_args_is_help=True) +def search( + uris: Optional[list[str]] = typer.Argument( + None, + metavar="[URI]...", + help=( + "URI of the DLite entity to search for. Multiple URIs can be provided. " + "Note, the 'http://onto-ns.com/meta' prefix is optional." + ), + show_default=False, + ), + query: Optional[str] = typer.Option( + None, + "--query", + "-q", + help="Backend-specific query to search for DLite entities.", + show_default=False, + ), + as_json: bool = typer.Option( + False, + "--json", + "-j", + help="Return the search results as JSON.", + show_default=False, + is_flag=True, + ), +) -> None: + """Search for (remote) DLite entities.""" + backend = _get_backend() + + if not uris and not query: + ERROR_CONSOLE.print( + "[bold red]Error[/bold red]: Missing either argument 'URI' or option " + "'query'." + ) + raise typer.Exit(1) + + backend_query: "Optional[dict[str, Any]]" = json.loads(query) if query else None + if uris: + uris = [ + uri + if uri.startswith("http://onto-ns.com/meta") + else f"http://onto-ns.com/meta/{uri.lstrip('/')}" + for uri in uris + ] + backend_query = ( + {"$and": [{"uri": {"$in": uris}}, backend_query]} + if backend_query + else {"uri": {"$in": uris}} + ) + + if backend_query is None: + ERROR_CONSOLE.print("[bold red]Error[/bold red]: Internal CLI error.") + raise typer.Exit(1) + + found_entities: list[dlite.Instance] = [] + for raw_entity in backend.find(backend_query): + raw_entity.pop("_id") + entity: dlite.Instance = dlite.Instance.from_dict( + raw_entity, single=True, check_storages=False + ) + found_entities.append(entity) + + if as_json: + print(json.dumps([_.asdict(uuid=False) for _ in found_entities])) + raise typer.Exit() + + print(f"Found {len(found_entities)} entities: {[_.uuid for _ in found_entities]}") @APP.command(no_args_is_help=True) @@ -295,7 +359,7 @@ def validate( show_default=True, case_sensitive=False, ), -): +) -> None: """Validate (local) DLite entities.""" unique_filepaths = set(filepaths or []) directories = list(set(directories or [])) From 6c9d699a6712bf8756878f90b5a3c6215db6e9fe Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Fri, 28 Apr 2023 22:27:04 +0200 Subject: [PATCH 23/39] Implement `entities-service update` command --- dlite_entities_service/utils_cli/main.py | 138 ++++++++++++++++++++++- 1 file changed, 134 insertions(+), 4 deletions(-) diff --git a/dlite_entities_service/utils_cli/main.py b/dlite_entities_service/utils_cli/main.py index d7a60675..a790b23c 100644 --- a/dlite_entities_service/utils_cli/main.py +++ b/dlite_entities_service/utils_cli/main.py @@ -194,10 +194,140 @@ def iterate() -> None: print("Not implemented yet") -@APP.command(hidden=True) -def update() -> None: - """Update an existing DLite entity.""" - print("Not implemented yet") +@APP.command(no_args_is_help=True) +def update( + filepaths: Optional[list[Path]] = typer.Option( + None, + "--file", + "-f", + exists=True, + file_okay=True, + dir_okay=False, + readable=True, + resolve_path=True, + help="Path to DLite entity file.", + show_default=False, + ), + directories: Optional[list[Path]] = typer.Option( + None, + "--dir", + "-d", + exists=True, + file_okay=False, + dir_okay=True, + readable=True, + resolve_path=True, + help=( + "Path to directory with DLite entities. All files matching the given " + "format(s) in the directory will be uploaded. " + "Subdirectories will be ignored." + ), + show_default=False, + ), + file_formats: Optional[list[EntityFileFormats]] = typer.Option( + [EntityFileFormats.JSON], + "--format", + help="Format of DLite entity file(s).", + show_choices=True, + show_default=True, + case_sensitive=False, + ), + insert: bool = typer.Option( + False, + "--insert", + "-i", + help="Insert the entity if it does not exist yet.", + show_default=False, + is_flag=True, + ), +) -> None: + """Update an existing (remote) DLite entity.""" + unique_filepaths = set(filepaths or []) + directories = list(set(directories or [])) + file_formats = list(set(file_formats or [])) + + if not filepaths and not directories: + ERROR_CONSOLE.print( + "[bold red]Error[/bold red]: Missing either option '--file' / '-f' or " + "'--dir' / '-d'." + ) + raise typer.Exit(1) + + for directory in directories: + for root, _, files in os.walk(directory): + unique_filepaths |= set( + Path(root) / file + for file in files + if file.lower().endswith(tuple(file_formats)) + ) + + if not unique_filepaths: + ERROR_CONSOLE.print( + "[bold red]Error[/bold red]: No files found with the given options." + ) + raise typer.Exit(1) + + successes = [] + inserted = [] + for filepath in unique_filepaths: + if filepath.suffix[1:].lower() not in file_formats: + ERROR_CONSOLE.print( + "[bold yellow]Warning[/bold yellow]: File format " + f"{filepath.suffix[1:].lower()!r} is not supported. Skipping file: " + f"{filepath}" + ) + continue + + entity: "dict[str, Any]" = ( + json.loads(filepath.read_bytes()) + if filepath.suffix[1:].lower() == "json" + else yaml.safe_load(filepath.read_bytes()) + ) + + try: + dlite.Instance.from_dict(entity, single=True, check_storages=False) + except ( # pylint: disable=redefined-outer-name + dlite.DLiteError, + KeyError, + ) as exc: + ERROR_CONSOLE.print( + f"[bold red]Error[/bold red]: {filepath} cannot be loaded with DLite. " + f"DLite exception: {exc}" + ) + raise typer.Exit(1) from exc + + try: + result = _get_backend().update_one( + filter={"uri": entity["uri"]}, + update=entity, + upsert=insert, + ) + except AnyWriteError as exc: + ERROR_CONSOLE.print( + f"[bold red]Error[/bold red]: {filepath} cannot be uploaded. " + f"Backend exception: {exc}" + ) + raise typer.Exit(1) from exc + + if insert: + if result.upserted_id: + inserted.append(filepath) + successes.append(filepath) + + if successes and inserted: + print( + f"Successfully updated {len(successes) - len(inserted)} entities and " + f"inserted {len(inserted)} new entities: " + f"{[str(_) for _ in successes if _ not in inserted]} " + f"and {[str(_) for _ in inserted]}" + ) + elif successes: + print( + f"Successfully updated {len(successes)} entities: " + f"{[str(_) for _ in successes]}" + ) + else: + print("No entities were updated.") @APP.command(no_args_is_help=True) From 209e46cb88abf605fedd6eae7d4d4186c8e3e0c1 Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Fri, 25 Aug 2023 10:20:00 +0200 Subject: [PATCH 24/39] Update dependencies --- pyproject.toml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d446ee8f..4a6c686b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,18 +44,18 @@ dependencies = [ # ] testing = [ "mongomock ~=4.1", - "pytest ~=7.3", - "pytest-cov ~=4.0", + "pytest ~=7.4", + "pytest-cov ~=4.1", ] cli = [ - "DLite-Python ~=0.3.19", + "DLite-Python ~=0.3.22", "typer[all] ~=0.7.0", ] dev = [ "mongomock ~=4.1", - "pytest ~=7.3", - "pytest-cov ~=4.0", - "DLite-Python ~=0.3.19", + "pytest ~=7.4", + "pytest-cov ~=4.1", + "DLite-Python ~=0.3.22", "typer[all] ~=0.7.0", "pre-commit ~=3.3", "pylint ~=2.17", From 68b561c1163414f309bfb9959c4a8e50f867dc19 Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Fri, 25 Aug 2023 10:42:18 +0200 Subject: [PATCH 25/39] Update to pydantic v2 --- dlite_entities_service/utils_cli/config.py | 27 +++++++++++----------- tests/utils_cli/test_upload.py | 4 ++-- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/dlite_entities_service/utils_cli/config.py b/dlite_entities_service/utils_cli/config.py index aca23066..50faa241 100644 --- a/dlite_entities_service/utils_cli/config.py +++ b/dlite_entities_service/utils_cli/config.py @@ -44,13 +44,11 @@ def autocomplete(cls, incomplete: str) -> Generator[tuple[str, str], None, None] """Return a list of valid configuration options.""" for member in cls: if member.value.startswith(incomplete): - if member.value not in CONFIG.__fields__: + if member.value not in CONFIG.model_fields: raise typer.BadParameter( f"Invalid configuration option: {member.value!r}" ) - yield member.value, CONFIG.__fields__[ - member.value - ].field_info.description + yield member.value, CONFIG.model_fields[member.value].description def is_sensitive(self) -> bool: """Return True if this is a sensitive configuration option.""" @@ -63,7 +61,7 @@ def set_config( ..., help=( "Configuration option to set. These can also be set as an environment " - f"variable by prefixing with {CONFIG.Config.env_prefix!r}." + f"variable by prefixing with {CONFIG.model_config['env_prefix']!r}." ), show_choices=True, # Start using shell_complete once tiangolo/typer#334 is resolved. @@ -90,11 +88,11 @@ def set_config( dotenv_file = CLI_DOTENV_FILE if not dotenv_file.exists(): dotenv_file.touch() - set_key(dotenv_file, f"{CONFIG.Config.env_prefix}{key}", value) + set_key(dotenv_file, f"{CONFIG.model_config['env_prefix']}{key}", value) print( - f"Set {CONFIG.Config.env_prefix}{key} to sensitive value." + f"Set {CONFIG.model_config['env_prefix']}{key} to sensitive value." if key.is_sensitive() - else f"Set {CONFIG.Config.env_prefix}{key} to {value}." + else f"Set {CONFIG.model_config['env_prefix']}{key} to {value}." ) @@ -117,8 +115,8 @@ def unset( else: dotenv_file = CLI_DOTENV_FILE if dotenv_file.exists(): - unset_key(dotenv_file, f"{CONFIG.Config.env_prefix}{key}") - print(f"Unset {CONFIG.Config.env_prefix}{key}.") + unset_key(dotenv_file, f"{CONFIG.model_config['env_prefix']}{key}") + print(f"Unset {CONFIG.model_config['env_prefix']}{key}.") @APP.command() @@ -158,10 +156,13 @@ def show( dotenv_file = CLI_DOTENV_FILE if dotenv_file.exists(): values = { - ConfigFields(key[len(CONFIG.Config.env_prefix) :]): value + ConfigFields(key[len(CONFIG.model_config["env_prefix"]) :]): value for key, value in dotenv_values(dotenv_file).items() if key - in [f"{CONFIG.Config.env_prefix}{_}" for _ in ConfigFields.__members__] + in [ + f"{CONFIG.model_config['env_prefix']}{_}" + for _ in ConfigFields.__members__ + ] } else: ERROR_CONSOLE.print(f"No {dotenv_file} file found.") @@ -170,4 +171,4 @@ def show( for key, value in values.items(): if not reveal_sensitive and key.is_sensitive(): value = "***" - print(f"[bold]{CONFIG.Config.env_prefix}{key}[/bold]: {value}") + print(f"[bold]{CONFIG.model_config['env_prefix']}{key}[/bold]: {value}") diff --git a/tests/utils_cli/test_upload.py b/tests/utils_cli/test_upload.py index 126777b5..53960a17 100644 --- a/tests/utils_cli/test_upload.py +++ b/tests/utils_cli/test_upload.py @@ -32,7 +32,7 @@ def test_upload_filepath( from dlite_entities_service.service.config import CONFIG from dlite_entities_service.utils_cli import main - mongo_client = MongoClient(CONFIG.mongo_uri) + mongo_client = MongoClient(str(CONFIG.mongo_uri)) mock_entities_collection = mongo_client["dlite"]["entities"] monkeypatch.setattr(main, "ENTITIES_COLLECTION", mock_entities_collection) @@ -100,7 +100,7 @@ def test_upload_directory( from dlite_entities_service.service.config import CONFIG from dlite_entities_service.utils_cli import main - mongo_client = MongoClient(CONFIG.mongo_uri) + mongo_client = MongoClient(str(CONFIG.mongo_uri)) mock_entities_collection = mongo_client["dlite"]["entities"] monkeypatch.setattr(main, "ENTITIES_COLLECTION", mock_entities_collection) From 454aa606e1b7915b06a511981e837019eb888d1b Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Fri, 25 Aug 2023 10:59:59 +0200 Subject: [PATCH 26/39] Fix 'config show' --- dlite_entities_service/utils_cli/config.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/dlite_entities_service/utils_cli/config.py b/dlite_entities_service/utils_cli/config.py index 50faa241..0a434b12 100644 --- a/dlite_entities_service/utils_cli/config.py +++ b/dlite_entities_service/utils_cli/config.py @@ -20,8 +20,10 @@ from dlite_entities_service.utils_cli._utils.global_settings import STATUS ERROR_CONSOLE = Console(stderr=True) -CLI_DOTENV_FILE = Path(__file__).resolve().parent / ".env" -SERVICE_DOTENV_FILE = Path(__file__).resolve().parent.parent.parent / ".env" +CLI_DOTENV_FILE = Path(__file__).resolve().parent / CONFIG.model_config["env_file"] +SERVICE_DOTENV_FILE = ( + Path(__file__).resolve().parent.parent.parent / CONFIG.model_config["env_file"] +) APP = typer.Typer( name=__file__.rsplit("/", 1)[-1].replace(".py", ""), @@ -124,7 +126,8 @@ def unset_all() -> None: """Unset all configuration options.""" typer.confirm( "Are you sure you want to unset (remove) all configuration options in " - f"{'Service' if STATUS['use_service_dotenv'] else 'CLI'}-specific .env file?", + f"{'Service' if STATUS['use_service_dotenv'] else 'CLI'}-specific " + f"{CONFIG.model_config['env_file']} file?", abort=True, ) @@ -161,7 +164,7 @@ def show( if key in [ f"{CONFIG.model_config['env_prefix']}{_}" - for _ in ConfigFields.__members__ + for _ in ConfigFields.__members__.values() ] } else: @@ -170,5 +173,5 @@ def show( for key, value in values.items(): if not reveal_sensitive and key.is_sensitive(): - value = "***" + value = "*" * 8 print(f"[bold]{CONFIG.model_config['env_prefix']}{key}[/bold]: {value}") From 5f08fd47cbad5622d9bdd05d91946519d441a5dd Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Fri, 25 Aug 2023 11:29:05 +0200 Subject: [PATCH 27/39] Add --json, --yaml, and --json-one-line options These are currently only used in 'config show' to return the output in the given format, but are made as global options to be available whenever an output of that kind is desired. --- .../utils_cli/_utils/global_settings.py | 40 +++++++++++++++++++ dlite_entities_service/utils_cli/config.py | 23 +++++++++-- 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/dlite_entities_service/utils_cli/_utils/global_settings.py b/dlite_entities_service/utils_cli/_utils/global_settings.py index 726cc976..d0a88acd 100644 --- a/dlite_entities_service/utils_cli/_utils/global_settings.py +++ b/dlite_entities_service/utils_cli/_utils/global_settings.py @@ -42,6 +42,46 @@ def global_options( is_flag=True, rich_help_panel="Global options", ), + as_json: bool = typer.Option( + False, + "--json", + help=( + "Print output as JSON. (Muting mutually exclusive with --yaml/--yml and " + "--json-one-line.)" + ), + is_flag=True, + rich_help_panel="Global options", + ), + as_json_one_line: bool = typer.Option( + False, + "--json-one-line", + help=( + "Print output as JSON without new lines. (Muting mutually exclusive with " + "--yaml/--yml and --json.)" + ), + is_flag=True, + rich_help_panel="Global options", + ), + as_yaml: bool = typer.Option( + False, + "--yaml", + "--yml", + help=( + "Print output as YAML. (Mutually exclusive with --json and " + "--json-one-line.)" + ), + is_flag=True, + rich_help_panel="Global options", + ), ) -> None: """Global options for the CLI.""" STATUS["use_service_dotenv"] = use_service_dotenv + + if sum(int(_) for _ in [as_json, as_json_one_line, as_yaml]) > 1: + raise typer.BadParameter( + "Cannot use --json, --yaml/--yml, and --json-one-line together in any " + "combination." + ) + STATUS["as_json"] = as_json + STATUS["as_json_one_line"] = as_json_one_line + STATUS["as_yaml"] = as_yaml diff --git a/dlite_entities_service/utils_cli/config.py b/dlite_entities_service/utils_cli/config.py index 0a434b12..67f94b9d 100644 --- a/dlite_entities_service/utils_cli/config.py +++ b/dlite_entities_service/utils_cli/config.py @@ -12,16 +12,19 @@ f"'pip install {Path(__file__).resolve().parent.parent.parent.resolve()}[cli]'" ) from exc +import yaml from dotenv import dotenv_values, set_key, unset_key -from rich import print # pylint: disable=redefined-builtin +from rich import print, print_json # pylint: disable=redefined-builtin from rich.console import Console from dlite_entities_service.service.config import CONFIG from dlite_entities_service.utils_cli._utils.global_settings import STATUS ERROR_CONSOLE = Console(stderr=True) -CLI_DOTENV_FILE = Path(__file__).resolve().parent / CONFIG.model_config["env_file"] -SERVICE_DOTENV_FILE = ( +CLI_DOTENV_FILE: Path = ( + Path(__file__).resolve().parent / CONFIG.model_config["env_file"] +) +SERVICE_DOTENV_FILE: Path = ( Path(__file__).resolve().parent.parent.parent / CONFIG.model_config["env_file"] ) @@ -158,6 +161,8 @@ def show( else: dotenv_file = CLI_DOTENV_FILE if dotenv_file.exists(): + if not any(STATUS[_] for _ in ["as_json", "as_json_one_line", "as_yaml"]): + print(f"Current configuration in {dotenv_file}:\n") values = { ConfigFields(key[len(CONFIG.model_config["env_prefix"]) :]): value for key, value in dotenv_values(dotenv_file).items() @@ -171,7 +176,17 @@ def show( ERROR_CONSOLE.print(f"No {dotenv_file} file found.") raise typer.Exit(1) + output = {} for key, value in values.items(): if not reveal_sensitive and key.is_sensitive(): value = "*" * 8 - print(f"[bold]{CONFIG.model_config['env_prefix']}{key}[/bold]: {value}") + output[f"{CONFIG.model_config['env_prefix']}{key}"] = value + + if STATUS["as_json"] or STATUS["as_json_one_line"]: + print_json(data=output, indent=2 if STATUS["as_json"] else None) + elif STATUS["as_yaml"]: + print(yaml.safe_dump(output, sort_keys=False, allow_unicode=True)) + else: + print( + "\n".join(f"[bold]{key}[/bold]: {value}" for key, value in output.items()) + ) From e77ff9cfe42bfa56b98c6efc3165b1213394d8d3 Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Fri, 25 Aug 2023 11:36:09 +0200 Subject: [PATCH 28/39] Description clarification --- dlite_entities_service/utils_cli/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlite_entities_service/utils_cli/main.py b/dlite_entities_service/utils_cli/main.py index a790b23c..e7abaca9 100644 --- a/dlite_entities_service/utils_cli/main.py +++ b/dlite_entities_service/utils_cli/main.py @@ -111,7 +111,7 @@ def upload( case_sensitive=False, ), ) -> None: - """Upload (local) DLite entities.""" + """Upload (local) DLite entities to a remote location.""" unique_filepaths = set(filepaths or []) directories = list(set(directories or [])) file_formats = list(set(file_formats or [])) From dc8c10bb3d427d2d28abc700de114a7eeb20429b Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Fri, 25 Aug 2023 15:23:10 +0200 Subject: [PATCH 29/39] Move around pytest fixtures --- .pre-commit-config.yaml | 2 +- tests/conftest.py | 10 ---------- tests/utils_cli/conftest.py | 35 ++++++++++++++++++++++++++++++++++ tests/utils_cli/test_upload.py | 29 +++------------------------- 4 files changed, 39 insertions(+), 37 deletions(-) create mode 100644 tests/utils_cli/conftest.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 436017f6..f0c7c13e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -52,7 +52,7 @@ repos: rev: v1.5.1 hooks: - id: mypy - exclude: ^docs/example/.*$$ + exclude: ^(docs/example|tests)/.*$ additional_dependencies: - pydantic>=2 - types-requests diff --git a/tests/conftest.py b/tests/conftest.py index 8d0b3bda..c85a11a2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,16 +6,6 @@ if TYPE_CHECKING: from pathlib import Path - from typer.testing import CliRunner - - -@pytest.fixture(scope="session") -def cli() -> "CliRunner": - """Fixture for CLI runner.""" - from typer.testing import CliRunner - - return CliRunner(mix_stderr=False) - @pytest.fixture(scope="session") def samples() -> "Path": diff --git a/tests/utils_cli/conftest.py b/tests/utils_cli/conftest.py new file mode 100644 index 00000000..1f8ff09f --- /dev/null +++ b/tests/utils_cli/conftest.py @@ -0,0 +1,35 @@ +"""Fixtures for the utils_cli tests.""" +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from pymongo.collection import Collection + from typer.testing import CliRunner + + +@pytest.fixture(scope="session") +def cli() -> "CliRunner": + """Fixture for CLI runner.""" + from typer.testing import CliRunner + + return CliRunner(mix_stderr=False) + + +@pytest.fixture +def mock_entities_collection(monkeypatch: pytest.MonkeyPatch) -> "Collection": + """Return a mock entities collection.""" + from mongomock import MongoClient + + from dlite_entities_service.service.config import CONFIG + from dlite_entities_service.utils_cli import main + + mongo_client = MongoClient(str(CONFIG.mongo_uri)) + mock_entities_collection = mongo_client["dlite"]["entities"] + + monkeypatch.setattr(main, "ENTITIES_COLLECTION", mock_entities_collection) + monkeypatch.setattr( + main, "get_collection", lambda *args, **kwargs: mock_entities_collection + ) + + return mock_entities_collection diff --git a/tests/utils_cli/test_upload.py b/tests/utils_cli/test_upload.py index 53960a17..92b120e2 100644 --- a/tests/utils_cli/test_upload.py +++ b/tests/utils_cli/test_upload.py @@ -1,12 +1,11 @@ """Tests for `entities-service upload` CLI command.""" from typing import TYPE_CHECKING -import pytest - if TYPE_CHECKING: from pathlib import Path from typing import Any + from pymongo.collection import Collection from typer.testing import CliRunner @@ -22,24 +21,13 @@ def test_upload_no_args(cli: "CliRunner") -> None: def test_upload_filepath( - cli: "CliRunner", samples: "Path", monkeypatch: pytest.MonkeyPatch + cli: "CliRunner", samples: "Path", mock_entities_collection: "Collection" ) -> None: """Test upload with a filepath.""" import json - from mongomock import MongoClient - - from dlite_entities_service.service.config import CONFIG from dlite_entities_service.utils_cli import main - mongo_client = MongoClient(str(CONFIG.mongo_uri)) - mock_entities_collection = mongo_client["dlite"]["entities"] - - monkeypatch.setattr(main, "ENTITIES_COLLECTION", mock_entities_collection) - monkeypatch.setattr( - main, "get_collection", lambda *args, **kwargs: mock_entities_collection - ) - result = cli.invoke( main.APP, f"upload --file {samples / 'valid_entities' / 'Person.json'}" ) @@ -90,24 +78,13 @@ def test_upload_no_file_or_dir(cli: "CliRunner") -> None: def test_upload_directory( - cli: "CliRunner", samples: "Path", monkeypatch: pytest.MonkeyPatch + cli: "CliRunner", samples: "Path", mock_entities_collection: "Collection" ) -> None: """Test upload with a directory.""" import json - from mongomock import MongoClient - - from dlite_entities_service.service.config import CONFIG from dlite_entities_service.utils_cli import main - mongo_client = MongoClient(str(CONFIG.mongo_uri)) - mock_entities_collection = mongo_client["dlite"]["entities"] - - monkeypatch.setattr(main, "ENTITIES_COLLECTION", mock_entities_collection) - monkeypatch.setattr( - main, "get_collection", lambda *args, **kwargs: mock_entities_collection - ) - result = cli.invoke(main.APP, f"upload --dir {samples / 'valid_entities'}") assert result.exit_code == 0 From c4a90df6bad4e84f09263bf54979110bd9eb4a55 Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Fri, 27 Oct 2023 15:45:07 +0200 Subject: [PATCH 30/39] Fix imports --- dlite_entities_service/models/soft5.py | 2 +- dlite_entities_service/utils_cli/main.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/dlite_entities_service/models/soft5.py b/dlite_entities_service/models/soft5.py index 2a761cfe..1896e87f 100644 --- a/dlite_entities_service/models/soft5.py +++ b/dlite_entities_service/models/soft5.py @@ -5,7 +5,7 @@ from pydantic import BaseModel, Field, field_validator, model_validator from pydantic.networks import AnyHttpUrl -from dlite_entities_service.config import CONFIG +from dlite_entities_service.service.config import CONFIG class SOFT5Dimension(BaseModel): diff --git a/dlite_entities_service/utils_cli/main.py b/dlite_entities_service/utils_cli/main.py index e7abaca9..fbe8ba06 100644 --- a/dlite_entities_service/utils_cli/main.py +++ b/dlite_entities_service/utils_cli/main.py @@ -20,7 +20,6 @@ from rich import print # pylint: disable=redefined-builtin from rich.console import Console -from dlite_entities_service import __version__ from dlite_entities_service.service.backend import ( ENTITIES_COLLECTION, AnyWriteError, From aa86225e131c39abafd7e353da369f4f0b411ce9 Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Wed, 22 Nov 2023 15:28:02 +0100 Subject: [PATCH 31/39] Use typing.Annotated and make the CLI run --- .../utils_cli/_utils/global_settings.py | 108 ++++++++++-------- dlite_entities_service/utils_cli/config.py | 12 +- dlite_entities_service/utils_cli/main.py | 46 ++++++-- 3 files changed, 107 insertions(+), 59 deletions(-) diff --git a/dlite_entities_service/utils_cli/_utils/global_settings.py b/dlite_entities_service/utils_cli/_utils/global_settings.py index 64404229..1556a9ef 100644 --- a/dlite_entities_service/utils_cli/_utils/global_settings.py +++ b/dlite_entities_service/utils_cli/_utils/global_settings.py @@ -2,6 +2,7 @@ from __future__ import annotations from pathlib import Path +from typing import Annotated, Optional try: import typer @@ -17,6 +18,9 @@ STATUS = {"use_service_dotenv": False} +# Type Aliases +OptionalBool = Optional[bool] + def print_version(value: bool) -> None: """Print version and exit.""" @@ -26,55 +30,69 @@ def print_version(value: bool) -> None: def global_options( - _: bool - | None = typer.Option( - None, - "--version", - help="Show version and exit", - is_eager=True, - callback=print_version, - ), - use_service_dotenv: bool = typer.Option( - False, - "--use-service-dotenv/--use-cli-dotenv", - help=( - "Use the .env file also used for the DLite Entities Service or one only " - "for the CLI." + _: Annotated[ + OptionalBool, + typer.Option( + None, + "--version", + help="Show version and exit", + is_eager=True, + callback=print_version, + ), + ] = None, + use_service_dotenv: Annotated[ + bool, + typer.Option( + False, + "--use-service-dotenv/--use-cli-dotenv", + help=( + "Use the .env file also used for the DLite Entities Service or one " + "only for the CLI." + ), + is_flag=True, + rich_help_panel="Global options", ), - is_flag=True, - rich_help_panel="Global options", - ), - as_json: bool = typer.Option( - False, - "--json", - help=( - "Print output as JSON. (Muting mutually exclusive with --yaml/--yml and " - "--json-one-line.)" + ] = False, + as_json: Annotated[ + bool, + typer.Option( + False, + "--json", + help=( + "Print output as JSON. (Muting mutually exclusive with --yaml/--yml " + "and --json-one-line.)" + ), + is_flag=True, + rich_help_panel="Global options", ), - is_flag=True, - rich_help_panel="Global options", - ), - as_json_one_line: bool = typer.Option( - False, - "--json-one-line", - help=( - "Print output as JSON without new lines. (Muting mutually exclusive with " - "--yaml/--yml and --json.)" + ] = False, + as_json_one_line: Annotated[ + bool, + typer.Option( + False, + "--json-one-line", + help=( + "Print output as JSON without new lines. (Muting mutually exclusive " + "with --yaml/--yml and --json.)" + ), + is_flag=True, + rich_help_panel="Global options", ), - is_flag=True, - rich_help_panel="Global options", - ), - as_yaml: bool = typer.Option( - False, - "--yaml", - "--yml", - help=( - "Print output as YAML. (Mutually exclusive with --json and " - "--json-one-line.)" + ] = False, + as_yaml: Annotated[ + bool, + typer.Option( + False, + "--yaml", + "--yml", + help=( + "Print output as YAML. (Mutually exclusive with --json and " + "--json-one-line.)" + ), + is_flag=True, + rich_help_panel="Global options", ), - is_flag=True, - rich_help_panel="Global options", - ), + ] = False, ) -> None: """Global options for the CLI.""" STATUS["use_service_dotenv"] = use_service_dotenv diff --git a/dlite_entities_service/utils_cli/config.py b/dlite_entities_service/utils_cli/config.py index 3fe8b717..65e6fac5 100644 --- a/dlite_entities_service/utils_cli/config.py +++ b/dlite_entities_service/utils_cli/config.py @@ -5,7 +5,7 @@ from collections.abc import Generator from enum import Enum from pathlib import Path -from typing import Annotated +from typing import Annotated, Optional try: import typer @@ -63,11 +63,16 @@ def is_sensitive(self) -> bool: return self in [ConfigFields.MONGO_PASSWORD] +# Type Aliases +OptionalStr = Optional[str] + + @APP.command(name="set") def set_config( key: Annotated[ ConfigFields, typer.Argument( + ..., help=( "Configuration option to set. These can also be set as an environment " f"variable by prefixing with {CONFIG.model_config['env_prefix']!r}." @@ -81,8 +86,9 @@ def set_config( ), ], value: Annotated[ - str | None, + OptionalStr, typer.Argument( + None, help=( "Value to set. For sensitive values, this will be prompted for if not " "provided." @@ -113,6 +119,7 @@ def unset( key: Annotated[ ConfigFields, typer.Argument( + ..., help="Configuration option to unset.", show_choices=True, # Start using shell_complete once tiangolo/typer#334 is resolved. @@ -159,6 +166,7 @@ def show( reveal_sensitive: Annotated[ bool, typer.Option( + False, "--reveal-sensitive", help="Reveal sensitive values. (DANGEROUS! Use with caution.)", is_flag=True, diff --git a/dlite_entities_service/utils_cli/main.py b/dlite_entities_service/utils_cli/main.py index 8aed6ceb..6d235f71 100644 --- a/dlite_entities_service/utils_cli/main.py +++ b/dlite_entities_service/utils_cli/main.py @@ -6,7 +6,7 @@ import os from enum import Enum from pathlib import Path -from typing import TYPE_CHECKING, Annotated +from typing import TYPE_CHECKING, Annotated, Optional try: import typer @@ -47,6 +47,13 @@ class EntityFileFormats(str, Enum): YML = "yml" +# Type Aliases +OptionalListPath = Optional[list[Path]] +OptionalListEntityFileFormats = Optional[list[EntityFileFormats]] +OptionalListStr = Optional[list[str]] +OptionalStr = Optional[str] + + APP = typer.Typer( name="entities-service", help="DLite entities service utility CLI", @@ -76,8 +83,9 @@ def _get_backend() -> Collection: @APP.command(no_args_is_help=True) def upload( filepaths: Annotated[ - list[Path] | None, + OptionalListPath, typer.Option( + None, "--file", "-f", exists=True, @@ -90,8 +98,9 @@ def upload( ), ] = None, directories: Annotated[ - list[Path] | None, + OptionalListPath, typer.Option( + None, "--dir", "-d", exists=True, @@ -108,8 +117,9 @@ def upload( ), ] = None, file_formats: Annotated[ - list[EntityFileFormats] | None, + OptionalListEntityFileFormats, typer.Option( + [EntityFileFormats.JSON], "--format", help="Format of DLite entity file(s).", show_choices=True, @@ -206,8 +216,9 @@ def iterate() -> None: @APP.command(no_args_is_help=True) def update( filepaths: Annotated[ - list[Path] | None, + OptionalListPath, typer.Option( + None, "--file", "-f", exists=True, @@ -220,8 +231,9 @@ def update( ), ] = None, directories: Annotated[ - list[Path] | None, + OptionalListPath, typer.Option( + None, "--dir", "-d", exists=True, @@ -238,8 +250,9 @@ def update( ), ] = None, file_formats: Annotated[ - list[EntityFileFormats] | None, + OptionalListEntityFileFormats, typer.Option( + [EntityFileFormats.JSON], "--format", help="Format of DLite entity file(s).", show_choices=True, @@ -252,6 +265,7 @@ def update( insert: Annotated[ bool, typer.Option( + False, "--insert", "-i", help="Insert the entity if it does not exist yet.", @@ -354,6 +368,7 @@ def delete( uri: Annotated[ str, typer.Argument( + ..., help="URI of the DLite entity to delete.", show_default=False, ), @@ -384,6 +399,7 @@ def get( uri: Annotated[ str, typer.Argument( + ..., help="URI of the DLite entity to get.", show_default=False, ), @@ -407,8 +423,9 @@ def get( @APP.command(no_args_is_help=True) def search( uris: Annotated[ - list[str] | None, + OptionalListStr, typer.Argument( + None, metavar="[URI]...", help=( "URI of the DLite entity to search for. Multiple URIs can be provided. " @@ -418,8 +435,9 @@ def search( ), ] = None, query: Annotated[ - str | None, + OptionalStr, typer.Option( + None, "--query", "-q", help="Backend-specific query to search for DLite entities.", @@ -429,6 +447,7 @@ def search( as_json: Annotated[ bool, typer.Option( + False, "--json", "-j", help="Return the search results as JSON.", @@ -483,8 +502,9 @@ def search( @APP.command(no_args_is_help=True) def validate( filepaths: Annotated[ - list[Path] | None, + OptionalListPath, typer.Option( + None, "--file", "-f", exists=True, @@ -497,8 +517,9 @@ def validate( ), ] = None, directories: Annotated[ - list[Path] | None, + OptionalListPath, typer.Option( + None, "--dir", "-d", exists=True, @@ -515,8 +536,9 @@ def validate( ), ] = None, file_formats: Annotated[ - list[EntityFileFormats] | None, + OptionalListEntityFileFormats, typer.Option( + [EntityFileFormats.JSON], "--format", help="Format of DLite entity file(s).", show_choices=True, From 5ad86392c25a3ed73b3873bf365118cfac5bbe14 Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Wed, 22 Nov 2023 15:56:25 +0100 Subject: [PATCH 32/39] Attempt fixing typer There's an issue that the Option aliases are not being read. --- .../utils_cli/_utils/global_settings.py | 5 ----- dlite_entities_service/utils_cli/config.py | 4 ---- dlite_entities_service/utils_cli/main.py | 14 -------------- pyproject.toml | 6 ++---- tests/conftest.py | 2 +- tests/utils_cli/test_upload.py | 2 +- 6 files changed, 4 insertions(+), 29 deletions(-) diff --git a/dlite_entities_service/utils_cli/_utils/global_settings.py b/dlite_entities_service/utils_cli/_utils/global_settings.py index 1556a9ef..2b42bf5e 100644 --- a/dlite_entities_service/utils_cli/_utils/global_settings.py +++ b/dlite_entities_service/utils_cli/_utils/global_settings.py @@ -33,7 +33,6 @@ def global_options( _: Annotated[ OptionalBool, typer.Option( - None, "--version", help="Show version and exit", is_eager=True, @@ -43,7 +42,6 @@ def global_options( use_service_dotenv: Annotated[ bool, typer.Option( - False, "--use-service-dotenv/--use-cli-dotenv", help=( "Use the .env file also used for the DLite Entities Service or one " @@ -56,7 +54,6 @@ def global_options( as_json: Annotated[ bool, typer.Option( - False, "--json", help=( "Print output as JSON. (Muting mutually exclusive with --yaml/--yml " @@ -69,7 +66,6 @@ def global_options( as_json_one_line: Annotated[ bool, typer.Option( - False, "--json-one-line", help=( "Print output as JSON without new lines. (Muting mutually exclusive " @@ -82,7 +78,6 @@ def global_options( as_yaml: Annotated[ bool, typer.Option( - False, "--yaml", "--yml", help=( diff --git a/dlite_entities_service/utils_cli/config.py b/dlite_entities_service/utils_cli/config.py index 65e6fac5..45aa9f19 100644 --- a/dlite_entities_service/utils_cli/config.py +++ b/dlite_entities_service/utils_cli/config.py @@ -72,7 +72,6 @@ def set_config( key: Annotated[ ConfigFields, typer.Argument( - ..., help=( "Configuration option to set. These can also be set as an environment " f"variable by prefixing with {CONFIG.model_config['env_prefix']!r}." @@ -88,7 +87,6 @@ def set_config( value: Annotated[ OptionalStr, typer.Argument( - None, help=( "Value to set. For sensitive values, this will be prompted for if not " "provided." @@ -119,7 +117,6 @@ def unset( key: Annotated[ ConfigFields, typer.Argument( - ..., help="Configuration option to unset.", show_choices=True, # Start using shell_complete once tiangolo/typer#334 is resolved. @@ -166,7 +163,6 @@ def show( reveal_sensitive: Annotated[ bool, typer.Option( - False, "--reveal-sensitive", help="Reveal sensitive values. (DANGEROUS! Use with caution.)", is_flag=True, diff --git a/dlite_entities_service/utils_cli/main.py b/dlite_entities_service/utils_cli/main.py index 6d235f71..f4fc36f4 100644 --- a/dlite_entities_service/utils_cli/main.py +++ b/dlite_entities_service/utils_cli/main.py @@ -100,7 +100,6 @@ def upload( directories: Annotated[ OptionalListPath, typer.Option( - None, "--dir", "-d", exists=True, @@ -119,7 +118,6 @@ def upload( file_formats: Annotated[ OptionalListEntityFileFormats, typer.Option( - [EntityFileFormats.JSON], "--format", help="Format of DLite entity file(s).", show_choices=True, @@ -218,7 +216,6 @@ def update( filepaths: Annotated[ OptionalListPath, typer.Option( - None, "--file", "-f", exists=True, @@ -233,7 +230,6 @@ def update( directories: Annotated[ OptionalListPath, typer.Option( - None, "--dir", "-d", exists=True, @@ -252,7 +248,6 @@ def update( file_formats: Annotated[ OptionalListEntityFileFormats, typer.Option( - [EntityFileFormats.JSON], "--format", help="Format of DLite entity file(s).", show_choices=True, @@ -265,7 +260,6 @@ def update( insert: Annotated[ bool, typer.Option( - False, "--insert", "-i", help="Insert the entity if it does not exist yet.", @@ -368,7 +362,6 @@ def delete( uri: Annotated[ str, typer.Argument( - ..., help="URI of the DLite entity to delete.", show_default=False, ), @@ -399,7 +392,6 @@ def get( uri: Annotated[ str, typer.Argument( - ..., help="URI of the DLite entity to get.", show_default=False, ), @@ -425,7 +417,6 @@ def search( uris: Annotated[ OptionalListStr, typer.Argument( - None, metavar="[URI]...", help=( "URI of the DLite entity to search for. Multiple URIs can be provided. " @@ -437,7 +428,6 @@ def search( query: Annotated[ OptionalStr, typer.Option( - None, "--query", "-q", help="Backend-specific query to search for DLite entities.", @@ -447,7 +437,6 @@ def search( as_json: Annotated[ bool, typer.Option( - False, "--json", "-j", help="Return the search results as JSON.", @@ -504,7 +493,6 @@ def validate( filepaths: Annotated[ OptionalListPath, typer.Option( - None, "--file", "-f", exists=True, @@ -519,7 +507,6 @@ def validate( directories: Annotated[ OptionalListPath, typer.Option( - None, "--dir", "-d", exists=True, @@ -538,7 +525,6 @@ def validate( file_formats: Annotated[ OptionalListEntityFileFormats, typer.Option( - [EntityFileFormats.JSON], "--format", help="Format of DLite entity file(s).", show_choices=True, diff --git a/pyproject.toml b/pyproject.toml index 023a757e..e94f5d37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,13 +42,11 @@ testing = [ "pytest-cov ~=4.1", ] cli = [ - "DLite-Python ~=0.3.22", - "typer[all] ~=0.7.0", + "DLite-Python ~=0.4.5", + "typer[all] ~=0.9.0", ] dev = [ "pre-commit ~=3.5", - "pylint ~=3.0", - "pylint-pydantic ~=0.3.0", "dlite-entities-service[testing,cli]", ] diff --git a/tests/conftest.py b/tests/conftest.py index 481b4df2..c395f960 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,4 +14,4 @@ def samples() -> Path: """Fixture for samples directory.""" from pathlib import Path - return Path(__file__).resolve().parent / "samples" + return (Path(__file__).resolve().parent / "samples").resolve() diff --git a/tests/utils_cli/test_upload.py b/tests/utils_cli/test_upload.py index 19849eee..62452b16 100644 --- a/tests/utils_cli/test_upload.py +++ b/tests/utils_cli/test_upload.py @@ -33,7 +33,7 @@ def test_upload_filepath( result = cli.invoke( main.APP, f"upload --file {samples / 'valid_entities' / 'Person.json'}" ) - assert result.exit_code == 0 + assert result.exit_code == 0, result.stderr assert mock_entities_collection.count_documents({}) == 1 stored_entity: dict[str, Any] = mock_entities_collection.find_one({}) From 3a5be34a80835da8e878f121ae1322998d33ab58 Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Thu, 23 Nov 2023 10:31:55 +0100 Subject: [PATCH 33/39] Revert using Annotated for typer It fails to parse the metadata of Option and Argument. Also, avoid using Python 3.10-style typing, since typer cannot handle it (X | Y). Indeed, to ensure pyupgrade doesn't change it automatically type aliases are created for all occurrences. --- .../utils_cli/_utils/global_settings.py | 100 +++--- dlite_entities_service/utils_cli/config.py | 88 +++-- dlite_entities_service/utils_cli/main.py | 331 ++++++++---------- pyproject.toml | 1 + 4 files changed, 231 insertions(+), 289 deletions(-) diff --git a/dlite_entities_service/utils_cli/_utils/global_settings.py b/dlite_entities_service/utils_cli/_utils/global_settings.py index 2b42bf5e..350e20b0 100644 --- a/dlite_entities_service/utils_cli/_utils/global_settings.py +++ b/dlite_entities_service/utils_cli/_utils/global_settings.py @@ -2,7 +2,7 @@ from __future__ import annotations from pathlib import Path -from typing import Annotated, Optional +from typing import Optional try: import typer @@ -30,64 +30,54 @@ def print_version(value: bool) -> None: def global_options( - _: Annotated[ - OptionalBool, - typer.Option( - "--version", - help="Show version and exit", - is_eager=True, - callback=print_version, + _: OptionalBool = typer.Option( + None, + "--version", + help="Show version and exit", + is_eager=True, + callback=print_version, + ), + use_service_dotenv: bool = typer.Option( + False, + "--use-service-dotenv/--use-cli-dotenv", + help=( + "Use the .env file also used for the DLite Entities Service or one " + "only for the CLI." ), - ] = None, - use_service_dotenv: Annotated[ - bool, - typer.Option( - "--use-service-dotenv/--use-cli-dotenv", - help=( - "Use the .env file also used for the DLite Entities Service or one " - "only for the CLI." - ), - is_flag=True, - rich_help_panel="Global options", + is_flag=True, + rich_help_panel="Global options", + ), + as_json: bool = typer.Option( + False, + "--json", + help=( + "Print output as JSON. (Muting mutually exclusive with --yaml/--yml " + "and --json-one-line.)" ), - ] = False, - as_json: Annotated[ - bool, - typer.Option( - "--json", - help=( - "Print output as JSON. (Muting mutually exclusive with --yaml/--yml " - "and --json-one-line.)" - ), - is_flag=True, - rich_help_panel="Global options", + is_flag=True, + rich_help_panel="Global options", + ), + as_json_one_line: bool = typer.Option( + False, + "--json-one-line", + help=( + "Print output as JSON without new lines. (Muting mutually exclusive " + "with --yaml/--yml and --json.)" ), - ] = False, - as_json_one_line: Annotated[ - bool, - typer.Option( - "--json-one-line", - help=( - "Print output as JSON without new lines. (Muting mutually exclusive " - "with --yaml/--yml and --json.)" - ), - is_flag=True, - rich_help_panel="Global options", + is_flag=True, + rich_help_panel="Global options", + ), + as_yaml: bool = typer.Option( + False, + "--yaml", + "--yml", + help=( + "Print output as YAML. (Mutually exclusive with --json and " + "--json-one-line.)" ), - ] = False, - as_yaml: Annotated[ - bool, - typer.Option( - "--yaml", - "--yml", - help=( - "Print output as YAML. (Mutually exclusive with --json and " - "--json-one-line.)" - ), - is_flag=True, - rich_help_panel="Global options", - ), - ] = False, + is_flag=True, + rich_help_panel="Global options", + ), ) -> None: """Global options for the CLI.""" STATUS["use_service_dotenv"] = use_service_dotenv diff --git a/dlite_entities_service/utils_cli/config.py b/dlite_entities_service/utils_cli/config.py index 45aa9f19..5d2c5aa2 100644 --- a/dlite_entities_service/utils_cli/config.py +++ b/dlite_entities_service/utils_cli/config.py @@ -5,7 +5,7 @@ from collections.abc import Generator from enum import Enum from pathlib import Path -from typing import Annotated, Optional +from typing import Optional try: import typer @@ -38,6 +38,9 @@ invoke_without_command=True, ) +# Type Aliases +OptionalStr = Optional[str] + class ConfigFields(str, Enum): """Configuration options.""" @@ -63,37 +66,28 @@ def is_sensitive(self) -> bool: return self in [ConfigFields.MONGO_PASSWORD] -# Type Aliases -OptionalStr = Optional[str] - - @APP.command(name="set") def set_config( - key: Annotated[ - ConfigFields, - typer.Argument( - help=( - "Configuration option to set. These can also be set as an environment " - f"variable by prefixing with {CONFIG.model_config['env_prefix']!r}." - ), - show_choices=True, - # Start using shell_complete once tiangolo/typer#334 is resolved. - # shell_complete=ConfigFields.autocomplete, - autocompletion=ConfigFields.autocomplete, - case_sensitive=False, - show_default=False, + key: ConfigFields = typer.Argument( + help=( + "Configuration option to set. These can also be set as an environment " + f"variable by prefixing with {CONFIG.model_config['env_prefix']!r}." ), - ], - value: Annotated[ - OptionalStr, - typer.Argument( - help=( - "Value to set. For sensitive values, this will be prompted for if not " - "provided." - ), - show_default=False, + show_choices=True, + # Start using shell_complete once tiangolo/typer#334 is resolved. + # shell_complete=ConfigFields.autocomplete, + autocompletion=ConfigFields.autocomplete, + case_sensitive=False, + show_default=False, + ), + value: OptionalStr = typer.Argument( + None, + help=( + "Value to set. For sensitive values, this will be prompted for if not " + "provided." ), - ] = None, + show_default=False, + ), ) -> None: """Set a configuration option.""" if not value: @@ -114,18 +108,16 @@ def set_config( @APP.command() def unset( - key: Annotated[ - ConfigFields, - typer.Argument( - help="Configuration option to unset.", - show_choices=True, - # Start using shell_complete once tiangolo/typer#334 is resolved. - # shell_complete=ConfigFields.autocomplete, - autocompletion=ConfigFields.autocomplete, - case_sensitive=False, - show_default=False, - ), - ] + key: ConfigFields = typer.Argument( + None, + help="Configuration option to unset.", + show_choices=True, + # Start using shell_complete once tiangolo/typer#334 is resolved. + # shell_complete=ConfigFields.autocomplete, + autocompletion=ConfigFields.autocomplete, + case_sensitive=False, + show_default=False, + ), ) -> None: """Unset a single configuration option.""" if STATUS["use_service_dotenv"]: @@ -160,15 +152,13 @@ def unset_all() -> None: @APP.command() def show( - reveal_sensitive: Annotated[ - bool, - typer.Option( - "--reveal-sensitive", - help="Reveal sensitive values. (DANGEROUS! Use with caution.)", - is_flag=True, - show_default=False, - ), - ] = False, + reveal_sensitive: bool = typer.Option( + False, + "--reveal-sensitive", + help="Reveal sensitive values. (DANGEROUS! Use with caution.)", + is_flag=True, + show_default=False, + ), ) -> None: """Show the current configuration.""" if STATUS["use_service_dotenv"]: diff --git a/dlite_entities_service/utils_cli/main.py b/dlite_entities_service/utils_cli/main.py index f4fc36f4..e829a108 100644 --- a/dlite_entities_service/utils_cli/main.py +++ b/dlite_entities_service/utils_cli/main.py @@ -6,7 +6,7 @@ import os from enum import Enum from pathlib import Path -from typing import TYPE_CHECKING, Annotated, Optional +from typing import TYPE_CHECKING, Optional try: import typer @@ -48,9 +48,9 @@ class EntityFileFormats(str, Enum): # Type Aliases -OptionalListPath = Optional[list[Path]] OptionalListEntityFileFormats = Optional[list[EntityFileFormats]] OptionalListStr = Optional[list[str]] +OptionalListPath = Optional[list[Path]] OptionalStr = Optional[str] @@ -82,51 +82,42 @@ def _get_backend() -> Collection: @APP.command(no_args_is_help=True) def upload( - filepaths: Annotated[ - OptionalListPath, - typer.Option( - None, - "--file", - "-f", - exists=True, - file_okay=True, - dir_okay=False, - readable=True, - resolve_path=True, - help="Path to DLite entity file.", - show_default=False, - ), - ] = None, - directories: Annotated[ - OptionalListPath, - typer.Option( - "--dir", - "-d", - exists=True, - file_okay=False, - dir_okay=True, - readable=True, - resolve_path=True, - help=( - "Path to directory with DLite entities. All files matching the given " - "format(s) in the directory will be uploaded. " - "Subdirectories will be ignored." - ), - show_default=False, - ), - ] = None, - file_formats: Annotated[ - OptionalListEntityFileFormats, - typer.Option( - "--format", - help="Format of DLite entity file(s).", - show_choices=True, - show_default=True, - case_sensitive=False, + filepaths: OptionalListPath = typer.Option( + None, + "--file", + "-f", + exists=True, + file_okay=True, + dir_okay=False, + readable=True, + resolve_path=True, + help="Path to DLite entity file.", + show_default=False, + ), + directories: OptionalListPath = typer.Option( + None, + "--dir", + "-d", + exists=True, + file_okay=False, + dir_okay=True, + readable=True, + resolve_path=True, + help=( + "Path to directory with DLite entities. All files matching the given " + "format(s) in the directory will be uploaded. " + "Subdirectories will be ignored." ), - ] = [ # noqa: B006 - EntityFileFormats.JSON - ], + show_default=False, + ), + file_formats: OptionalListEntityFileFormats = typer.Option( + [EntityFileFormats.JSON], + "--format", + help="Format of DLite entity file(s).", + show_choices=True, + show_default=True, + case_sensitive=False, + ), ) -> None: """Upload (local) DLite entities to a remote location.""" unique_filepaths = set(filepaths or []) @@ -213,60 +204,50 @@ def iterate() -> None: @APP.command(no_args_is_help=True) def update( - filepaths: Annotated[ - OptionalListPath, - typer.Option( - "--file", - "-f", - exists=True, - file_okay=True, - dir_okay=False, - readable=True, - resolve_path=True, - help="Path to DLite entity file.", - show_default=False, - ), - ] = None, - directories: Annotated[ - OptionalListPath, - typer.Option( - "--dir", - "-d", - exists=True, - file_okay=False, - dir_okay=True, - readable=True, - resolve_path=True, - help=( - "Path to directory with DLite entities. All files matching the given " - "format(s) in the directory will be uploaded. " - "Subdirectories will be ignored." - ), - show_default=False, - ), - ] = None, - file_formats: Annotated[ - OptionalListEntityFileFormats, - typer.Option( - "--format", - help="Format of DLite entity file(s).", - show_choices=True, - show_default=True, - case_sensitive=False, - ), - ] = [ # noqa: B006 - EntityFileFormats.JSON - ], - insert: Annotated[ - bool, - typer.Option( - "--insert", - "-i", - help="Insert the entity if it does not exist yet.", - show_default=False, - is_flag=True, + filepaths: OptionalListPath = typer.Option( + None, + "--file", + "-f", + exists=True, + file_okay=True, + dir_okay=False, + readable=True, + resolve_path=True, + help="Path to DLite entity file.", + show_default=False, + ), + directories: OptionalListPath = typer.Option( + None, + "--dir", + "-d", + exists=True, + file_okay=False, + dir_okay=True, + readable=True, + resolve_path=True, + help=( + "Path to directory with DLite entities. All files matching the given " + "format(s) in the directory will be uploaded. " + "Subdirectories will be ignored." ), - ] = False, + show_default=False, + ), + file_formats: OptionalListEntityFileFormats = typer.Option( + [EntityFileFormats.JSON], + "--format", + help="Format of DLite entity file(s).", + show_choices=True, + show_default=True, + case_sensitive=False, + ), + insert: bool = typer.Option( + False, + "--insert", + "-i", + help="Insert the entity if it does not exist yet.", + show_default=False, + is_flag=True, + ), ) -> None: """Update an existing (remote) DLite entity.""" unique_filepaths = set(filepaths or []) @@ -359,13 +340,10 @@ def update( @APP.command(no_args_is_help=True) def delete( - uri: Annotated[ - str, - typer.Argument( - help="URI of the DLite entity to delete.", - show_default=False, - ), - ], + uri: str = typer.Argument( + help="URI of the DLite entity to delete.", + show_default=False, + ), ) -> None: """Delete an existing (remote) DLite entity.""" backend = _get_backend() @@ -389,13 +367,10 @@ def delete( @APP.command(no_args_is_help=True) def get( - uri: Annotated[ - str, - typer.Argument( - help="URI of the DLite entity to get.", - show_default=False, - ), - ], + uri: str = typer.Argument( + help="URI of the DLite entity to get.", + show_default=False, + ), ) -> None: """Get an existing (remote) DLite entity.""" backend = _get_backend() @@ -414,36 +389,30 @@ def get( @APP.command(no_args_is_help=True) def search( - uris: Annotated[ - OptionalListStr, - typer.Argument( - metavar="[URI]...", - help=( - "URI of the DLite entity to search for. Multiple URIs can be provided. " - "Note, the 'http://onto-ns.com/meta' prefix is optional." - ), - show_default=False, - ), - ] = None, - query: Annotated[ - OptionalStr, - typer.Option( - "--query", - "-q", - help="Backend-specific query to search for DLite entities.", - show_default=False, + uris: OptionalListStr = typer.Argument( + None, + metavar="[URI]...", + help=( + "URI of the DLite entity to search for. Multiple URIs can be provided. " + "Note, the 'http://onto-ns.com/meta' prefix is optional." ), - ] = None, - as_json: Annotated[ - bool, - typer.Option( - "--json", - "-j", - help="Return the search results as JSON.", - show_default=False, - is_flag=True, - ), - ] = False, + show_default=False, + ), + query: OptionalStr = typer.Option( + None, + "--query", + "-q", + help="Backend-specific query to search for DLite entities.", + show_default=False, + ), + as_json: bool = typer.Option( + False, + "--json", + "-j", + help="Return the search results as JSON.", + show_default=False, + is_flag=True, + ), ) -> None: """Search for (remote) DLite entities.""" backend = _get_backend() @@ -490,50 +459,42 @@ def search( @APP.command(no_args_is_help=True) def validate( - filepaths: Annotated[ - OptionalListPath, - typer.Option( - "--file", - "-f", - exists=True, - file_okay=True, - dir_okay=False, - readable=True, - resolve_path=True, - help="Path to DLite entity file.", - show_default=False, - ), - ] = None, - directories: Annotated[ - OptionalListPath, - typer.Option( - "--dir", - "-d", - exists=True, - file_okay=False, - dir_okay=True, - readable=True, - resolve_path=True, - help=( - "Path to directory with DLite entities. All files matching the given " - "format(s) in the directory will be validated. " - "Subdirectories will be ignored." - ), - show_default=False, - ), - ] = None, - file_formats: Annotated[ - OptionalListEntityFileFormats, - typer.Option( - "--format", - help="Format of DLite entity file(s).", - show_choices=True, - show_default=True, - case_sensitive=False, + filepaths: OptionalListPath = typer.Option( + None, + "--file", + "-f", + exists=True, + file_okay=True, + dir_okay=False, + readable=True, + resolve_path=True, + help="Path to DLite entity file.", + show_default=False, + ), + directories: OptionalListPath = typer.Option( + None, + "--dir", + "-d", + exists=True, + file_okay=False, + dir_okay=True, + readable=True, + resolve_path=True, + help=( + "Path to directory with DLite entities. All files matching the given " + "format(s) in the directory will be validated. " + "Subdirectories will be ignored." ), - ] = [ # noqa: B006 - EntityFileFormats.JSON - ], + show_default=False, + ), + file_formats: OptionalListEntityFileFormats = typer.Option( + [EntityFileFormats.JSON], + "--format", + help="Format of DLite entity file(s).", + show_choices=True, + show_default=True, + case_sensitive=False, + ), ) -> None: """Validate (local) DLite entities.""" unique_filepaths = set(filepaths or []) diff --git a/pyproject.toml b/pyproject.toml index e94f5d37..8650f252 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,6 +96,7 @@ extend-select = [ ] ignore = [ "PLR", # Design related pylint codes + "B008", # Performing function calls in argument defaults - done all the time in the CLI. ] isort.required-imports = ["from __future__ import annotations"] From 424df4c011888a6701157b765f81b6ab1d6b8af0 Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Thu, 23 Nov 2023 11:00:06 +0100 Subject: [PATCH 34/39] Fix tests after merge --- .github/workflows/ci_tests.yml | 2 +- tests/conftest.py | 8 ++++---- tests/static/entities.yaml | 4 ++-- tests/utils.py | 2 +- tests/utils_cli/test_upload.py | 10 +++++----- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci_tests.yml b/.github/workflows/ci_tests.yml index 65e178c5..1fea87a2 100644 --- a/.github/workflows/ci_tests.yml +++ b/.github/workflows/ci_tests.yml @@ -126,7 +126,7 @@ jobs: run: | python -m pip install -U pip pip install -U setuptools wheel flit - pip install -U -e .[testing,cli] + pip install -U -e .[testing] - name: Run pytest run: pytest -vv --cov-report=xml diff --git a/tests/conftest.py b/tests/conftest.py index 762e144f..1934e1f6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -59,7 +59,7 @@ def mongo_test_collection(static_dir: Path, live_backend: bool) -> Collection | import yaml if live_backend: - from dlite_entities_service.backend import ENTITIES_COLLECTION + from dlite_entities_service.service.backend import ENTITIES_COLLECTION # TODO: Handle authentication properly ENTITIES_COLLECTION.insert_many( @@ -71,7 +71,7 @@ def mongo_test_collection(static_dir: Path, live_backend: bool) -> Collection | # else from mongomock import MongoClient - from dlite_entities_service.config import CONFIG + from dlite_entities_service.service.config import CONFIG client_kwargs = { "username": CONFIG.mongo_user, @@ -101,7 +101,7 @@ def _mock_backend_entities_collection( if mongo_test_collection is None: return - from dlite_entities_service import backend + from dlite_entities_service.service import backend monkeypatch.setattr(backend, "ENTITIES_COLLECTION", mongo_test_collection) @@ -113,8 +113,8 @@ def client(live_backend: bool) -> TestClient: from fastapi.testclient import TestClient - from dlite_entities_service.config import CONFIG from dlite_entities_service.main import APP + from dlite_entities_service.service.config import CONFIG if live_backend: host, port = os.getenv("ENTITY_SERVICE_HOST", "localhost"), os.getenv( diff --git a/tests/static/entities.yaml b/tests/static/entities.yaml index fde7d070..f705b156 100644 --- a/tests/static/entities.yaml +++ b/tests/static/entities.yaml @@ -17,7 +17,7 @@ type: string - description: A cat. - uri: http://onto-ns.com/meta/0.1/Cat + uri: http://onto-ns.com/meta/0.2/Cat meta: http://onto-ns.com/meta/0.3/EntitySchema dimensions: ncolors: Number of different colors the cat has. @@ -40,7 +40,7 @@ # SOFT5 example - description: A dog. namespace: http://onto-ns.com/meta - version: '0.1' + version: '0.2' name: Dog dimensions: - description: Number of different colors the dog has. diff --git a/tests/utils.py b/tests/utils.py index db01e234..3f122c16 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -19,7 +19,7 @@ def get_version_name(uri: str) -> tuple[str, str]: """Return the version and name part of a uri.""" import re - from dlite_entities_service.config import CONFIG + from dlite_entities_service.service.config import CONFIG namespace = str(CONFIG.base_url).rstrip("/") diff --git a/tests/utils_cli/test_upload.py b/tests/utils_cli/test_upload.py index a61aecd8..4c92a151 100644 --- a/tests/utils_cli/test_upload.py +++ b/tests/utils_cli/test_upload.py @@ -16,7 +16,7 @@ def test_upload_no_args(cli: CliRunner) -> None: from dlite_entities_service.utils_cli.main import APP, upload result = cli.invoke(APP, "upload") - assert result.exit_code == 0 + assert result.exit_code == 0, result.stderr assert upload.__doc__ in result.stdout assert result.stdout == cli.invoke(APP, "upload --help").stdout @@ -52,7 +52,7 @@ def test_upload_filepath_invalid(cli: CliRunner, static_dir: Path) -> None: result = cli.invoke( APP, f"upload --file {static_dir / 'invalid_entities' / 'Person.json'}" ) - assert result.exit_code == 1 + assert result.exit_code == 1, result.stdout assert "cannot be loaded with DLite." in result.stderr assert not result.stdout @@ -64,7 +64,7 @@ def test_upload_filepath_invalid_format(cli: CliRunner, tmp_path: Path) -> None: (tmp_path / "Person.txt").touch() result = cli.invoke(APP, f"upload --file {tmp_path / 'Person.txt'}") - assert result.exit_code == 0 + assert result.exit_code == 0, result.stderr assert "File format 'txt' is not supported." in result.stderr assert "No entities were uploaded." in result.stdout @@ -74,7 +74,7 @@ def test_upload_no_file_or_dir(cli: CliRunner) -> None: from dlite_entities_service.utils_cli.main import APP result = cli.invoke(APP, "upload --format json") - assert result.exit_code == 1 + assert result.exit_code == 1, result.stdout assert "Missing either option '--file' / '-f'" in result.stderr assert not result.stdout @@ -88,7 +88,7 @@ def test_upload_directory( from dlite_entities_service.utils_cli import main result = cli.invoke(main.APP, f"upload --dir {static_dir / 'valid_entities'}") - assert result.exit_code == 0 + assert result.exit_code == 0, result.stderr assert mock_entities_collection.count_documents({}) == 3 stored_entities = list(mock_entities_collection.find({})) From 783aad7fe2234e858a2b42f3d8b530e5ed122b8c Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Thu, 23 Nov 2023 11:09:44 +0100 Subject: [PATCH 35/39] Skip testing CLI if using Python 3.12+ DLite does not support Python 3.12+. --- tests/utils_cli/test_upload.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/utils_cli/test_upload.py b/tests/utils_cli/test_upload.py index 4c92a151..37b95641 100644 --- a/tests/utils_cli/test_upload.py +++ b/tests/utils_cli/test_upload.py @@ -1,8 +1,11 @@ """Tests for `entities-service upload` CLI command.""" from __future__ import annotations +import sys from typing import TYPE_CHECKING +import pytest + if TYPE_CHECKING: from pathlib import Path from typing import Any @@ -11,6 +14,11 @@ from typer.testing import CliRunner +pytestmark = pytest.mark.skipif( + sys.version_info >= (3, 12), reason="DLite does not yet support Python 3.12+." +) + + def test_upload_no_args(cli: CliRunner) -> None: """Test `entities-service upload` CLI command.""" from dlite_entities_service.utils_cli.main import APP, upload From 6c2b513dcf22c9dcd0ecc0a1304d2ca59432e8e5 Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Thu, 23 Nov 2023 11:21:43 +0100 Subject: [PATCH 36/39] Use test client as a context manager. --- tests/test_route.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/test_route.py b/tests/test_route.py index 13cdcdc9..5d65bd67 100644 --- a/tests/test_route.py +++ b/tests/test_route.py @@ -28,7 +28,8 @@ def test_get_entity( """Test the route to retrieve a DLite/SOFT entity.""" from fastapi import status - response = client.get(f"/{version}/{name}", timeout=5) + with client as client: + response = client.get(f"/{version}/{name}", timeout=5) assert ( response.is_success @@ -54,7 +55,8 @@ def test_get_entity_instance( """Validate that we can instantiate an Instance from the response""" from dlite import Instance - response = client.get(f"/{version}/{name}", timeout=5) + with client as client: + response = client.get(f"/{version}/{name}", timeout=5) assert (resolve_entity := response.json()) == entity, resolve_entity @@ -66,7 +68,8 @@ def test_get_entity_not_found(client: TestClient) -> None: from fastapi import status version, name = "0.0", "NonExistantEntity" - response = client.get(f"/{version}/{name}", timeout=5) + with client as client: + response = client.get(f"/{version}/{name}", timeout=5) assert not response.is_success, "Non existant (valid) URI returned an OK response!" assert ( @@ -83,7 +86,8 @@ def test_get_entity_invalid_uri(client: TestClient) -> None: from fastapi import status version, name = "1.0", "EntityName" - response = client.get(f"/{name}/{version}", timeout=5) + with client as client: + response = client.get(f"/{name}/{version}", timeout=5) assert not response.is_success, "Invalid URI returned an OK response!" assert ( From 925ec7200e23e49aa4952e44f3365635e3f24267 Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Thu, 23 Nov 2023 11:49:00 +0100 Subject: [PATCH 37/39] Try passing in the local user+group for docker CI test --- .github/workflows/ci_tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci_tests.yml b/.github/workflows/ci_tests.yml index 1fea87a2..1c3b9721 100644 --- a/.github/workflows/ci_tests.yml +++ b/.github/workflows/ci_tests.yml @@ -82,6 +82,7 @@ jobs: --name "entity-service" \ --network "host" \ --volume "${PWD}:/app" \ + --user "$(id -u):$(id -g)" \ entity-service sleep 5 From 01d7d6902855769fe41a5129ef5ebc3885932660 Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Thu, 23 Nov 2023 11:53:42 +0100 Subject: [PATCH 38/39] Upload code coverage from docker CI job --- .github/workflows/ci_tests.yml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci_tests.yml b/.github/workflows/ci_tests.yml index 1c3b9721..674a2b8d 100644 --- a/.github/workflows/ci_tests.yml +++ b/.github/workflows/ci_tests.yml @@ -95,7 +95,7 @@ jobs: - name: Run tests run: | { - pytest -vv --live-backend + pytest -vv --live-backend --cov-report=xml } || { echo "Failed! Here's the Docker logs for the service:" && docker logs entity-service && @@ -106,6 +106,17 @@ jobs: exit 1 } + - name: Upload coverage + if: github.repository_owner == 'SINTEF' + uses: codecov/codecov-action@v3 + with: + fail_ci_if_error: true + env_vars: OS,PYTHON + flags: docker + env: + OS: ubuntu-latest + PYTHON: '3.10' + pytest: runs-on: ubuntu-latest @@ -138,6 +149,7 @@ jobs: with: fail_ci_if_error: true env_vars: OS,PYTHON + flags: local env: OS: ubuntu-latest PYTHON: ${{ matrix.python_version }} From 6710fe4b24f6a4dace21b6ad2925eb1eea96d00b Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Thu, 23 Nov 2023 13:26:20 +0100 Subject: [PATCH 39/39] Properly support Python 3.11 3.11 introduces StrEnum. --- dlite_entities_service/utils_cli/config.py | 13 +++++++++++-- dlite_entities_service/utils_cli/main.py | 13 +++++++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/dlite_entities_service/utils_cli/config.py b/dlite_entities_service/utils_cli/config.py index 5d2c5aa2..0055c160 100644 --- a/dlite_entities_service/utils_cli/config.py +++ b/dlite_entities_service/utils_cli/config.py @@ -2,11 +2,20 @@ # pylint: disable=duplicate-code from __future__ import annotations +import sys from collections.abc import Generator -from enum import Enum from pathlib import Path from typing import Optional +if sys.version_info >= (3, 11): + from enum import StrEnum +else: + from enum import Enum + + class StrEnum(str, Enum): + """Enum with string values.""" + + try: import typer except ImportError as exc: @@ -42,7 +51,7 @@ OptionalStr = Optional[str] -class ConfigFields(str, Enum): +class ConfigFields(StrEnum): """Configuration options.""" BASE_URL = "base_url" diff --git a/dlite_entities_service/utils_cli/main.py b/dlite_entities_service/utils_cli/main.py index e829a108..f6499518 100644 --- a/dlite_entities_service/utils_cli/main.py +++ b/dlite_entities_service/utils_cli/main.py @@ -4,10 +4,19 @@ import json import os -from enum import Enum +import sys from pathlib import Path from typing import TYPE_CHECKING, Optional +if sys.version_info >= (3, 11): + from enum import StrEnum +else: + from enum import Enum + + class StrEnum(str, Enum): + """Enum with string values.""" + + try: import typer except ImportError as exc: @@ -39,7 +48,7 @@ ERROR_CONSOLE = Console(stderr=True) -class EntityFileFormats(str, Enum): +class EntityFileFormats(StrEnum): """Supported entity file formats.""" JSON = "json"