From 8cac3619d7d80526fb34fd67c41474c2afd63b38 Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen <43357585+CasperWA@users.noreply.github.com> Date: Wed, 22 Nov 2023 10:41:09 +0100 Subject: [PATCH] Update dev tools (#52) Use ruff instead of pylint and isort. Also, add pyupgrade. Configure ruff starting from what is suggested at https://learn.scientific-python.org/development/guides/style/ Update pre-commit hooks and dependency versions. Update code base according to new dev tools. Use `typing.Annotated` for all pydantic and FastAPI stuff. Use lifespan in FastAPI. --- .github/utils/docker_test.py | 53 ++++-- .github/workflows/ci_tests.yml | 16 +- .github/workflows/ci_update_dependencies.yml | 1 - .pre-commit-config.yaml | 61 +++---- dlite_entities_service/__init__.py | 1 + dlite_entities_service/backend.py | 2 + dlite_entities_service/config.py | 37 +++-- dlite_entities_service/logger.py | 3 +- dlite_entities_service/main.py | 59 ++++--- dlite_entities_service/models/__init__.py | 2 + dlite_entities_service/models/soft5.py | 164 ++++++++++-------- dlite_entities_service/models/soft7.py | 165 +++++++++++-------- dlite_entities_service/uvicorn.py | 9 +- docs/example/dlite_example.py | 7 +- pyproject.toml | 69 ++++---- 15 files changed, 368 insertions(+), 281 deletions(-) diff --git a/.github/utils/docker_test.py b/.github/utils/docker_test.py index eec97f9..510f92e 100755 --- a/.github/utils/docker_test.py +++ b/.github/utils/docker_test.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """Run tests for the service.""" -# pylint: disable=import-error +from __future__ import annotations + import argparse import json import os @@ -10,13 +11,14 @@ import requests from dlite import Instance +from fastapi import status from pymongo import MongoClient if TYPE_CHECKING: from typing import Any, Literal -DLITE_TEST_ENTITIES: "list[dict[str, Any]]" = [ +DLITE_TEST_ENTITIES: list[dict[str, Any]] = [ { "uri": "http://onto-ns.com/meta/0.1/Person", "meta": "http://onto-ns.com/meta/0.3/EntitySchema", @@ -104,14 +106,15 @@ def add_testdata() -> None: """Add MongoDB test data.""" - mongodb_user = os.getenv("entity_service_mongo_user") - mongodb_pass = os.getenv("entity_service_mongo_password") - mongodb_uri = os.getenv("entity_service_mongo_uri") + mongodb_user = os.getenv("ENTITY_SERVICE_MONGO_USER") + mongodb_pass = os.getenv("ENTITY_SERVICE_MONGO_PASSWORD") + mongodb_uri = os.getenv("ENTITY_SERVICE_MONGO_URI") if any(_ is None for _ in (mongodb_user, mongodb_pass, mongodb_uri)): - raise ValueError( - "'entity_service_mongo_uri', 'entity_service_mongo_user', and " - "'entity_service_mongo_password' environment variables MUST be specified." + error_message = ( + "ENTITY_SERVICE_MONGO_URI, ENTITY_SERVICE_MONGO_USER, and " + "ENTITY_SERVICE_MONGO_PASSWORD environment variables MUST be specified." ) + raise ValueError(error_message) client = MongoClient(mongodb_uri, username=mongodb_user, password=mongodb_pass) collection = client.dlite.entities @@ -124,19 +127,25 @@ def _get_version_name(uri: str) -> tuple[str, str]: r"^http://onto-ns\.com/meta/(?P[^/]+)/(?P[^/]+)$", uri ) if match is None: - raise RuntimeError("Could not retrieve version and name from test entities.") + error_message = ( + f"Could not retrieve version and name from {uri!r}. " + "URI must be of the form: " + "http://onto-ns.com/meta/{version}/{name}" + ) + raise RuntimeError(error_message) return match.group("version") or "", match.group("name") or "" -def _get_uri(entity: "dict[str, Any]") -> str: +def _get_uri(entity: dict[str, Any]) -> str: """Return the uri for an entity.""" namespace = entity.get("namespace") version = entity.get("version") name = entity.get("name") if any(_ is None for _ in (namespace, version, name)): - raise RuntimeError( + error_message = ( "Could not retrieve namespace, version, and/or name from test entities." ) + raise RuntimeError(error_message) return f"{namespace}/{version}/{name}" @@ -144,14 +153,20 @@ def run_tests() -> None: """Test the service.""" host = os.getenv("DOCKER_TEST_HOST", "localhost") port = os.getenv("DOCKER_TEST_PORT", "8000") + for test_entity in DLITE_TEST_ENTITIES: uri = test_entity.get("uri") + if uri is None: uri = _get_uri(test_entity) + if not isinstance(uri, str): - raise TypeError("uri must be a string") + error_message = f"uri must be a string. Got {type(uri)}." + raise TypeError(error_message) + version, name = _get_version_name(uri) response = requests.get(f"http://{host}:{port}/{version}/{name}", timeout=5) + assert response.ok, ( f"Test data {uri!r} not found! (Or some other error).\n" f"Response:\n{json.dumps(response.json(), indent=2)}" @@ -161,18 +176,23 @@ def run_tests() -> None: assert entity == test_entity Instance.from_dict(test_entity) + # Test that the service returns a Not Found (404) for non existant URIs version, name = _get_version_name("http://onto-ns.com/meta/0.3/EntitySchema") response = requests.get(f"http://{host}:{port}/{version}/{name}", timeout=5) + assert not response.ok, "Non existant (valid) URI returned an OK response!" assert ( - response.status_code == 404 + response.status_code == status.HTTP_404_NOT_FOUND ), f"Response:\n\n{json.dumps(response.json(), indent=2)}" + # Test that the service raises a pydantic ValidationError and returns an " + # "Unprocessable Entity (422) for invalid URIs version, name = _get_version_name("http://onto-ns.com/meta/Entity/1.0") response = requests.get(f"http://{host}:{port}/{version}/{name}", timeout=5) + assert not response.ok, "Invalid URI returned an OK response!" assert ( - response.status_code != 404 + response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY ), f"Response:\n\n{json.dumps(response.json(), indent=2)}" @@ -189,14 +209,15 @@ def main(args: list[str] | None = None) -> None: default="run_tests", ) - job: 'Literal["add-testdata","run-tests"]' = parser.parse_args(args).job + job: Literal["add-testdata", "run-tests"] = parser.parse_args(args).job if job == "add-testdata": add_testdata() elif job == "run-tests": run_tests() else: - raise ValueError(f"{job!r} isn't a valid input.") + error_message = f"Invalid job {job!r}." + raise ValueError(error_message) if __name__ == "__main__": diff --git a/.github/workflows/ci_tests.yml b/.github/workflows/ci_tests.yml index f8acbf0..de7ce86 100644 --- a/.github/workflows/ci_tests.yml +++ b/.github/workflows/ci_tests.yml @@ -17,12 +17,10 @@ jobs: # pre-commit python_version_pre-commit: "3.10" - skip_pre-commit_hooks: pylint # pylint & safety python_version_pylint_safety: "3.10" - pylint_runs: | - --rcfile=pyproject.toml --extension-pkg-whitelist='pydantic' dlite_entities_service + run_pylint: false # Build dist python_version_package: "3.10" @@ -51,9 +49,9 @@ jobs: MONGO_INITDB_ROOT_PASSWORD: root env: - entity_service_mongo_uri: mongodb://localhost:27017 - entity_service_mongo_user: root - entity_service_mongo_password: root + ENTITY_SERVICE_MONGO_URI: mongodb://localhost:27017 + ENTITY_SERVICE_MONGO_USER: root + ENTITY_SERVICE_MONGO_PASSWORD: root DOCKER_TEST_PORT: 8000 steps: @@ -71,9 +69,9 @@ jobs: - name: Run Docker container run: | docker run --rm -d \ - --env entity_service_mongo_uri \ - --env entity_service_mongo_user \ - --env entity_service_mongo_password \ + --env ENTITY_SERVICE_MONGO_URI \ + --env ENTITY_SERVICE_MONGO_USER \ + --env ENTITY_SERVICE_MONGO_PASSWORD \ --env PORT=${DOCKER_TEST_PORT} \ --name "entity-service" \ --network "host" \ diff --git a/.github/workflows/ci_update_dependencies.yml b/.github/workflows/ci_update_dependencies.yml index 542d141..c2645ef 100644 --- a/.github/workflows/ci_update_dependencies.yml +++ b/.github/workflows/ci_update_dependencies.yml @@ -24,6 +24,5 @@ jobs: update_pre-commit: true python_version: "3.10" install_extras: "[dev]" - skip_pre-commit_hooks: "pylint" secrets: PAT: ${{ secrets.TEAM40_PAT }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a9c7a11..b5cd7ff 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,21 +18,31 @@ repos: - id: trailing-whitespace args: [--markdown-linebreak-ext=md] - # isort is a tool to sort and group import statements in Python files - # It works on files in-place - - repo: https://github.com/timothycrosley/isort - rev: 5.12.0 - hooks: - - id: isort - args: ["--profile", "black", "--filter-files", "--skip-gitignore"] - # Black is a code style and formatter # It works on files in-place - repo: https://github.com/ambv/black - rev: 23.10.1 + rev: 23.11.0 hooks: - id: black + # Ruff is a code style and formatter + # It works on files in-place + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.1.6 + hooks: + - id: ruff + args: + - --fix + - --show-fixes + + # Pyupgrade is a code upgrade tool + # It works on files in-place + - repo: https://github.com/asottile/pyupgrade + rev: v3.15.0 + hooks: + - id: pyupgrade + args: [--py310-plus] + # Bandit is a security linter # More information can be found in its documentation: # https://bandit.readthedocs.io/en/latest/ @@ -49,41 +59,10 @@ repos: # The project's documentation can be found at: # https://mypy.readthedocs.io/en/stable/index.html - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.6.1 + rev: v1.7.0 hooks: - id: mypy exclude: ^docs/example/.*$$ additional_dependencies: - pydantic>=2 - types-requests - - - repo: local - hooks: - # pylint is a Python linter - # It is run through the local environment to ensure external packages can be - # imported without issue. - # For more information about pylint see its documentation at: - # https://pylint.pycqa.org/en/latest/ - - id: pylint - name: pylint - entry: pylint - args: ["--rcfile=pyproject.toml"] - language: python - types: [python] - require_serial: true - exclude: ^(tests|docs/example)/.*$ - # pylint is a Python linter - # It is run through the local environment to ensure external packages can be - # 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/.*$ diff --git a/dlite_entities_service/__init__.py b/dlite_entities_service/__init__.py index 8757e11..6381fdf 100644 --- a/dlite_entities_service/__init__.py +++ b/dlite_entities_service/__init__.py @@ -1,4 +1,5 @@ """DLite entities service.""" +from __future__ import annotations __version__ = "0.0.1" __author__ = "Casper Welzel Andersen" diff --git a/dlite_entities_service/backend.py b/dlite_entities_service/backend.py index 5ee8345..66c6de5 100644 --- a/dlite_entities_service/backend.py +++ b/dlite_entities_service/backend.py @@ -1,4 +1,6 @@ """Backend implementation.""" +from __future__ import annotations + from pymongo import MongoClient from dlite_entities_service.config import CONFIG diff --git a/dlite_entities_service/config.py b/dlite_entities_service/config.py index cfed590..0340ffc 100644 --- a/dlite_entities_service/config.py +++ b/dlite_entities_service/config.py @@ -1,10 +1,11 @@ """Service app configuration.""" -from typing import Any +from __future__ import annotations + +from typing import Annotated, Any from pydantic import Field, SecretStr, field_validator from pydantic.networks import AnyHttpUrl, MultiHostUrl, UrlConstraints from pydantic_settings import BaseSettings, SettingsConfigDict -from typing_extensions import Annotated MongoDsn = Annotated[ MultiHostUrl, UrlConstraints(allowed_schemes=["mongodb", "mongodb+srv"]) @@ -15,20 +16,24 @@ class ServiceSettings(BaseSettings): """Service app configuration.""" - base_url: AnyHttpUrl = Field( - AnyHttpUrl("http://onto-ns.com/meta"), - description="Base URL, where the service is running.", - ) - mongo_uri: MongoDsn = Field( - MongoDsn("mongodb://localhost:27017"), - description="URI for the MongoDB cluster/server.", - ) - mongo_user: str | None = Field( - None, description="Username for connecting to the MongoDB." - ) - mongo_password: SecretStr | None = Field( - None, description="Password for connecting to the MongoDB." - ) + base_url: Annotated[ + AnyHttpUrl, + Field( + description="Base URL, where the service is running.", + ), + ] = AnyHttpUrl("http://onto-ns.com/meta") + mongo_uri: Annotated[ + MongoDsn, + Field( + description="URI for the MongoDB cluster/server.", + ), + ] = MongoDsn("mongodb://localhost:27017") + mongo_user: Annotated[ + str | None, Field(description="Username for connecting to the MongoDB.") + ] = None + mongo_password: Annotated[ + SecretStr | None, Field(description="Password for connecting to the MongoDB.") + ] = None @field_validator("base_url", mode="before") @classmethod diff --git a/dlite_entities_service/logger.py b/dlite_entities_service/logger.py index aa41a0d..34bbb1f 100644 --- a/dlite_entities_service/logger.py +++ b/dlite_entities_service/logger.py @@ -1,4 +1,6 @@ """Logging to file.""" +from __future__ import annotations + import logging import sys from contextlib import contextmanager @@ -8,7 +10,6 @@ from uvicorn.logging import DefaultFormatter if TYPE_CHECKING: # pragma: no cover - # pylint: disable=ungrouped-imports import logging.handlers diff --git a/dlite_entities_service/main.py b/dlite_entities_service/main.py index ec43e34..7f9fe01 100644 --- a/dlite_entities_service/main.py +++ b/dlite_entities_service/main.py @@ -1,6 +1,9 @@ """The main application module.""" +from __future__ import annotations + +from contextlib import asynccontextmanager from pathlib import Path as sysPath -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Annotated from fastapi import FastAPI, HTTPException, Path, status @@ -14,14 +17,29 @@ from typing import Any +# Application lifespan function +@asynccontextmanager +async def lifespan(_: FastAPI): + """Add lifespan events to the application.""" + # Do some logging + LOGGER.debug("Starting service with config: %s", CONFIG) + + # Run application + yield + + +# Setup application APP = FastAPI( title="DLite Entities Service", version=__version__, description=( sysPath(__file__).resolve().parent.parent.resolve() / "README.md" ).read_text(encoding="utf8"), + lifespan=lifespan, ) + +# Setup routes SEMVER_REGEX = ( r"^(?P0|[1-9]\d*)(?:\.(?P0|[1-9]\d*))?(?:\.(?P0|[1-9]\d*))?" r"(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)" @@ -42,17 +60,26 @@ response_model_exclude_unset=True, ) async def get_entity( - version: str = Path( - ..., - regex=SEMVER_REGEX, - description="The version part must be of the kind MAJOR.MINOR.", - ), - name: str = Path( - ..., - regex=r"^[A-Za-z]+$", - description="The name part must be CamelCase without any white space.", - ), -) -> "dict[str, Any]": + version: Annotated[ + str, + Path( + title="Entity version", + pattern=SEMVER_REGEX, + description="The version part must be of the kind MAJOR.MINOR.", + ), + ], + name: Annotated[ + str, + Path( + title="Entity name", + pattern=r"(?i)^[A-Z]+$", + description=( + "The name part is without any white space. It is conventionally " + "written in PascalCase." + ), + ), + ], +) -> dict[str, Any]: """Get a DLite entity.""" query = { "$or": [ @@ -61,7 +88,7 @@ async def get_entity( ] } LOGGER.debug("Performing MongoDB query: %r", query) - entity_doc: "dict[str, Any]" = ENTITIES_COLLECTION.find_one(query) + entity_doc: dict[str, Any] = ENTITIES_COLLECTION.find_one(query) if entity_doc is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -70,9 +97,3 @@ async def get_entity( LOGGER.debug("Found entity's MongoDB ID: %s", entity_doc["_id"]) entity_doc.pop("_id", None) return entity_doc - - -@APP.on_event("startup") -async def on_startup() -> None: - """Do some logging.""" - LOGGER.debug("Starting service with config: %s", CONFIG) diff --git a/dlite_entities_service/models/__init__.py b/dlite_entities_service/models/__init__.py index 68dbd5c..1c860ee 100644 --- a/dlite_entities_service/models/__init__.py +++ b/dlite_entities_service/models/__init__.py @@ -1,4 +1,6 @@ """SOFT models.""" +from __future__ import annotations + from pydantic import ValidationError from .soft5 import SOFT5Entity diff --git a/dlite_entities_service/models/soft5.py b/dlite_entities_service/models/soft5.py index 2a761cf..7f0d3db 100644 --- a/dlite_entities_service/models/soft5.py +++ b/dlite_entities_service/models/soft5.py @@ -1,6 +1,7 @@ """SOFT5 models.""" -# pylint: disable=duplicate-code -from typing import Any +from __future__ import annotations + +from typing import Annotated, Any from pydantic import BaseModel, Field, field_validator, model_validator from pydantic.networks import AnyHttpUrl @@ -11,87 +12,108 @@ class SOFT5Dimension(BaseModel): """The defining metadata for a SOFT5 Entity's dimension.""" - name: str = Field(..., description="The name of the dimension.") - description: str = Field( - ..., description="A human-readable description of the dimension." - ) + name: Annotated[str, Field(description="The name of the dimension.")] + description: Annotated[ + str, Field(description="A human-readable description of the dimension.") + ] class SOFT5Property(BaseModel): """The defining metadata for a SOFT5 Entity's property.""" - name: str | None = Field( - None, - description=("The name of the property."), - ) - type_: str = Field( - ..., - alias="type", - description="The type of the described property, e.g., an integer.", - ) - ref: AnyHttpUrl | None = Field( - None, - alias="$ref", - definition=( - "Formally a part of type. `$ref` is used together with the `ref` type, " - "which is a special datatype for referring to other instances." + name: Annotated[ + str | None, + Field( + description=("The name of the property."), + ), + ] = None + type_: Annotated[ + str, + Field( + alias="type", + description="The type of the described property, e.g., an integer.", + ), + ] + ref: Annotated[ + AnyHttpUrl | None, + Field( + alias="$ref", + description=( + "Formally a part of type. `$ref` is used together with the `ref` type, " + "which is a special datatype for referring to other instances." + ), ), - ) - dims: list[str] | None = Field( - None, - description=( - "The dimension of multi-dimensional properties. This is a list of " - "dimension expressions referring to the dimensions defined above. For " - "instance, if an entity have dimensions with names `H`, `K`, and `L` and " - "a property with shape `['K', 'H+1']`, the property of an instance of " - "this entity with dimension values `H=2`, `K=2`, `L=6` will have shape " - "`[2, 3]`." + ] = None + dims: Annotated[ + list[str] | None, + Field( + description=( + "The dimension of multi-dimensional properties. This is a list of " + "dimension expressions referring to the dimensions defined above. For " + "instance, if an entity have dimensions with names `H`, `K`, and `L` " + "and a property with shape `['K', 'H+1']`, the property of an instance " + "of this entity with dimension values `H=2`, `K=2`, `L=6` will have " + "shape `[2, 3]`." + ), ), - ) - unit: str | None = Field(None, description="The unit of the property.") - description: str = Field( - ..., description="A human-readable description of the property." - ) + ] = None + unit: Annotated[str | None, Field(description="The unit of the property.")] = None + description: Annotated[ + str, Field(description="A human-readable description of the property.") + ] class SOFT5Entity(BaseModel): """A SOFT5 Entity returned from this service.""" - name: str | None = Field(None, description="The name of the entity.") - version: str | None = Field(None, description="The version of the entity.") - namespace: AnyHttpUrl | None = Field( - None, description="The namespace of the entity." - ) - uri: AnyHttpUrl | None = Field( - None, - description=( - "The universal identifier for the entity. This MUST start with the base " - "URL." + name: Annotated[str | None, Field(description="The name of the entity.")] = None + version: Annotated[ + str | None, Field(description="The version of the entity.") + ] = None + namespace: Annotated[ + AnyHttpUrl | None, Field(description="The namespace of the entity.") + ] = None + uri: Annotated[ + AnyHttpUrl | None, + Field( + description=( + "The universal identifier for the entity. This MUST start with the base" + " URL." + ), ), - ) - meta: AnyHttpUrl = Field( - AnyHttpUrl("http://onto-ns.com/meta/0.3/EntitySchema"), - description=( - "URI for the metadata entity. For all entities at onto-ns.com, the " - "EntitySchema v0.3 is used." + ] = None + meta: Annotated[ + AnyHttpUrl, + Field( + description=( + "URI for the metadata entity. For all entities at onto-ns.com, the " + "EntitySchema v0.3 is used." + ), ), - ) - description: str = Field("", description="Description of the entity.") - dimensions: list[SOFT5Dimension] = Field( - [], - description="A list of dimensions with name and an accompanying description.", - ) - properties: list[SOFT5Property] = Field(..., description="A list of properties.") + ] = AnyHttpUrl("http://onto-ns.com/meta/0.3/EntitySchema") + description: Annotated[str, Field(description="Description of the entity.")] = "" + dimensions: Annotated[ + list[SOFT5Dimension], + Field( + description=( + "A list of dimensions with name and an accompanying description." + ), + ), + ] = [] + properties: Annotated[ + list[SOFT5Property], Field(description="A list of properties.") + ] @field_validator("uri", "namespace") @classmethod def _validate_base_url(cls, value: AnyHttpUrl) -> AnyHttpUrl: """Validate `uri` starts with the current base URL for the service.""" if not str(value).startswith(str(CONFIG.base_url)): - raise ValueError( + error_message = ( "This service only works with DLite/SOFT entities at " f"{CONFIG.base_url}." ) + raise ValueError(error_message) return value @field_validator("meta") @@ -99,23 +121,25 @@ def _validate_base_url(cls, value: AnyHttpUrl) -> AnyHttpUrl: def _only_support_onto_ns(cls, value: AnyHttpUrl) -> AnyHttpUrl: """Validate `meta` only refers to onto-ns.com EntitySchema v0.3.""" if str(value) != "http://onto-ns.com/meta/0.3/EntitySchema": - raise ValueError( + error_message = ( "This service only works with DLite/SOFT entities using EntitySchema " "v0.3 at onto-ns.com as the metadata entity." ) + raise ValueError(error_message) return value @model_validator(mode="before") @classmethod def _check_cross_dependent_fields(cls, data: Any) -> Any: """Check that `name`, `version`, and `namespace` are all set or all unset.""" - if isinstance(data, dict): - if any(data.get(_) is None for _ in ("name", "version", "namespace")): - if not all( - data.get(_) is None for _ in ("name", "version", "namespace") - ): - raise ValueError( - "Either all of `name`, `version`, and `namespace` must be set " - "or all must be unset." - ) + if ( + isinstance(data, dict) + and any(data.get(_) is None for _ in ("name", "version", "namespace")) + and not all(data.get(_) is None for _ in ("name", "version", "namespace")) + ): + error_message = ( + "Either all of `name`, `version`, and `namespace` must be set " + "or all must be unset." + ) + raise ValueError(error_message) return data diff --git a/dlite_entities_service/models/soft7.py b/dlite_entities_service/models/soft7.py index f3dac41..9d7f059 100644 --- a/dlite_entities_service/models/soft7.py +++ b/dlite_entities_service/models/soft7.py @@ -1,6 +1,7 @@ """SOFT7 models.""" -# pylint: disable=duplicate-code -from typing import Any +from __future__ import annotations + +from typing import Annotated, Any from pydantic import BaseModel, Field, field_validator, model_validator from pydantic.networks import AnyHttpUrl @@ -11,86 +12,104 @@ class SOFT7Property(BaseModel): """The defining metadata for a SOFT7 Entity's property.""" - name: str | None = Field( - None, - description=( - "The name of the property. This is not necessary if the SOFT7 approach to " - "entities are taken." + name: Annotated[ + str | None, + Field( + description=( + "The name of the property. This is not necessary if the SOFT7 approach " + "to entities are taken." + ), + ), + ] = None + type_: Annotated[ + str, + Field( + alias="type", + description="The type of the described property, e.g., an integer.", ), - ) - type_: str = Field( - ..., - alias="type", - description="The type of the described property, e.g., an integer.", - ) - ref: AnyHttpUrl | None = Field( - None, - alias="$ref", - definition=( - "Formally a part of type. `$ref` is used together with the `ref` type, " - "which is a special datatype for referring to other instances." + ] + ref: Annotated[ + AnyHttpUrl | None, + Field( + alias="$ref", + description=( + "Formally a part of type. `$ref` is used together with the `ref` type, " + "which is a special datatype for referring to other instances." + ), ), - ) - shape: list[str] | None = Field( - None, - description=( - "The dimension of multi-dimensional properties. This is a list of " - "dimension expressions referring to the dimensions defined above. For " - "instance, if an entity have dimensions with names `H`, `K`, and `L` and " - "a property with shape `['K', 'H+1']`, the property of an instance of " - "this entity with dimension values `H=2`, `K=2`, `L=6` will have shape " - "`[2, 3]`. Note, this was called `dims` in SOFT5." + ] = None + shape: Annotated[ + list[str] | None, + Field( + description=( + "The dimension of multi-dimensional properties. This is a list of " + "dimension expressions referring to the dimensions defined above. For " + "instance, if an entity have dimensions with names `H`, `K`, and `L` " + "and a property with shape `['K', 'H+1']`, the property of an instance " + "of this entity with dimension values `H=2`, `K=2`, `L=6` will have " + "shape `[2, 3]`. Note, this was called `dims` in SOFT5." + ), ), - ) - unit: str | None = Field(None, description="The unit of the property.") - description: str = Field( - ..., description="A human-readable description of the property." - ) + ] = None + unit: Annotated[str | None, Field(description="The unit of the property.")] = None + description: Annotated[ + str, Field(description="A human-readable description of the property.") + ] class SOFT7Entity(BaseModel): """A SOFT7 Entity returned from this service.""" - name: str | None = Field(None, description="The name of the entity.") - version: str | None = Field(None, description="The version of the entity.") - namespace: AnyHttpUrl | None = Field( - None, description="The namespace of the entity." - ) - uri: AnyHttpUrl = Field( - ..., - description=( - "The universal identifier for the entity. This MUST start with the base " - "URL." + name: Annotated[str | None, Field(description="The name of the entity.")] = None + version: Annotated[ + str | None, Field(description="The version of the entity.") + ] = None + namespace: Annotated[ + AnyHttpUrl | None, Field(description="The namespace of the entity.") + ] = None + uri: Annotated[ + AnyHttpUrl, + Field( + description=( + "The universal identifier for the entity. This MUST start with the " + "base URL." + ), ), - ) - meta: AnyHttpUrl = Field( - AnyHttpUrl("http://onto-ns.com/meta/0.3/EntitySchema"), - description=( - "URI for the metadata entity. For all entities at onto-ns.com, the " - "EntitySchema v0.3 is used." + ] + meta: Annotated[ + AnyHttpUrl, + Field( + description=( + "URI for the metadata entity. For all entities at onto-ns.com, the " + "EntitySchema v0.3 is used." + ), ), - ) - description: str = Field("", description="Description of the entity.") - dimensions: dict[str, str] = Field( - {}, description="A dict of dimensions with an accompanying description." - ) - properties: dict[str, SOFT7Property] = Field( - ..., - description=( - "A dictionary of properties, mapping the property name to a dictionary of " - "metadata defining the property." + ] = AnyHttpUrl("http://onto-ns.com/meta/0.3/EntitySchema") + description: Annotated[str, Field(description="Description of the entity.")] = "" + dimensions: Annotated[ + dict[str, str], + Field(description="A dict of dimensions with an accompanying description."), + ] = {} + properties: Annotated[ + dict[str, SOFT7Property], + Field( + description=( + "A dictionary of properties, mapping the property name to a dictionary " + "of metadata defining the property." + ), ), - ) + ] @field_validator("uri", "namespace") @classmethod def _validate_base_url(cls, value: AnyHttpUrl) -> AnyHttpUrl: """Validate `uri` starts with the current base URL for the service.""" if not str(value).startswith(str(CONFIG.base_url)): - raise ValueError( + error_message = ( "This service only works with DLite/SOFT entities at " f"{CONFIG.base_url}." ) + raise ValueError(error_message) return value @field_validator("meta") @@ -98,23 +117,25 @@ def _validate_base_url(cls, value: AnyHttpUrl) -> AnyHttpUrl: def _only_support_onto_ns(cls, value: AnyHttpUrl) -> AnyHttpUrl: """Validate `meta` only refers to onto-ns.com EntitySchema v0.3.""" if str(value) != "http://onto-ns.com/meta/0.3/EntitySchema": - raise ValueError( + error_message = ( "This service only works with DLite/SOFT entities using EntitySchema " "v0.3 at onto-ns.com as the metadata entity." ) + raise ValueError(error_message) return value @model_validator(mode="before") @classmethod def _check_cross_dependent_fields(cls, data: Any) -> Any: """Check that `name`, `version`, and `namespace` are all set or all unset.""" - if isinstance(data, dict): - if any(data.get(_) is None for _ in ("name", "version", "namespace")): - if not all( - data.get(_) is None for _ in ("name", "version", "namespace") - ): - raise ValueError( - "Either all of `name`, `version`, and `namespace` must be set " - "or all must be unset." - ) + if ( + isinstance(data, dict) + and any(data.get(_) is None for _ in ("name", "version", "namespace")) + and not all(data.get(_) is None for _ in ("name", "version", "namespace")) + ): + error_message = ( + "Either all of `name`, `version`, and `namespace` must be set " + "or all must be unset." + ) + raise ValueError(error_message) return data diff --git a/dlite_entities_service/uvicorn.py b/dlite_entities_service/uvicorn.py index 2bd4683..38715db 100644 --- a/dlite_entities_service/uvicorn.py +++ b/dlite_entities_service/uvicorn.py @@ -1,11 +1,18 @@ """Customize running application with uvicorn.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar + from uvicorn.workers import UvicornWorker as OriginalUvicornWorker +if TYPE_CHECKING: # pragma: no cover + from typing import Any + class UvicornWorker(OriginalUvicornWorker): """Uvicorn worker class to be used in production with gunicorn.""" - CONFIG_KWARGS = { + CONFIG_KWARGS: ClassVar[dict[str, Any]] = { "server_header": False, "headers": [("Server", "DLiteEntitiesService")], } diff --git a/docs/example/dlite_example.py b/docs/example/dlite_example.py index 9e1152c..4aa1c2a 100644 --- a/docs/example/dlite_example.py +++ b/docs/example/dlite_example.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import requests from dlite import Instance @@ -6,7 +8,10 @@ def get_instance(uri: str) -> Instance: uri = uri.replace("http://onto-ns.com/meta", "http://localhost:8000") response = requests.get(uri) if not response.ok: - raise RuntimeError("Instance not found.") + error_message = ( + f"Could not retrieve instance {uri!r}.\nResponse:\n{response.content}" + ) + raise RuntimeError(error_message) entity = response.json() return Instance.from_dict(entity, check_storages=False) diff --git a/pyproject.toml b/pyproject.toml index 4483ba8..98aa29d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ classifiers = [ "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Natural Language :: English", "Operating System :: OS Independent", "Private :: Do Not Upload", @@ -27,29 +28,16 @@ requires-python = "~=3.10" dynamic = ["version", "description"] dependencies = [ - "fastapi ~=0.104.0", - "pydantic-settings ~=2.0", - "pymongo ~=4.5", + "fastapi ~=0.104.1", + "pydantic-settings ~=2.1", + "pymongo ~=4.6", "python-dotenv ~=1.0", - "uvicorn >=0.23.2,<1", + "uvicorn >=0.24.0,<1", ] [project.optional-dependencies] -# docs = [ -# "mike ~=1.1", -# "mkdocs ~=1.4", -# "mkdocs-awesome-pages-plugin ~=2.8", -# "mkdocs-material ~=9.1", -# "mkdocstrings[python-legacy] ~=0.20.0", -# ] -# testing = [ -# "pytest ~=7.2", -# "pytest-cov ~=4.0", -# ] dev = [ "pre-commit ~=3.5", - "pylint ~=3.0", - "pylint-pydantic ~=0.3.0", ] [project.urls] @@ -69,21 +57,34 @@ allow_redefinition = true check_untyped_defs = true plugins = ["pydantic.mypy"] -[tool.pylint] -max-line-length = 88 -disable = [ - "too-few-public-methods", - "no-name-in-module", - "no-self-argument", +[tool.ruff.lint] +extend-select = [ + "E", # pycodestyle + "F", # pyflakes + "B", # flake8-bugbear + "BLE", # flake8-blind-except + "I", # isort + "ARG", # flake8-unused-arguments + "C4", # flake8-comprehensions + "EM", # flake8-errmsg + "ICN", # flake8-import-conventions + "G", # flake8-logging-format + "PGH", # pygrep-hooks + "PIE", # flake8-pie + "PL", # pylint + "PT", # flake8-pytest-style + "PTH", # flake8-use-pathlib + "RET", # flake8-return + "RUF", # Ruff-specific + "SIM", # flake8-simplify + "T20", # flake8-print + "YTT", # flake8-2020 + "EXE", # flake8-executable + "NPY", # NumPy specific rules + "PD", # pandas-vet + "PYI", # flake8-pyi ] -max-args = 15 -max-branches = 15 -load-plugins = ["pylint_pydantic"] - -# [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", -# ] +ignore = [ + "PLR", # Design related pylint codes +] +isort.required-imports = ["from __future__ import annotations"]