From 7302f891578cf5f98f694a6e934be541915fb40d Mon Sep 17 00:00:00 2001 From: Javed Habib Date: Tue, 4 Jun 2024 00:15:20 +0530 Subject: [PATCH 1/6] add models and validators --- tesk/api/ga4gh/tes/models/__init__.py | 1 + tesk/api/ga4gh/tes/models/service_info.py | 152 ++++++++++++++++++ .../tes/models/service_info_organization.py | 33 ++++ .../api/ga4gh/tes/models/service_info_type.py | 53 ++++++ .../ga4gh/tes/models/validators/__init__.py | 1 + .../tes/models/validators/base/__init__.py | 1 + .../tes/models/validators/base/validator.py | 64 ++++++++ .../models/validators/rfc2386_validator.py | 51 ++++++ .../models/validators/rfc3339_validator.py | 48 ++++++ .../models/validators/rfc3986_validator.py | 59 +++++++ 10 files changed, 463 insertions(+) create mode 100644 tesk/api/ga4gh/tes/models/__init__.py create mode 100644 tesk/api/ga4gh/tes/models/service_info.py create mode 100644 tesk/api/ga4gh/tes/models/service_info_organization.py create mode 100644 tesk/api/ga4gh/tes/models/service_info_type.py create mode 100644 tesk/api/ga4gh/tes/models/validators/__init__.py create mode 100644 tesk/api/ga4gh/tes/models/validators/base/__init__.py create mode 100644 tesk/api/ga4gh/tes/models/validators/base/validator.py create mode 100644 tesk/api/ga4gh/tes/models/validators/rfc2386_validator.py create mode 100644 tesk/api/ga4gh/tes/models/validators/rfc3339_validator.py create mode 100644 tesk/api/ga4gh/tes/models/validators/rfc3986_validator.py diff --git a/tesk/api/ga4gh/tes/models/__init__.py b/tesk/api/ga4gh/tes/models/__init__.py new file mode 100644 index 0000000..bc57317 --- /dev/null +++ b/tesk/api/ga4gh/tes/models/__init__.py @@ -0,0 +1 @@ +"""TESK API GA4GH TES models.""" diff --git a/tesk/api/ga4gh/tes/models/service_info.py b/tesk/api/ga4gh/tes/models/service_info.py new file mode 100644 index 0000000..5688cf6 --- /dev/null +++ b/tesk/api/ga4gh/tes/models/service_info.py @@ -0,0 +1,152 @@ +"""TesServiceInfo model, used to represent the service information.""" + +import logging +from contextlib import suppress +from typing import List, Optional + +from pydantic import BaseModel, Field, ValidationError, validator + +from .service_info_organization import TesServiceInfoOrganization +from .service_info_type import TesServiceInfoType +from .validators.rfc2386_validator import RFC2386Validator +from .validators.rfc3339_validator import RFC3339Validator +from .validators.rfc3986_validator import RFC3986Validator + +logger = logging.getLogger(__name__) + +class TesServiceInfo(BaseModel): + """TesServiceInfo model, used to represent the service information.""" + + id: str = Field( + ..., + example='org.ga4gh.myservice', + description=( + 'Unique ID of this service. Reverse domain name notation is recommended, ' + 'though not required. The identifier should attempt to be globally unique ' + 'so it can be used in downstream aggregator services e.g. Service Registry.' + ), + ) + name: str = Field( + ..., + example='My project', + description='Name of this service. Should be human readable.', + ) + type: TesServiceInfoType = Field( + ..., + description='Type of a GA4GH service', + ) + description: Optional[str] = Field( + default=None, + example='This service provides...', + description=( + 'Description of the service. Should be human readable and ' + 'provide information about the service.' + ), + ) + organization: TesServiceInfoOrganization = Field( + ..., + example='My organization', + ) + contactUrl: Optional[str] = Field( + default=None, + example='mailto:support@example.com', + description=( + 'URL of the contact for the provider of this service, ' + 'e.g. a link to a contact form (RFC 3986 format), ' + 'or an email (RFC 2368 format).' + ), + ) + documentationUrl: Optional[str] = Field( + default=None, + example='https://docs.myservice.example.com', + description='URL of the documentation for this service.', + ) + created_at: Optional[str] = Field( + default=None, + example='2019-06-04T12:58:19Z', + description=( + 'Timestamp describing when the service was first deployed ' + 'and available (RFC 3339 format)' + ), + ) + updatedAt: Optional[str] = Field( + default=None, + example='2019-06-04T12:58:19Z', + description=( + 'Timestamp describing when the service was last updated (RFC 3339 format)' + ), + ) + environment: Optional[str] = Field( + default=None, + example='test', + description=( + 'Environment the service is running in. Use this to distinguish ' + 'between production, development and testing/staging deployments. ' + 'Suggested values are prod, test, dev, staging. However this is ' + 'advised and not enforced.' + ), + ) + version: str = Field( + ..., + example='1.0.0', + description=( + 'Version of the service being described. Semantic versioning is ' + 'recommended, but other identifiers, such as dates or commit hashes, ' + 'are also allowed. The version should be changed whenever the service ' + 'is updated.' + ), + ) + storage: List[str] = ( + Field( + default_factory=list, + example=[ + 'file:///path/to/local/funnel-storage', + 's3://ohsu-compbio-funnel/storage', + ], + description=( + 'Lists some, but not necessarily all, storage locations supported ' + 'by the service.' + ), + ), + ) + tesResources_backend_parameters: List[str] = Field( + default_factory=list, + example=['VmSize'], + description=( + 'Lists all tesResources.backend_parameters keys ' 'supported by the service' + ), + ) + + @validator('id', 'name', 'type', 'organization', 'version') + def not_empty(cls, v): + """Validate that the value is not empty.""" + if not v: + raise ValueError('must not be empty') + return v + + @validator('contactUrl') + def validate_contact(cls, v): + """Validate the contactURL format based on RFC 3986 or 2368 standard.""" + if v: + url_validator = RFC3986Validator(field=v, model=cls) + email_validator = RFC2386Validator(field=v, model=cls) + + with suppress(ValidationError): + return email_validator.validate() + + with suppress(ValidationError): + return url_validator.validate() + + logger.error('contactUrl must be based on RFC 3986 or 2368 standard.') + raise ValidationError( + 'contactUrl must be based on RFC 3986 or 2368 standard.' + ) + return v + + @validator('createdAt', 'updatedAt') + def validate_timestamp(cls, v): + """Validate the timestamp format based on RFC 3339 standard.""" + if v: + date_validator = RFC3339Validator(field=v, model=cls) + date_validator.validate() + return v diff --git a/tesk/api/ga4gh/tes/models/service_info_organization.py b/tesk/api/ga4gh/tes/models/service_info_organization.py new file mode 100644 index 0000000..907c43d --- /dev/null +++ b/tesk/api/ga4gh/tes/models/service_info_organization.py @@ -0,0 +1,33 @@ +"""Organization providing the service.""" + +from pydantic import BaseModel, Field, validator + +from .validators.rfc3986_validator import RFC3986Validator + + +class TesServiceInfoOrganization(BaseModel): + """Organization providing the service.""" + + name: str = Field( + ..., + example='My organization', + description='Name of the organization providing the service.', + ) + url: str = Field( + ..., + example='https://example.com', + description='URL of the website of the organization (RFC 3986 format).', + ) + + @validator('name', 'url') + def not_empty(cls, v): + """Validate that the value is not empty.""" + if not v: + raise ValueError(f'{v} must not be empty.') + return v + + @validator('url') + def validate_url(cls, v): + """Validate the URL format based on RFC 3986 standard.""" + validator = RFC3986Validator() + return validator.validate(v) diff --git a/tesk/api/ga4gh/tes/models/service_info_type.py b/tesk/api/ga4gh/tes/models/service_info_type.py new file mode 100644 index 0000000..8b5e68f --- /dev/null +++ b/tesk/api/ga4gh/tes/models/service_info_type.py @@ -0,0 +1,53 @@ +"""Type of a GA4GH service.""" + +from typing import Literal + +from pydantic import BaseModel, Field, validator + + +class TesServiceInfoType(BaseModel): + """Class represing the type of a `GA4GH` service. + + Example: + { + "group": "org.ga4gh", + "artifact": "tes", + "version": "1.1.0" + } + """ + + group: str = Field( + default='org.ga4gh', + example='org.ga4gh', + description=( + 'Namespace in reverse domain name format. Use `org.ga4gh` for ' + 'implementations compliant with official GA4GH specifications. ' + 'For services with custom APIs not standardized by GA4GH, or ' + 'implementations diverging from official GA4GH specifications, use ' + "a different namespace (e.g. your organization's reverse domain name)." + ), + ) + artifact: Literal['tes'] = Field( + default='tes', + example='tes', + description=( + 'Name of the API or GA4GH specification implemented. Official GA4GH types ' + 'should be assigned as part of standards approval process. ' + 'Custom artifacts are supported.' + ), + ) + version: str = Field( + default='1.1.0', + example='1.0.0', + description=( + 'Version of the API or specification. ' + 'GA4GH specifications use semantic versioning.' + ), + ) + + @validator('group', 'artifact', 'version') + def not_empty(cls, v): + """Validate that the value is not empty.""" + if not v: + raise ValueError('must not be empty') + return v diff --git a/tesk/api/ga4gh/tes/models/validators/__init__.py b/tesk/api/ga4gh/tes/models/validators/__init__.py new file mode 100644 index 0000000..be63431 --- /dev/null +++ b/tesk/api/ga4gh/tes/models/validators/__init__.py @@ -0,0 +1 @@ +"""Custom validators for TES models.""" diff --git a/tesk/api/ga4gh/tes/models/validators/base/__init__.py b/tesk/api/ga4gh/tes/models/validators/base/__init__.py new file mode 100644 index 0000000..aba2988 --- /dev/null +++ b/tesk/api/ga4gh/tes/models/validators/base/__init__.py @@ -0,0 +1 @@ +"""Abstract package for implementing custom validators.""" diff --git a/tesk/api/ga4gh/tes/models/validators/base/validator.py b/tesk/api/ga4gh/tes/models/validators/base/validator.py new file mode 100644 index 0000000..b940a64 --- /dev/null +++ b/tesk/api/ga4gh/tes/models/validators/base/validator.py @@ -0,0 +1,64 @@ +"""Base validator class, all cutom validator must implement it.""" + +import logging +from abc import ABC, abstractmethod +from typing import Generic, TypeVar, final + +from pydantic import BaseModel, ValidationError + +T = TypeVar('T') +logger = logging.getLogger(__name__) + + +class BaseValidator(ABC, Generic[T]): + """Base custom validator class.""" + + @property + @abstractmethod + def field(self) -> T: + """Returns the field to be validated.""" + pass + + @property + @abstractmethod + def model(self) -> BaseModel: + """Returns the pydantic model whose field is being validated.""" + pass + + @property + @abstractmethod + def error_message(self) -> str: + """Returns the error message.""" + pass + + @abstractmethod + def validation_logic(self) -> bool: + """Login validation. + + Returns: + True if the validation is successful, False otherwise. + """ + pass + + @final + def raiseError(self): + """Raise a validation error.""" + logger.error(f""" + Validation failed for {self.field} in {self.model.__name__}. + """) + raise ValidationError(self.error_message, model=self.model, fields={self.field}) + + @final + def validate(self) -> T: + """Validate the value. + + Returns: + The validated value (if valid). + + Raises: + ValueError: If the value is not valid. + """ + if not self.validation_logic(): + self.raiseError() + + return self.field diff --git a/tesk/api/ga4gh/tes/models/validators/rfc2386_validator.py b/tesk/api/ga4gh/tes/models/validators/rfc2386_validator.py new file mode 100644 index 0000000..0ff5f88 --- /dev/null +++ b/tesk/api/ga4gh/tes/models/validators/rfc2386_validator.py @@ -0,0 +1,51 @@ +"""RFC2386 email validator.""" + +import re + +from pydantic import BaseModel + +from .base.validator import BaseValidator + + +class RFC2386Validator(BaseValidator): + """RFC2386 email validator. + + Validate an email based on RFC 2386 standard. + + Attributes: + field (str): The email string to be validated. + model (BaseModel): The Pydantic model whose field is being validated. + """ + + def __init__(self, field: str, model: BaseModel) -> None: + """Initialize the RFC2386Validator. + + Args: + field (str): The field to be validated. + model (BaseModel): The Pydantic model whose field is being validated. + """ + super().__init__() + self._field = field + self._model = model + + @property + def field(self) -> str: + """Return the field to be validated.""" + return self._field + + @property + def model(self) -> BaseModel: + """Return the Pydantic model whose field is being validated.""" + return self._model + + @property + def error_message(self) -> str: + """Return the error message.""" + return 'Invalid email format, only RFC 2386 standard allowed.' + + def validation_logic(self) -> bool: + """Validation logic for RFC 2386 standard.""" + email_regex = re.compile( + r'^mailto:[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' + ) + return bool(re.match(email_regex, self.field)) diff --git a/tesk/api/ga4gh/tes/models/validators/rfc3339_validator.py b/tesk/api/ga4gh/tes/models/validators/rfc3339_validator.py new file mode 100644 index 0000000..69ca890 --- /dev/null +++ b/tesk/api/ga4gh/tes/models/validators/rfc3339_validator.py @@ -0,0 +1,48 @@ +"""RFC3339 date validator.""" + +from datetime import datetime + +from pydantic import BaseModel + +from .base.validator import BaseValidator + + +class RFC3339Validator(BaseValidator): + """RFC3339Validator date validator. + + Validate a date based on RFC 3339 standard. + + Attributes: + field (str): The date string to be validated. + _model (Base_model): The Pydantic _model whose field is being validated. + """ + + def __init__(self, field: str, _model: BaseModel) -> None: + """Initialize the RFC3339Validator. + + Args: + field (str): The date string to be validated. + __model (Base_model): The Pydantic _model whose field is being validated. + """ + super().__init__() + self._field = field + self._model = _model + + @property + def field(self) -> str: + """Return the field to be validated.""" + return self._field + + @property + def model(self) -> BaseModel: + """Return the Pydantic _model whose field is being validated.""" + return self._model + + @property + def error_message(self) -> str: + """Return the error message.""" + return 'Invalid date format, only RFC 3339 standard allowed.' + + def validation_logic(self) -> bool: + """Validation logic for RFC 3339 standard.""" + return bool(datetime.strptime(self.field, '%Y-%m-%dT%H:%M:%S%z').tzinfo) diff --git a/tesk/api/ga4gh/tes/models/validators/rfc3986_validator.py b/tesk/api/ga4gh/tes/models/validators/rfc3986_validator.py new file mode 100644 index 0000000..70fb27a --- /dev/null +++ b/tesk/api/ga4gh/tes/models/validators/rfc3986_validator.py @@ -0,0 +1,59 @@ +"""RFC3986 URL validator.""" + +import re + +from pydantic import BaseModel + +from .base.validator import BaseValidator + + +class RFC3986Validator(BaseValidator): + """RFC3986Validator URL validator. + + Validate a URL based on RFC 3986 standard. + + Attributes: + field (str): The URL string to be validated. + model (BaseModel): The Pydantic model whose field is being validated. + """ + + def __init__(self, field: str, model: BaseModel) -> None: + """Initialize the RFC3986Validator. + + Args: + field (str): The URL string to be validated. + model (BaseModel): The Pydantic model whose field is being validated. + """ + self._field = field + self._model = model + + @property + def field(self) -> str: + """Return the field to be validated.""" + return self._field + + @property + def model(self) -> BaseModel: + """Return the Pydantic model whose field is being validated.""" + return self._model + + @property + def error_message(self) -> str: + """Return the error message.""" + return 'Invalid URL format, only RFC 3986 standard allowed.' + + def validation_logic(self) -> bool: + """Validate a URL based on RFC 3986 standard.""" + url_regex = re.compile( + r'^(?:http|ftp)s?://' # http:// or https:// + r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+' # domain part 1 + r'(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|' # domain part 2 + r'localhost|' # localhost... + r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}|' # ...or ipv4 + r'\[?[A-F0-9]*:[A-F0-9:]+\]?)' # ...or ipv6 + r'(?::\d+)?' # optional port + r'(?:/?|[/?]\S+)$', + re.IGNORECASE, + ) + + return bool(re.match(url_regex, self.field)) From 698fd6248a0cb31988115d2ff8d75d9ac402cf30 Mon Sep 17 00:00:00 2001 From: Javed Habib Date: Wed, 5 Jun 2024 20:50:11 +0530 Subject: [PATCH 2/6] endpoint seem to be working, validation not so much, threading still to be looked at. --- deployment/config.yaml | 25 +++ poetry.lock | 13 +- pyproject.toml | 1 + tesk/api/ga4gh/tes/base/__init__.py | 6 + tesk/api/ga4gh/tes/base/base_tesk_request.py | 45 +++++ tesk/api/ga4gh/tes/controllers.py | 14 +- tesk/api/ga4gh/tes/models/service_info.py | 168 ++++++++++------- .../tes/models/service_info_organization.py | 19 +- .../api/ga4gh/tes/models/service_info_type.py | 5 + .../models/validators/base/base_validator.py | 74 ++++++++ .../tes/models/validators/base/validator.py | 64 ------- .../models/validators/rfc2386_validator.py | 29 +-- .../models/validators/rfc3339_validator.py | 27 +-- .../models/validators/rfc3986_validator.py | 26 +-- tesk/api/ga4gh/tes/service_info/__init__.py | 1 + .../ga4gh/tes/service_info/base/__init__.py | 1 + .../base/base_service_info_request.py | 122 +++++++++++++ .../ga4gh/tes/service_info/service_info.py | 36 ++++ tesk/app.py | 45 +---- tesk/tesk_app.py | 169 ++++++++++++++++++ 20 files changed, 639 insertions(+), 251 deletions(-) create mode 100644 tesk/api/ga4gh/tes/base/__init__.py create mode 100644 tesk/api/ga4gh/tes/base/base_tesk_request.py create mode 100644 tesk/api/ga4gh/tes/models/validators/base/base_validator.py delete mode 100644 tesk/api/ga4gh/tes/models/validators/base/validator.py create mode 100644 tesk/api/ga4gh/tes/service_info/__init__.py create mode 100644 tesk/api/ga4gh/tes/service_info/base/__init__.py create mode 100644 tesk/api/ga4gh/tes/service_info/base/base_service_info_request.py create mode 100644 tesk/api/ga4gh/tes/service_info/service_info.py create mode 100644 tesk/tesk_app.py diff --git a/deployment/config.yaml b/deployment/config.yaml index e102bf3..2504c5e 100644 --- a/deployment/config.yaml +++ b/deployment/config.yaml @@ -48,6 +48,31 @@ api: swagger_ui: True serve_spec: True +# ServiceInfo configuration based on ServiceInfo model +serviceInfo: + id: org.ga4gh.myservice + name: My project + type: + group: org.ga4gh + artifact: tes + version: 1.0.0 + description: This service provides... + organization: + name: My organization + url: https://example.com + contactUrl: mailto:support@example.com + documentationUrl: https://docs.myservice.example.com + createdAt: '2019-06-04T12:58:19Z' + updatedAt: '2019-06-04T12:58:19Z' + environment: test + version: 1.0.0 + storage: + - file:///path/to/local/funnel-storage + - s3://ohsu-compbio-funnel/storage + tesResources_backend_parameters: + - VmSize + - ParamToRecogniseDataComingFromConfig + # Logging configuration # Cf. https://foca.readthedocs.io/en/latest/modules/foca.models.html#foca.models.config.LogConfig log: diff --git a/poetry.lock b/poetry.lock index 3143c30..b666d83 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2980,6 +2980,17 @@ files = [ [package.dependencies] botocore-stubs = "*" +[[package]] +name = "types-pyyaml" +version = "6.0.12.20240311" +description = "Typing stubs for PyYAML" +optional = false +python-versions = ">=3.8" +files = [ + {file = "types-PyYAML-6.0.12.20240311.tar.gz", hash = "sha256:a9e0f0f88dc835739b0c1ca51ee90d04ca2a897a71af79de9aec5f38cb0a5342"}, + {file = "types_PyYAML-6.0.12.20240311-py3-none-any.whl", hash = "sha256:b845b06a1c7e54b8e5b4c683043de0d9caf205e7434b3edc678ff2411979b8f6"}, +] + [[package]] name = "types-requests" version = "2.32.0.20240523" @@ -3165,4 +3176,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "f84324e2321c3fc0bd7e89e46294a494af5805b4bc1845adeb7d08d5ffe7fdfb" +content-hash = "2b2f816322f2b591b92a3f634e489dbf7b75c5cbd8d31f1cf15dba62d9b1c7a2" diff --git a/pyproject.toml b/pyproject.toml index f92be0a..3235e6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ kubernetes = "^29.0.0" python = "^3.11" requests = ">=2.20.0" urllib3 = "^2.2.1" +types-pyyaml = "^6.0.12.20240311" [tool.poetry.group.docs.dependencies] added-value = "^0.24.0" diff --git a/tesk/api/ga4gh/tes/base/__init__.py b/tesk/api/ga4gh/tes/base/__init__.py new file mode 100644 index 0000000..a86e9ce --- /dev/null +++ b/tesk/api/ga4gh/tes/base/__init__.py @@ -0,0 +1,6 @@ +"""Base classes for the TES API. + +This module contains the base abstract class which is needed by all the +subclasses of the TES API, serving as single source of truth for the common +business logic, properties and methods. +""" diff --git a/tesk/api/ga4gh/tes/base/base_tesk_request.py b/tesk/api/ga4gh/tes/base/base_tesk_request.py new file mode 100644 index 0000000..af2a7de --- /dev/null +++ b/tesk/api/ga4gh/tes/base/base_tesk_request.py @@ -0,0 +1,45 @@ +"""Base classes for the TES API request.""" + +from abc import ABC, abstractmethod +from typing import final + +from pydantic import BaseModel + +from tesk.tesk_app import TeskApp + + +class BaseTeskRequest(ABC, TeskApp): + """Base class for the TES API. + + This class is an abstract class that defines the common properties and + methods needed by all of the TES API endpoint business logic. + """ + + def __init__(self) -> None: + """Initializes the BaseTeskRequest class.""" + super().__init__() + + @abstractmethod + def api_response(self) -> BaseModel: + """Returns the response as Pydantic model. + + Should be implemented by the child class as final + business logic for the specific endpoint. + + Returns: + BaseModel: API response for the specific endpoint. + """ + pass + + @final + def response(self) -> dict: + """Returns serialized response. + + Should be envoked by controller. + + Returns: + dict: Serialized response for the specific endpoint. + """ + _response = self.api_response() + assert isinstance(_response, BaseModel) + return _response.dict() diff --git a/tesk/api/ga4gh/tes/controllers.py b/tesk/api/ga4gh/tes/controllers.py index 5560968..7a355b4 100644 --- a/tesk/api/ga4gh/tes/controllers.py +++ b/tesk/api/ga4gh/tes/controllers.py @@ -5,6 +5,8 @@ # from connexion import request # type: ignore from foca.utils.logging import log_traffic # type: ignore +from tesk.api.ga4gh.tes.service_info.service_info import ServiceInfo + # Get logger instance logger = logging.getLogger(__name__) @@ -36,14 +38,10 @@ def CreateTask(*args, **kwargs) -> dict: # type: ignore # GET /tasks/service-info @log_traffic -def GetServiceInfo(*args, **kwargs) -> dict: # type: ignore - """Get service info. - - Args: - *args: Variable length argument list. - **kwargs: Arbitrary keyword arguments. - """ - pass +def GetServiceInfo() -> dict: + """Get service info.""" + service_info = ServiceInfo() + return service_info.response() # GET /tasks diff --git a/tesk/api/ga4gh/tes/models/service_info.py b/tesk/api/ga4gh/tes/models/service_info.py index 5688cf6..6806504 100644 --- a/tesk/api/ga4gh/tes/models/service_info.py +++ b/tesk/api/ga4gh/tes/models/service_info.py @@ -1,21 +1,66 @@ """TesServiceInfo model, used to represent the service information.""" import logging -from contextlib import suppress from typing import List, Optional -from pydantic import BaseModel, Field, ValidationError, validator +from pydantic import BaseModel, Field -from .service_info_organization import TesServiceInfoOrganization -from .service_info_type import TesServiceInfoType -from .validators.rfc2386_validator import RFC2386Validator -from .validators.rfc3339_validator import RFC3339Validator -from .validators.rfc3986_validator import RFC3986Validator +from tesk.api.ga4gh.tes.models.service_info_organization import ( + TesServiceInfoOrganization, +) +from tesk.api.ga4gh.tes.models.service_info_type import TesServiceInfoType logger = logging.getLogger(__name__) + class TesServiceInfo(BaseModel): - """TesServiceInfo model, used to represent the service information.""" + """TesServiceInfo model, used to represent the service information. + + Attributes: + id (str): Unique ID of this service. + name (str): Name of this service. + type (TesServiceInfoType): Type of a GA4GH service. + description (str): Description of the service. + organization (TesServiceInfoOrganization): Organization providing the service. + contactUrl (str): URL of the contact for the provider of this service. + documentationUrl (str): URL of the documentation for this service. + created_at (str): Timestamp describing when the service was first deployed + and available. + updatedAt (str): Timestamp describing when the service was last updated. + environment (str): Environment the service is running in. + version (str): Version of the service being described. + storage (List[str]): Lists some, but not necessarily all, storage locations + supported by the service. + tesResources_backend_parameters (List[str]): Lists of supported + tesResources.backend_parameters + keys. + + Example: + { + "id": "org.ga4gh.myservice", + "name": "My project", + "type": { + "group": "org.ga4gh", + "artifact": "tes", + "version": "1.0.0" + }, + "description": "This service provides...", + "organization": { + "name": "My organization", + "url": "https://example.com" + }, + "contactUrl": "mailto:support@example.com", + "documentationUrl": "https://docs.myservice.example.com", + "createdAt": "2019-06-04T12:58:19Z", + "updatedAt": "2019-06-04T12:58:19Z", + "environment": "test", + "version": "1.0.0", + "storage": [ + "file:///path/to/local/funnel-storage", + "s3://ohsu-compbio-funnel/storage" + ] + } + """ id: str = Field( ..., @@ -31,10 +76,7 @@ class TesServiceInfo(BaseModel): example='My project', description='Name of this service. Should be human readable.', ) - type: TesServiceInfoType = Field( - ..., - description='Type of a GA4GH service', - ) + type: TesServiceInfoType description: Optional[str] = Field( default=None, example='This service provides...', @@ -43,10 +85,7 @@ class TesServiceInfo(BaseModel): 'provide information about the service.' ), ) - organization: TesServiceInfoOrganization = Field( - ..., - example='My organization', - ) + organization: TesServiceInfoOrganization contactUrl: Optional[str] = Field( default=None, example='mailto:support@example.com', @@ -96,57 +135,64 @@ class TesServiceInfo(BaseModel): 'is updated.' ), ) - storage: List[str] = ( - Field( - default_factory=list, - example=[ - 'file:///path/to/local/funnel-storage', - 's3://ohsu-compbio-funnel/storage', - ], - description=( - 'Lists some, but not necessarily all, storage locations supported ' - 'by the service.' - ), + storage: List[str] = Field( + default_factory=list, + example=[ + 'file:///path/to/local/funnel-storage', + 's3://ohsu-compbio-funnel/storage', + ], + description=( + 'Lists some, but not necessarily all, storage locations supported ' + 'by the service.' ), ) tesResources_backend_parameters: List[str] = Field( default_factory=list, example=['VmSize'], description=( - 'Lists all tesResources.backend_parameters keys ' 'supported by the service' + 'Lists all tesResources.backend_parameters keys supported by the service' ), ) - @validator('id', 'name', 'type', 'organization', 'version') - def not_empty(cls, v): - """Validate that the value is not empty.""" - if not v: - raise ValueError('must not be empty') - return v - - @validator('contactUrl') - def validate_contact(cls, v): - """Validate the contactURL format based on RFC 3986 or 2368 standard.""" - if v: - url_validator = RFC3986Validator(field=v, model=cls) - email_validator = RFC2386Validator(field=v, model=cls) - - with suppress(ValidationError): - return email_validator.validate() - - with suppress(ValidationError): - return url_validator.validate() - - logger.error('contactUrl must be based on RFC 3986 or 2368 standard.') - raise ValidationError( - 'contactUrl must be based on RFC 3986 or 2368 standard.' - ) - return v - - @validator('createdAt', 'updatedAt') - def validate_timestamp(cls, v): - """Validate the timestamp format based on RFC 3339 standard.""" - if v: - date_validator = RFC3339Validator(field=v, model=cls) - date_validator.validate() - return v + # @validator('id', 'name', 'type', 'organization', 'version') + # def not_empty(cls, v): + # """Validate that the value is not empty.""" + # if not v: + # logger.error(f'Field {v} must not be empty in model {cls.__name__}.') + # raise ValueError(f'Field {v} must not be empty in model {cls.__name__}.') + # return v + + # @validator('documentationUrl') + # def validate_url(cls, v): + # """Validate the documentationURL format based on RFC 3986 standard.""" + # if v: + # validator = RFC3986Validator(field=v, model=cls) + # return validator.validate() + # return v + + # @validator('contactUrl') + # def validate_url_and_email(cls, v): + # """Validate the contactURL format based on RFC 3986 or 2368 standard.""" + # if v: + # url_validator = RFC3986Validator(field=v, model=cls) + # email_validator = RFC2386Validator(field=v, model=cls) + + # with suppress(ValidationError): + # return email_validator.validate() + + # with suppress(ValidationError): + # return url_validator.validate() + + # logger.error('contactUrl must be based on RFC 3986 or 2368 standard.') + # raise ValidationError( + # 'contactUrl must be based on RFC 3986 or 2368 standard.' + # ) + # return v + + # @validator('createdAt', 'updatedAt') + # def validate_timestamp(cls, v): + # """Validate the timestamp format based on RFC 3339 standard.""" + # if v: + # date_validator = RFC3339Validator(field=v, model=cls) + # return date_validator.validate() + # return v diff --git a/tesk/api/ga4gh/tes/models/service_info_organization.py b/tesk/api/ga4gh/tes/models/service_info_organization.py index 907c43d..dc19328 100644 --- a/tesk/api/ga4gh/tes/models/service_info_organization.py +++ b/tesk/api/ga4gh/tes/models/service_info_organization.py @@ -2,11 +2,22 @@ from pydantic import BaseModel, Field, validator -from .validators.rfc3986_validator import RFC3986Validator +from tesk.api.ga4gh.tes.models.validators.rfc3986_validator import RFC3986Validator class TesServiceInfoOrganization(BaseModel): - """Organization providing the service.""" + """Organization providing the service. + + Attributes: + name (str): Name of the organization providing the service. + url (str): URL of the website of the organization (RFC 3986 format). + + Example: + { + "name": "My organization", + "url": "https://example.com" + } + """ name: str = Field( ..., @@ -29,5 +40,5 @@ def not_empty(cls, v): @validator('url') def validate_url(cls, v): """Validate the URL format based on RFC 3986 standard.""" - validator = RFC3986Validator() - return validator.validate(v) + validator = RFC3986Validator(field=v, model=cls) + return validator.validate() diff --git a/tesk/api/ga4gh/tes/models/service_info_type.py b/tesk/api/ga4gh/tes/models/service_info_type.py index 8b5e68f..b78d0f6 100644 --- a/tesk/api/ga4gh/tes/models/service_info_type.py +++ b/tesk/api/ga4gh/tes/models/service_info_type.py @@ -8,6 +8,11 @@ class TesServiceInfoType(BaseModel): """Class represing the type of a `GA4GH` service. + Attributes: + group (str): Namespace in reverse domain name format. + artifact (str): Name of the API or GA4GH specification implemented. + version (str): Version of the API or specification. + Example: { "group": "org.ga4gh", diff --git a/tesk/api/ga4gh/tes/models/validators/base/base_validator.py b/tesk/api/ga4gh/tes/models/validators/base/base_validator.py new file mode 100644 index 0000000..5afbf6f --- /dev/null +++ b/tesk/api/ga4gh/tes/models/validators/base/base_validator.py @@ -0,0 +1,74 @@ +"""Base validator class, all custom validator must implement it.""" + +import logging +from abc import ABC, abstractmethod +from typing import Generic, TypeVar, final + +from pydantic import BaseModel, ValidationError + +T = TypeVar('T') +logger = logging.getLogger(__name__) + + +class BaseValidator(ABC, Generic[T]): + """Base custom validator class.""" + + def __init__(self, field: T, model: BaseModel) -> None: + """Initialize the validator. + + Args: + field (T): The field to be validated. + model (BaseModel): The pydantic model whose field is being validated. + """ + self._field = field + self._model = model + + @property + @abstractmethod + def error_message(self) -> str: + """Returns the error message. + + Returns: + str: The error message to be used when validation fails. + """ + pass + + @abstractmethod + def validation_logic(self) -> bool: + """Validation logic for the field. + + Returns: + True if the validation is successful, False otherwise. + """ + pass + + @final + def _raise_error(self): + """Raise a validation error. + + Raises: + ValidationError: Raised when the validation fails. + """ + logger.error(f""" + Validation failed for {self._field} in {self._model.__name__}. + """) + raise ValidationError( + self.error_message, + model=self._model, + fields={self._field}, + ) + + @final + def validate(self) -> T: + """Validate the value. + + Returns: + The validated value (if valid). + + Raises: + ValueError: If the value is not valid. + """ + if not self.validation_logic(): + self._raise_error() + + return self._field diff --git a/tesk/api/ga4gh/tes/models/validators/base/validator.py b/tesk/api/ga4gh/tes/models/validators/base/validator.py deleted file mode 100644 index b940a64..0000000 --- a/tesk/api/ga4gh/tes/models/validators/base/validator.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Base validator class, all cutom validator must implement it.""" - -import logging -from abc import ABC, abstractmethod -from typing import Generic, TypeVar, final - -from pydantic import BaseModel, ValidationError - -T = TypeVar('T') -logger = logging.getLogger(__name__) - - -class BaseValidator(ABC, Generic[T]): - """Base custom validator class.""" - - @property - @abstractmethod - def field(self) -> T: - """Returns the field to be validated.""" - pass - - @property - @abstractmethod - def model(self) -> BaseModel: - """Returns the pydantic model whose field is being validated.""" - pass - - @property - @abstractmethod - def error_message(self) -> str: - """Returns the error message.""" - pass - - @abstractmethod - def validation_logic(self) -> bool: - """Login validation. - - Returns: - True if the validation is successful, False otherwise. - """ - pass - - @final - def raiseError(self): - """Raise a validation error.""" - logger.error(f""" - Validation failed for {self.field} in {self.model.__name__}. - """) - raise ValidationError(self.error_message, model=self.model, fields={self.field}) - - @final - def validate(self) -> T: - """Validate the value. - - Returns: - The validated value (if valid). - - Raises: - ValueError: If the value is not valid. - """ - if not self.validation_logic(): - self.raiseError() - - return self.field diff --git a/tesk/api/ga4gh/tes/models/validators/rfc2386_validator.py b/tesk/api/ga4gh/tes/models/validators/rfc2386_validator.py index 0ff5f88..455e02e 100644 --- a/tesk/api/ga4gh/tes/models/validators/rfc2386_validator.py +++ b/tesk/api/ga4gh/tes/models/validators/rfc2386_validator.py @@ -2,9 +2,7 @@ import re -from pydantic import BaseModel - -from .base.validator import BaseValidator +from .base.base_validator import BaseValidator class RFC2386Validator(BaseValidator): @@ -17,27 +15,6 @@ class RFC2386Validator(BaseValidator): model (BaseModel): The Pydantic model whose field is being validated. """ - def __init__(self, field: str, model: BaseModel) -> None: - """Initialize the RFC2386Validator. - - Args: - field (str): The field to be validated. - model (BaseModel): The Pydantic model whose field is being validated. - """ - super().__init__() - self._field = field - self._model = model - - @property - def field(self) -> str: - """Return the field to be validated.""" - return self._field - - @property - def model(self) -> BaseModel: - """Return the Pydantic model whose field is being validated.""" - return self._model - @property def error_message(self) -> str: """Return the error message.""" @@ -46,6 +23,6 @@ def error_message(self) -> str: def validation_logic(self) -> bool: """Validation logic for RFC 2386 standard.""" email_regex = re.compile( - r'^mailto:[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' + r'^mailto:[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$', ) - return bool(re.match(email_regex, self.field)) + return bool(re.match(email_regex, self._field)) diff --git a/tesk/api/ga4gh/tes/models/validators/rfc3339_validator.py b/tesk/api/ga4gh/tes/models/validators/rfc3339_validator.py index 69ca890..504a4a8 100644 --- a/tesk/api/ga4gh/tes/models/validators/rfc3339_validator.py +++ b/tesk/api/ga4gh/tes/models/validators/rfc3339_validator.py @@ -2,9 +2,7 @@ from datetime import datetime -from pydantic import BaseModel - -from .base.validator import BaseValidator +from .base.base_validator import BaseValidator class RFC3339Validator(BaseValidator): @@ -17,27 +15,6 @@ class RFC3339Validator(BaseValidator): _model (Base_model): The Pydantic _model whose field is being validated. """ - def __init__(self, field: str, _model: BaseModel) -> None: - """Initialize the RFC3339Validator. - - Args: - field (str): The date string to be validated. - __model (Base_model): The Pydantic _model whose field is being validated. - """ - super().__init__() - self._field = field - self._model = _model - - @property - def field(self) -> str: - """Return the field to be validated.""" - return self._field - - @property - def model(self) -> BaseModel: - """Return the Pydantic _model whose field is being validated.""" - return self._model - @property def error_message(self) -> str: """Return the error message.""" @@ -45,4 +22,4 @@ def error_message(self) -> str: def validation_logic(self) -> bool: """Validation logic for RFC 3339 standard.""" - return bool(datetime.strptime(self.field, '%Y-%m-%dT%H:%M:%S%z').tzinfo) + return bool(datetime.strptime(self._field, '%Y-%m-%dT%H:%M:%S%z').tzinfo) diff --git a/tesk/api/ga4gh/tes/models/validators/rfc3986_validator.py b/tesk/api/ga4gh/tes/models/validators/rfc3986_validator.py index 70fb27a..e89c218 100644 --- a/tesk/api/ga4gh/tes/models/validators/rfc3986_validator.py +++ b/tesk/api/ga4gh/tes/models/validators/rfc3986_validator.py @@ -2,9 +2,7 @@ import re -from pydantic import BaseModel - -from .base.validator import BaseValidator +from .base.base_validator import BaseValidator class RFC3986Validator(BaseValidator): @@ -17,26 +15,6 @@ class RFC3986Validator(BaseValidator): model (BaseModel): The Pydantic model whose field is being validated. """ - def __init__(self, field: str, model: BaseModel) -> None: - """Initialize the RFC3986Validator. - - Args: - field (str): The URL string to be validated. - model (BaseModel): The Pydantic model whose field is being validated. - """ - self._field = field - self._model = model - - @property - def field(self) -> str: - """Return the field to be validated.""" - return self._field - - @property - def model(self) -> BaseModel: - """Return the Pydantic model whose field is being validated.""" - return self._model - @property def error_message(self) -> str: """Return the error message.""" @@ -56,4 +34,4 @@ def validation_logic(self) -> bool: re.IGNORECASE, ) - return bool(re.match(url_regex, self.field)) + return bool(re.match(url_regex, self._field)) diff --git a/tesk/api/ga4gh/tes/service_info/__init__.py b/tesk/api/ga4gh/tes/service_info/__init__.py new file mode 100644 index 0000000..3f96ad5 --- /dev/null +++ b/tesk/api/ga4gh/tes/service_info/__init__.py @@ -0,0 +1 @@ +"""Service info module for TES API.""" diff --git a/tesk/api/ga4gh/tes/service_info/base/__init__.py b/tesk/api/ga4gh/tes/service_info/base/__init__.py new file mode 100644 index 0000000..84ebabc --- /dev/null +++ b/tesk/api/ga4gh/tes/service_info/base/__init__.py @@ -0,0 +1 @@ +"""ServiceInfo base classes.""" diff --git a/tesk/api/ga4gh/tes/service_info/base/base_service_info_request.py b/tesk/api/ga4gh/tes/service_info/base/base_service_info_request.py new file mode 100644 index 0000000..71f9535 --- /dev/null +++ b/tesk/api/ga4gh/tes/service_info/base/base_service_info_request.py @@ -0,0 +1,122 @@ +"""Base class for the TES API service info endpoint.""" + +import os +from abc import abstractmethod +from hashlib import sha256 +from threading import Thread +from time import sleep +from typing import Optional, final + +from yaml import safe_load + +from tesk.api.ga4gh.tes.base.base_tesk_request import BaseTeskRequest +from tesk.api.ga4gh.tes.models.service_info import TesServiceInfo + + +class BaseServiceInfoRequest(BaseTeskRequest): + """Base class for the TES API service info endpoint. + + Abstraction class to hide away reload and parsing of service info from + the config file. + """ + + def __init__(self) -> None: + """Initializes the BaseServiceInfoRequest class. + + Attributes: + _prev_service_info_hash (str): Hash of the service info configuration. + _service_info (TesServiceInfo): Service info Pydantic model. + """ + super().__init__() + self._prev_service_info_hash = self._hash_service_info_config() + self._tesk_service_info_reload_interval = os.getenv( + 'TESK_SERVICE_INFO_RELOAD_INTERVAL', + float(60 * 60), + ) + self._service_info = self._get_service_info() + thread = Thread(target=self._reload_service_info) + thread.start() + + @abstractmethod + def _get_default_service_info(self) -> TesServiceInfo: + """Loads the default service info.""" + pass + + @final + def _reload_service_info(self) -> None: + """Reloads the service info.""" + while True: + if self._service_info_in_config_changed(): + self._get_service_info() + sleep(float(self._tesk_service_info_reload_interval)) + + @final + def _get_service_info(self) -> TesServiceInfo: + """Get and set the service info. + + Returns: + TesServiceInfo: Pydantic model representing service info. + """ + if _service_info := self._get_service_info_present_in_config(): + self._service_info = _service_info + else: + self._service_info = self._get_default_service_info() + return self._service_info + + @final + def _service_info_present_in_config(self) -> bool: + """Checks if service info is present in the config file. + + Returns: + bool: True if service info is present in the config file, + False otherwise. + """ + with open(self._tesk_foca_config_path) as config_file: + _config = safe_load(config_file) + return 'serviceInfo' in _config + + @final + def _service_info_in_config_changed(self) -> bool: + """Checks if service info changed in the config file and updates the hash. + + Returns: + bool: True if the service info in the config file has changed, + False otherwise. + """ + _new_hash = self._hash_service_info_config() + if _new_hash == self._prev_service_info_hash: + return False + self._prev_service_info_hash = _new_hash + return True + + @final + def _get_service_info_present_in_config(self) -> Optional[TesServiceInfo]: + """Get service info if present in the config file. + + Returns: + TesServiceInfo: Pydantic model representing service info from config file + if present, None otherwise. + + Raises: + NotFound: If service info is not present in the config file. + """ + if not self._service_info_present_in_config(): + return None + with open(self._tesk_foca_config_path) as config_file: + _config = safe_load(config_file) + _tes_service_info = _config['serviceInfo'] + return TesServiceInfo(**_tes_service_info) + + @final + def _hash_service_info_config(self) -> str: + """Hashes the service info configuration. + + Returns: + str: Hash of the service info configuration. + """ + if not self._service_info_present_in_config(): + return '' + _service_info = self._get_service_info_present_in_config() + assert _service_info is not None + _config_to_hash = _service_info.json().encode('utf-8') + return sha256(_config_to_hash).hexdigest() diff --git a/tesk/api/ga4gh/tes/service_info/service_info.py b/tesk/api/ga4gh/tes/service_info/service_info.py new file mode 100644 index 0000000..0c9f1ce --- /dev/null +++ b/tesk/api/ga4gh/tes/service_info/service_info.py @@ -0,0 +1,36 @@ +"""Service info for TES API.""" + +from tesk.api.ga4gh.tes.models.service_info import TesServiceInfo +from tesk.api.ga4gh.tes.models.service_info_organization import ( + TesServiceInfoOrganization, +) +from tesk.api.ga4gh.tes.models.service_info_type import TesServiceInfoType +from tesk.api.ga4gh.tes.service_info.base.base_service_info_request import ( + BaseServiceInfoRequest, +) + + +class ServiceInfo(BaseServiceInfoRequest): + """Service info for TES API.""" + + def _get_default_service_info(self) -> TesServiceInfo: + _example_service_info_type = TesServiceInfoType( + group='org.ga4gh', + artifact='tes', + version='1.1.0', + ) + _example_service_info_organization = TesServiceInfoOrganization( + name='my_organization', + url='https://example.com', + ) + return TesServiceInfo( + id='org.ga4gh.tes', + name='TES', + type=_example_service_info_type, + organization=_example_service_info_organization, + version='1.1.0', + ) + + def api_response(self) -> TesServiceInfo: + """Executes the service info request.""" + return self._get_service_info() diff --git a/tesk/app.py b/tesk/app.py index 52e3d02..73295f6 100644 --- a/tesk/app.py +++ b/tesk/app.py @@ -2,52 +2,21 @@ import logging import os -from pathlib import Path -from connexion import FlaskApp -from foca import Foca +from tesk.tesk_app import TeskApp logger = logging.getLogger(__name__) -def init_app() -> FlaskApp: - """Initialize and return the FOCA app. - - This function initializes the FOCA app by loading the configuration - from the environment variable `TESK_FOCA_CONFIG_PATH` if set, or from - the default path if not. It raises a `FileNotFoundError` if the - configuration file is not found. - - Returns: - FlaskApp: A Connexion application instance. - - Raises: - FileNotFoundError: If the configuration file is not found. - """ - # Determine the configuration path - config_path_env = os.getenv('TESK_FOCA_CONFIG_PATH') - if config_path_env: - config_path = Path(config_path_env).resolve() - else: - config_path = ( - Path(__file__).parents[1] / 'deployment' / 'config.yaml' - ).resolve() - - # Check if the configuration file exists - if not config_path.exists(): - raise FileNotFoundError(f'Config file not found at: {config_path}') - - foca = Foca( - config_file=config_path, - ) - return foca.create_app() - - def main() -> None: """Run FOCA application.""" - app = init_app() - app.run(port=app.port) + if os.getenv('CODE_ENVIRONMENT') != 'dev': + os.environ['CODE_ENVIRONMENT'] = 'prod' + + tesk_app = TeskApp() + tesk_app.run() if __name__ == '__main__': + os.environ['CODE_ENVIRONMENT'] = 'dev' main() diff --git a/tesk/tesk_app.py b/tesk/tesk_app.py new file mode 100644 index 0000000..a3bc224 --- /dev/null +++ b/tesk/tesk_app.py @@ -0,0 +1,169 @@ +"""Base class for the APP used in initialization of API.""" + +import logging +import os + +# import signal +# import subprocess +# import sys +# import threading +# import time +# from hashlib import sha256 +# from multiprocessing import Process, Queue +from pathlib import Path +from typing import final + +from foca import Foca + +logger = logging.getLogger(__name__) + + +class TeskApp: + """TESK API class.""" + + def __init__(self): + """Initializes the TeskApp API. + + Attributes: + foca (Foca): Foca instance. + _tesk_foca_config_path (str): Path to the configuration file. + _app (FlaskApp): Foca app instance. + """ + self._tesk_foca_config_path = '' + self._foca = Foca() + self._app = self._foca.create_app() + self._load_config() + + @final + def run(self) -> None: + """Run the application.""" + self._app.run(port=self._app.port) + + @final + def _load_config_path(self) -> None: + """Loads the configuration path. + + Gets the configuration path from the environment variable + (`TESK_FOCA_CONFIG_PATH`) or uses the default path. + + Raises: + FileNotFoundError: If the configuration file is not found. + """ + logger.info('Loading configuration path...') + if config_path_env := os.getenv('TESK_FOCA_CONFIG_PATH'): + self._tesk_foca_config_path = Path(config_path_env).resolve() + else: + self._tesk_foca_config_path = ( + Path(__file__).parents[1] / 'deployment' / 'config.yaml' + ).resolve() + + if not self._tesk_foca_config_path.exists(): + raise FileNotFoundError( + f'Config file not found at: {self._tesk_foca_config_path}', + ) + + @final + def _load_config(self) -> None: + """Loads the configuration and create FOCA and app instance.""" + self._load_config_path() + self._foca = Foca( + config_file=self._tesk_foca_config_path, + ) + self._app = self._foca.create_app() + + +# class TeskApp: +# """Base class for the APP.""" + +# def __init__(self): +# """Initializes the BaseApp class.""" +# self._reload_interval = int(os.getenv('CONFIG_RELOAD_INTERVAL', int(60*60))) +# self._load_config() +# self._prev_config_hash = self._hash_config() +# self.restart_server = False + +# if self._reload_interval > 0: +# logger.info( +# 'The configuration reload interval is set to ' +# f'{self._reload_interval} seconds.' +# ) +# self._start_reload_thread() +# else: +# logger.info( +# 'The configuration reload interval is set to 0. ' +# 'Configuration reload is disabled.' +# ) + +# def start_app(self): +# """Starts the application.""" +# self._app = self.foca.create_app() +# self._app.run(port=self._app.port) +# self.restart_server = False + +# def run(self) -> None: +# """Run the application.""" +# q = Queue() +# p = Process(target=self.start_app(), args=(q,)) +# p.start() +# while not self.restart_server: +# time.sleep(1) +# p.terminate() +# args = [sys.executable] + [sys.argv[0]] +# subprocess.call(args) + +# # TODO: Implement the reload method, server still doesn't seem restart +# def reload(self) -> None: +# """Reloads the configuration and if changes found restarts the server.""" +# while True: +# time.sleep(self._reload_interval) +# # self.reload() +# _new_hash = self._hash_config() +# if self._prev_config_hash == _new_hash: +# logger.info('Configuration has not changed. Skipping reload.') +# else: +# # reset the hash +# self._prev_config_hash = _new_hash +# logger.info(f'Reloading configuration at {time.time()}') +# # reinitialize the configuration +# self._load_config() +# logger.info('Configuration reloaded, restarting the server.') +# # restart the server +# self.restart_server = True + +# def restart_server(self) -> None: +# """Restarts the server by shutting down and starting it again.""" +# logger.info('Shutting down the server.') +# os.kill(os.getpid(), signal.SIGINT) +# self.run() + +# def _hash_config(self) -> str: +# with open(self._tesk_foca_config_path) as file: +# data = file.read() +# return sha256(data.encode('utf-8')).hexdigest() + +# def _load_config_path(self) -> None: +# """Loads the configuration path.""" +# logger.info('Loading configuration path...') +# if config_path_env := os.getenv('TESK_FOCA_CONFIG_PATH'): +# self._tesk_foca_config_path = Path(config_path_env).resolve() +# else: +# self._tesk_foca_config_path = ( +# Path(__file__).parents[1] / 'deployment' / 'config.yaml' +# ).resolve() + +# if not self._tesk_foca_config_path.exists(): +# raise FileNotFoundError( +# f'Config file not found at: {self._tesk_foca_config_path}' +# ) + +# def _load_config(self) -> None: +# """Loads the configuration.""" +# self._load_config_path() +# self.foca = Foca( +# config_file=self._tesk_foca_config_path, +# ) + +# def _start_reload_thread(self) -> None: +# """Starts a background thread to reload configuration periodically.""" +# thread = threading.Thread(target=self.reload, daemon=True) +# thread.start() From df46853f09b459311e6bce2d9f61f74001f05000 Mon Sep 17 00:00:00 2001 From: Javed Habib Date: Thu, 6 Jun 2024 21:34:24 +0530 Subject: [PATCH 3/6] =?UTF-8?q?validators=20working=20=D9=A9(^=E1=97=9C^?= =?UTF-8?q?=20)=D9=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tesk/api/ga4gh/tes/base/base_tesk_request.py | 2 +- tesk/api/ga4gh/tes/models/service_info.py | 73 +++++++++---------- .../tes/models/service_info_organization.py | 14 +--- .../api/ga4gh/tes/models/service_info_type.py | 9 +-- .../models/validators/base/base_validator.py | 63 ++++++++-------- .../models/validators/rfc2386_validator.py | 12 +-- .../models/validators/rfc3339_validator.py | 52 ++++++++++--- .../models/validators/rfc3986_validator.py | 12 +-- 8 files changed, 122 insertions(+), 115 deletions(-) diff --git a/tesk/api/ga4gh/tes/base/base_tesk_request.py b/tesk/api/ga4gh/tes/base/base_tesk_request.py index af2a7de..04d7add 100644 --- a/tesk/api/ga4gh/tes/base/base_tesk_request.py +++ b/tesk/api/ga4gh/tes/base/base_tesk_request.py @@ -35,7 +35,7 @@ def api_response(self) -> BaseModel: def response(self) -> dict: """Returns serialized response. - Should be envoked by controller. + Should be invoked by controller. Returns: dict: Serialized response for the specific endpoint. diff --git a/tesk/api/ga4gh/tes/models/service_info.py b/tesk/api/ga4gh/tes/models/service_info.py index 6806504..ea6fa56 100644 --- a/tesk/api/ga4gh/tes/models/service_info.py +++ b/tesk/api/ga4gh/tes/models/service_info.py @@ -1,14 +1,18 @@ """TesServiceInfo model, used to represent the service information.""" import logging +from contextlib import suppress from typing import List, Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, ValidationError, validator from tesk.api.ga4gh.tes.models.service_info_organization import ( TesServiceInfoOrganization, ) from tesk.api.ga4gh.tes.models.service_info_type import TesServiceInfoType +from tesk.api.ga4gh.tes.models.validators.rfc2386_validator import RFC2386Validator +from tesk.api.ga4gh.tes.models.validators.rfc3339_validator import RFC3339Validator +from tesk.api.ga4gh.tes.models.validators.rfc3986_validator import RFC3986Validator logger = logging.getLogger(__name__) @@ -154,45 +158,36 @@ class TesServiceInfo(BaseModel): ), ) - # @validator('id', 'name', 'type', 'organization', 'version') - # def not_empty(cls, v): - # """Validate that the value is not empty.""" - # if not v: - # logger.error(f'Field {v} must not be empty in model {cls.__name__}.') - # raise ValueError(f'Field {v} must not be empty in model {cls.__name__}.') - # return v - - # @validator('documentationUrl') - # def validate_url(cls, v): - # """Validate the documentationURL format based on RFC 3986 standard.""" - # if v: - # validator = RFC3986Validator(field=v, model=cls) - # return validator.validate() - # return v - - # @validator('contactUrl') - # def validate_url_and_email(cls, v): - # """Validate the contactURL format based on RFC 3986 or 2368 standard.""" - # if v: - # url_validator = RFC3986Validator(field=v, model=cls) - # email_validator = RFC2386Validator(field=v, model=cls) + # Remarks: + # @unique: There is a better way to do this, create a ValidateClass, which + # has all the validators and date sanitizers, create a + # BaseTeskModel(BaseModel, ValidateClass), this class will then be implemented + # by all the models, and the validators will be reused. + # The issue with this approach is that we don't have consistent field names and I + # feel they might be subject to change in future or among different models. + # This is why I have not implemented this approach. + # + # Another cool approach would be to create a decorator, I tried that but given + # FOCA uses pydantic v1.*, and if FOCA is to be upgraded to v2.*, the decorator + # validate would be removed and the code would break. + + # validators + _ = validator('documentationUrl', allow_reuse=True)(RFC3986Validator().validate) + _ = validator('created_at', 'updatedAt', allow_reuse=True)( + RFC3339Validator().validate + ) - # with suppress(ValidationError): - # return email_validator.validate() + @validator('contactUrl') + def validate_url_and_email(cls, v): + """Validate the contactURL format based on RFC 3986 or 2368 standard.""" + url_validator = RFC3986Validator() + email_validator = RFC2386Validator() - # with suppress(ValidationError): - # return url_validator.validate() + with suppress(ValidationError): + return email_validator.validate(cls, v) - # logger.error('contactUrl must be based on RFC 3986 or 2368 standard.') - # raise ValidationError( - # 'contactUrl must be based on RFC 3986 or 2368 standard.' - # ) - # return v + with suppress(ValidationError): + return url_validator.validate(cls, v) - # @validator('createdAt', 'updatedAt') - # def validate_timestamp(cls, v): - # """Validate the timestamp format based on RFC 3339 standard.""" - # if v: - # date_validator = RFC3339Validator(field=v, model=cls) - # return date_validator.validate() - # return v + logger.error('contactUrl must be based on RFC 3986 or 2368 standard.') + raise ValidationError('contactUrl must be based on RFC 3986 or 2368 standard.') diff --git a/tesk/api/ga4gh/tes/models/service_info_organization.py b/tesk/api/ga4gh/tes/models/service_info_organization.py index dc19328..3d76b37 100644 --- a/tesk/api/ga4gh/tes/models/service_info_organization.py +++ b/tesk/api/ga4gh/tes/models/service_info_organization.py @@ -30,15 +30,5 @@ class TesServiceInfoOrganization(BaseModel): description='URL of the website of the organization (RFC 3986 format).', ) - @validator('name', 'url') - def not_empty(cls, v): - """Validate that the value is not empty.""" - if not v: - raise ValueError(f'{v} must not be empty.') - return v - - @validator('url') - def validate_url(cls, v): - """Validate the URL format based on RFC 3986 standard.""" - validator = RFC3986Validator(field=v, model=cls) - return validator.validate() + # validators + _ = validator('url')(RFC3986Validator().validate) diff --git a/tesk/api/ga4gh/tes/models/service_info_type.py b/tesk/api/ga4gh/tes/models/service_info_type.py index b78d0f6..e4601bc 100644 --- a/tesk/api/ga4gh/tes/models/service_info_type.py +++ b/tesk/api/ga4gh/tes/models/service_info_type.py @@ -2,7 +2,7 @@ from typing import Literal -from pydantic import BaseModel, Field, validator +from pydantic import BaseModel, Field class TesServiceInfoType(BaseModel): @@ -49,10 +49,3 @@ class TesServiceInfoType(BaseModel): 'GA4GH specifications use semantic versioning.' ), ) - - @validator('group', 'artifact', 'version') - def not_empty(cls, v): - """Validate that the value is not empty.""" - if not v: - raise ValueError('must not be empty') - return v diff --git a/tesk/api/ga4gh/tes/models/validators/base/base_validator.py b/tesk/api/ga4gh/tes/models/validators/base/base_validator.py index 5afbf6f..6049c7a 100644 --- a/tesk/api/ga4gh/tes/models/validators/base/base_validator.py +++ b/tesk/api/ga4gh/tes/models/validators/base/base_validator.py @@ -2,26 +2,21 @@ import logging from abc import ABC, abstractmethod -from typing import Generic, TypeVar, final +from typing import Any, Generic, TypeVar -from pydantic import BaseModel, ValidationError +from pydantic import ValidationError T = TypeVar('T') logger = logging.getLogger(__name__) class BaseValidator(ABC, Generic[T]): - """Base custom validator class.""" + """Base custom validator class. - def __init__(self, field: T, model: BaseModel) -> None: - """Initialize the validator. - - Args: - field (T): The field to be validated. - model (BaseModel): The pydantic model whose field is being validated. - """ - self._field = field - self._model = model + Validators assume that the filed being validated is not optional as + optional fields are handled by the Pydantic model itself and mypy type + checking. + """ @property @abstractmethod @@ -34,41 +29,51 @@ def error_message(self) -> str: pass @abstractmethod - def validation_logic(self) -> bool: + def validation_logic(self, v: T) -> bool: """Validation logic for the field. + Args: + v: The value being validated. + Returns: - True if the validation is successful, False otherwise. + bool: True if the validation is successful, False otherwise. """ pass - @final - def _raise_error(self): + def _raise_error(self, cls: Any, v: T): """Raise a validation error. + Args: + cls: The class being validated. + v: The value being validated. + Raises: ValidationError: Raised when the validation fails. """ - logger.error(f""" - Validation failed for {self._field} in {self._model.__name__}. - """) + logger.error(f'Validation failed for {v} in {cls.__name__}.') raise ValidationError( self.error_message, - model=self._model, - fields={self._field}, + model=cls, ) - @final - def validate(self) -> T: + def validate(self, cls: Any, v: T) -> T: """Validate the value. + If the value is None, ie the fields is optional + in the model, then it is returned as is without any validation. + + Args: + cls: The class being validated. + v: The value being validated. + Returns: - The validated value (if valid). + T: The validated value (if valid). Raises: - ValueError: If the value is not valid. + ValidationError: If the value is not valid. """ - if not self.validation_logic(): - self._raise_error() - - return self._field + if not v: + return v + elif not self.validation_logic(v): + self._raise_error(cls, v) + return v diff --git a/tesk/api/ga4gh/tes/models/validators/rfc2386_validator.py b/tesk/api/ga4gh/tes/models/validators/rfc2386_validator.py index 455e02e..1f6ab74 100644 --- a/tesk/api/ga4gh/tes/models/validators/rfc2386_validator.py +++ b/tesk/api/ga4gh/tes/models/validators/rfc2386_validator.py @@ -2,17 +2,13 @@ import re -from .base.base_validator import BaseValidator +from tesk.api.ga4gh.tes.models.validators.base.base_validator import BaseValidator -class RFC2386Validator(BaseValidator): +class RFC2386Validator(BaseValidator[str]): """RFC2386 email validator. Validate an email based on RFC 2386 standard. - - Attributes: - field (str): The email string to be validated. - model (BaseModel): The Pydantic model whose field is being validated. """ @property @@ -20,9 +16,9 @@ def error_message(self) -> str: """Return the error message.""" return 'Invalid email format, only RFC 2386 standard allowed.' - def validation_logic(self) -> bool: + def validation_logic(self, v: str) -> bool: """Validation logic for RFC 2386 standard.""" email_regex = re.compile( r'^mailto:[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$', ) - return bool(re.match(email_regex, self._field)) + return bool(re.match(email_regex, v)) diff --git a/tesk/api/ga4gh/tes/models/validators/rfc3339_validator.py b/tesk/api/ga4gh/tes/models/validators/rfc3339_validator.py index 504a4a8..05ce59e 100644 --- a/tesk/api/ga4gh/tes/models/validators/rfc3339_validator.py +++ b/tesk/api/ga4gh/tes/models/validators/rfc3339_validator.py @@ -1,18 +1,15 @@ """RFC3339 date validator.""" -from datetime import datetime +import calendar +import re -from .base.base_validator import BaseValidator +from tesk.api.ga4gh.tes.models.validators.base.base_validator import BaseValidator -class RFC3339Validator(BaseValidator): +class RFC3339Validator(BaseValidator[str]): """RFC3339Validator date validator. Validate a date based on RFC 3339 standard. - - Attributes: - field (str): The date string to be validated. - _model (Base_model): The Pydantic _model whose field is being validated. """ @property @@ -20,6 +17,41 @@ def error_message(self) -> str: """Return the error message.""" return 'Invalid date format, only RFC 3339 standard allowed.' - def validation_logic(self) -> bool: - """Validation logic for RFC 3339 standard.""" - return bool(datetime.strptime(self._field, '%Y-%m-%dT%H:%M:%S%z').tzinfo) + def validation_logic(self, v: str) -> bool: + """Validation logic for RFC 3339 standard. + + Cf. https://github.com/naimetti/rfc3339-validator + """ + date_regex = re.compile( + r""" + ^ + (\d{4}) # Year + - + (0[1-9]|1[0-2]) # Month + - + (\d{2}) # Day + T + (?:[01]\d|2[0123]) # Hours + : + (?:[0-5]\d) # Minutes + : + (?:[0-5]\d) # Seconds + (?:\.\d+)? # Secfrac + (?: Z # UTC + | [+-](?:[01]\d|2[0123]):[0-5]\d # Offset + ) + $ + """, + re.VERBOSE, + ) + _match = date_regex.match(v) + if _match is None: + return False + year, month, day = map(int, _match.groups()) + if not year: + # Year 0 is not valid a valid date + return False + (_, max_day) = calendar.monthrange(year, month) + if not 1 <= day <= max_day: + return False + return True diff --git a/tesk/api/ga4gh/tes/models/validators/rfc3986_validator.py b/tesk/api/ga4gh/tes/models/validators/rfc3986_validator.py index e89c218..8faa2bf 100644 --- a/tesk/api/ga4gh/tes/models/validators/rfc3986_validator.py +++ b/tesk/api/ga4gh/tes/models/validators/rfc3986_validator.py @@ -2,17 +2,13 @@ import re -from .base.base_validator import BaseValidator +from tesk.api.ga4gh.tes.models.validators.base.base_validator import BaseValidator -class RFC3986Validator(BaseValidator): +class RFC3986Validator(BaseValidator[str]): """RFC3986Validator URL validator. Validate a URL based on RFC 3986 standard. - - Attributes: - field (str): The URL string to be validated. - model (BaseModel): The Pydantic model whose field is being validated. """ @property @@ -20,7 +16,7 @@ def error_message(self) -> str: """Return the error message.""" return 'Invalid URL format, only RFC 3986 standard allowed.' - def validation_logic(self) -> bool: + def validation_logic(self, v: str) -> bool: """Validate a URL based on RFC 3986 standard.""" url_regex = re.compile( r'^(?:http|ftp)s?://' # http:// or https:// @@ -34,4 +30,4 @@ def validation_logic(self) -> bool: re.IGNORECASE, ) - return bool(re.match(url_regex, self._field)) + return bool(re.match(url_regex, v)) From eb9da5a99d468541cbb38bc3622760d310989c92 Mon Sep 17 00:00:00 2001 From: Javed Habib Date: Thu, 6 Jun 2024 23:00:09 +0530 Subject: [PATCH 4/6] =?UTF-8?q?reload=20thingy=20=CA=98=E2=80=BF=CA=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{service_info.py => tes_service_info.py} | 6 +++--- ...zation.py => tes_service_info_organization.py} | 0 ...vice_info_type.py => tes_service_info_type.py} | 0 .../tes/models/validators/base/base_validator.py | 8 ++++++++ .../base/base_service_info_request.py | 15 ++++++++------- tesk/api/ga4gh/tes/service_info/service_info.py | 8 ++++---- tesk/tesk_app.py | 15 +++++++++++++++ 7 files changed, 38 insertions(+), 14 deletions(-) rename tesk/api/ga4gh/tes/models/{service_info.py => tes_service_info.py} (96%) rename tesk/api/ga4gh/tes/models/{service_info_organization.py => tes_service_info_organization.py} (100%) rename tesk/api/ga4gh/tes/models/{service_info_type.py => tes_service_info_type.py} (100%) diff --git a/tesk/api/ga4gh/tes/models/service_info.py b/tesk/api/ga4gh/tes/models/tes_service_info.py similarity index 96% rename from tesk/api/ga4gh/tes/models/service_info.py rename to tesk/api/ga4gh/tes/models/tes_service_info.py index ea6fa56..70c91fc 100644 --- a/tesk/api/ga4gh/tes/models/service_info.py +++ b/tesk/api/ga4gh/tes/models/tes_service_info.py @@ -6,10 +6,10 @@ from pydantic import BaseModel, Field, ValidationError, validator -from tesk.api.ga4gh.tes.models.service_info_organization import ( +from tesk.api.ga4gh.tes.models.tes_service_info_organization import ( TesServiceInfoOrganization, ) -from tesk.api.ga4gh.tes.models.service_info_type import TesServiceInfoType +from tesk.api.ga4gh.tes.models.tes_service_info_type import TesServiceInfoType from tesk.api.ga4gh.tes.models.validators.rfc2386_validator import RFC2386Validator from tesk.api.ga4gh.tes.models.validators.rfc3339_validator import RFC3339Validator from tesk.api.ga4gh.tes.models.validators.rfc3986_validator import RFC3986Validator @@ -159,7 +159,7 @@ class TesServiceInfo(BaseModel): ) # Remarks: - # @unique: There is a better way to do this, create a ValidateClass, which + # @uniqueg: There is a better way to do this, create a ValidateClass, which # has all the validators and date sanitizers, create a # BaseTeskModel(BaseModel, ValidateClass), this class will then be implemented # by all the models, and the validators will be reused. diff --git a/tesk/api/ga4gh/tes/models/service_info_organization.py b/tesk/api/ga4gh/tes/models/tes_service_info_organization.py similarity index 100% rename from tesk/api/ga4gh/tes/models/service_info_organization.py rename to tesk/api/ga4gh/tes/models/tes_service_info_organization.py diff --git a/tesk/api/ga4gh/tes/models/service_info_type.py b/tesk/api/ga4gh/tes/models/tes_service_info_type.py similarity index 100% rename from tesk/api/ga4gh/tes/models/service_info_type.py rename to tesk/api/ga4gh/tes/models/tes_service_info_type.py diff --git a/tesk/api/ga4gh/tes/models/validators/base/base_validator.py b/tesk/api/ga4gh/tes/models/validators/base/base_validator.py index 6049c7a..dd0206d 100644 --- a/tesk/api/ga4gh/tes/models/validators/base/base_validator.py +++ b/tesk/api/ga4gh/tes/models/validators/base/base_validator.py @@ -28,6 +28,14 @@ def error_message(self) -> str: """ pass + # TODO: Maybe add another abstract method for + # some common sanitization logic that might + # be invoked if validation fails, this way + # we can mitigate some common human errors. + # For example, if some one passes, mail@exmaple.com + # we can sanitize it to mailto:mail@example.com rather + # than throwing an error or potentially breaking the app. + @abstractmethod def validation_logic(self, v: T) -> bool: """Validation logic for the field. diff --git a/tesk/api/ga4gh/tes/service_info/base/base_service_info_request.py b/tesk/api/ga4gh/tes/service_info/base/base_service_info_request.py index 71f9535..e1ab716 100644 --- a/tesk/api/ga4gh/tes/service_info/base/base_service_info_request.py +++ b/tesk/api/ga4gh/tes/service_info/base/base_service_info_request.py @@ -10,7 +10,7 @@ from yaml import safe_load from tesk.api.ga4gh.tes.base.base_tesk_request import BaseTeskRequest -from tesk.api.ga4gh.tes.models.service_info import TesServiceInfo +from tesk.api.ga4gh.tes.models.tes_service_info import TesServiceInfo class BaseServiceInfoRequest(BaseTeskRequest): @@ -18,15 +18,16 @@ class BaseServiceInfoRequest(BaseTeskRequest): Abstraction class to hide away reload and parsing of service info from the config file. + + Attributes: + _prev_service_info_hash (str): Hash of the service info configuration taken + at init or after a set interval. + _service_info (TesServiceInfo): Service info Pydantic model. + _tesk_service_info_reload_interval (float): Interval to reload the service info. """ def __init__(self) -> None: - """Initializes the BaseServiceInfoRequest class. - - Attributes: - _prev_service_info_hash (str): Hash of the service info configuration. - _service_info (TesServiceInfo): Service info Pydantic model. - """ + """Initializes the BaseServiceInfoRequest class.""" super().__init__() self._prev_service_info_hash = self._hash_service_info_config() self._tesk_service_info_reload_interval = os.getenv( diff --git a/tesk/api/ga4gh/tes/service_info/service_info.py b/tesk/api/ga4gh/tes/service_info/service_info.py index 0c9f1ce..57e8955 100644 --- a/tesk/api/ga4gh/tes/service_info/service_info.py +++ b/tesk/api/ga4gh/tes/service_info/service_info.py @@ -1,10 +1,10 @@ """Service info for TES API.""" -from tesk.api.ga4gh.tes.models.service_info import TesServiceInfo -from tesk.api.ga4gh.tes.models.service_info_organization import ( +from tesk.api.ga4gh.tes.models.tes_service_info import TesServiceInfo +from tesk.api.ga4gh.tes.models.tes_service_info_organization import ( TesServiceInfoOrganization, ) -from tesk.api.ga4gh.tes.models.service_info_type import TesServiceInfoType +from tesk.api.ga4gh.tes.models.tes_service_info_type import TesServiceInfoType from tesk.api.ga4gh.tes.service_info.base.base_service_info_request import ( BaseServiceInfoRequest, ) @@ -33,4 +33,4 @@ def _get_default_service_info(self) -> TesServiceInfo: def api_response(self) -> TesServiceInfo: """Executes the service info request.""" - return self._get_service_info() + return self._service_info diff --git a/tesk/tesk_app.py b/tesk/tesk_app.py index a3bc224..52f363d 100644 --- a/tesk/tesk_app.py +++ b/tesk/tesk_app.py @@ -18,6 +18,12 @@ logger = logging.getLogger(__name__) +# TODO: Maybe TeskApp should be a singleton, and extend the Foca class, so that +# we can have a single instance of the app, and we can access the configuration +# and other attributes from the instance itself. This way we can avoid passing +# the configuration file path to the Foca class, and we can have a single point +# of access to the configuration and other attributes. + class TeskApp: """TESK API class.""" @@ -72,6 +78,15 @@ def _load_config(self) -> None: self._app = self._foca.create_app() +# Remarks: +# @unique, Below code was in an attempt to restart the Flask server, but it +# doesn't seem to work, the server doesn't restart, it breaks down. I saw that +# java implementation had a reload time period and spent embarrassingly long time +# trying to abstract that away here, failed to do so. Maybe FOCA can have a feature +# to reload the configuration and restart the server, but I am not sure if that is +# a good idea, as it might break the server in the middle of a request. + + # class TeskApp: # """Base class for the APP.""" From 27506c552ae3f52933217f30c6a884bfd91471ed Mon Sep 17 00:00:00 2001 From: Javed Habib Date: Fri, 7 Jun 2024 16:46:19 +0530 Subject: [PATCH 5/6] =?UTF-8?q?ci=20=20=CA=98=E2=80=BF=CA=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/code_quality.yaml | 4 ++-- mypy.ini | 6 ++++++ poetry.lock | 2 +- pyproject.toml | 1 + tesk/api/ga4gh/tes/base/base_tesk_request.py | 3 ++- .../tes/service_info/base/base_service_info_request.py | 5 ++--- tesk/tesk_app.py | 10 ++++++---- 7 files changed, 20 insertions(+), 11 deletions(-) diff --git a/.github/workflows/code_quality.yaml b/.github/workflows/code_quality.yaml index f3084be..f3a4cb9 100644 --- a/.github/workflows/code_quality.yaml +++ b/.github/workflows/code_quality.yaml @@ -58,8 +58,8 @@ jobs: with: os: ${{ job.os }} python-version: '3.11' - poetry-install-options: "--only=types --no-root" - poetry-export-options: "--only=types" + poetry-install-options: "--only=main --only=types --no-root" + poetry-export-options: "--only=main --only=types" - name: Check types run: poetry run mypy tesk/ diff --git a/mypy.ini b/mypy.ini index 00fe171..d6a5ed9 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,6 +1,7 @@ # Global options: [mypy] +plugins = pydantic.mypy warn_return_any = True warn_unused_configs = True @@ -12,3 +13,8 @@ ignore_missing_imports = True [mypy-foca] ignore_missing_imports = True + +[pydantic-mypy] +init_forbid_extra = True +init_typed = True +warn_required_dynamic_aliases = True \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index b666d83..8fcbc77 100644 --- a/poetry.lock +++ b/poetry.lock @@ -3176,4 +3176,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "2b2f816322f2b591b92a3f634e489dbf7b75c5cbd8d31f1cf15dba62d9b1c7a2" +content-hash = "55e5aea41ba1ff3fc04a95ba459e4869a60f1c14f10202bf00116b5272ff772f" diff --git a/pyproject.toml b/pyproject.toml index 3235e6e..905e65a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,6 +69,7 @@ types-botocore = "^1.0.2" types-requests = "^2.31.0.20240406" types-urllib3 = "^1.26.25.14" types-werkzeug = "^1.0.9" +types-pyyaml = "^6.0.12.20240311" [tool.poetry.scripts] api = 'tesk.app:main' diff --git a/tesk/api/ga4gh/tes/base/base_tesk_request.py b/tesk/api/ga4gh/tes/base/base_tesk_request.py index 04d7add..ec0c8ae 100644 --- a/tesk/api/ga4gh/tes/base/base_tesk_request.py +++ b/tesk/api/ga4gh/tes/base/base_tesk_request.py @@ -41,5 +41,6 @@ def response(self) -> dict: dict: Serialized response for the specific endpoint. """ _response = self.api_response() - assert isinstance(_response, BaseModel) + if not isinstance(_response, BaseModel): + raise TypeError('API response must be a Pydantic model.') return _response.dict() diff --git a/tesk/api/ga4gh/tes/service_info/base/base_service_info_request.py b/tesk/api/ga4gh/tes/service_info/base/base_service_info_request.py index e1ab716..6f59edb 100644 --- a/tesk/api/ga4gh/tes/service_info/base/base_service_info_request.py +++ b/tesk/api/ga4gh/tes/service_info/base/base_service_info_request.py @@ -115,9 +115,8 @@ def _hash_service_info_config(self) -> str: Returns: str: Hash of the service info configuration. """ - if not self._service_info_present_in_config(): - return '' _service_info = self._get_service_info_present_in_config() - assert _service_info is not None + if _service_info is None: + return '' _config_to_hash = _service_info.json().encode('utf-8') return sha256(_config_to_hash).hexdigest() diff --git a/tesk/tesk_app.py b/tesk/tesk_app.py index 52f363d..e7f73aa 100644 --- a/tesk/tesk_app.py +++ b/tesk/tesk_app.py @@ -19,10 +19,12 @@ # TODO: Maybe TeskApp should be a singleton, and extend the Foca class, so that -# we can have a single instance of the app, and we can access the configuration -# and other attributes from the instance itself. This way we can avoid passing -# the configuration file path to the Foca class, and we can have a single point -# of access to the configuration and other attributes. +# we can have a single instance of the app, and we can access the +# configuration and other attributes from the instance itself. +# This way we can avoid passing the configuration file path to the Foca class, +# and we can have a single point of access to the configuration and +# other attributes. + class TeskApp: """TESK API class.""" From 0b9aa643ce7d85f976871a3528749b80abfe7655 Mon Sep 17 00:00:00 2001 From: Javed Habib Date: Fri, 7 Jun 2024 17:28:18 +0530 Subject: [PATCH 6/6] =?UTF-8?q?nitpick=20(=20=CB=98=E2=96=BD=CB=98)?= =?UTF-8?q?=E3=81=A3=E2=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tesk/api/ga4gh/tes/base/base_tesk_request.py | 4 ++-- tesk/api/ga4gh/tes/controllers.py | 3 ++- tesk/api/ga4gh/tes/models/tes_service_info.py | 6 ++++-- tesk/api/ga4gh/tes/models/validators/base/base_validator.py | 2 +- tesk/api/ga4gh/tes/models/validators/rfc3339_validator.py | 4 +--- tesk/api/ga4gh/tes/service_info/service_info.py | 6 +++++- 6 files changed, 15 insertions(+), 10 deletions(-) diff --git a/tesk/api/ga4gh/tes/base/base_tesk_request.py b/tesk/api/ga4gh/tes/base/base_tesk_request.py index ec0c8ae..30a234c 100644 --- a/tesk/api/ga4gh/tes/base/base_tesk_request.py +++ b/tesk/api/ga4gh/tes/base/base_tesk_request.py @@ -1,7 +1,7 @@ """Base classes for the TES API request.""" from abc import ABC, abstractmethod -from typing import final +from typing import Any, final from pydantic import BaseModel @@ -32,7 +32,7 @@ def api_response(self) -> BaseModel: pass @final - def response(self) -> dict: + def response(self) -> dict[Any, Any]: """Returns serialized response. Should be invoked by controller. diff --git a/tesk/api/ga4gh/tes/controllers.py b/tesk/api/ga4gh/tes/controllers.py index 7a355b4..80fb999 100644 --- a/tesk/api/ga4gh/tes/controllers.py +++ b/tesk/api/ga4gh/tes/controllers.py @@ -1,6 +1,7 @@ """Controllers for GA4GH TES API endpoints.""" import logging +from typing import Any # from connexion import request # type: ignore from foca.utils.logging import log_traffic # type: ignore @@ -38,7 +39,7 @@ def CreateTask(*args, **kwargs) -> dict: # type: ignore # GET /tasks/service-info @log_traffic -def GetServiceInfo() -> dict: +def GetServiceInfo() -> dict[Any, Any]: """Get service info.""" service_info = ServiceInfo() return service_info.response() diff --git a/tesk/api/ga4gh/tes/models/tes_service_info.py b/tesk/api/ga4gh/tes/models/tes_service_info.py index 70c91fc..abf4ffa 100644 --- a/tesk/api/ga4gh/tes/models/tes_service_info.py +++ b/tesk/api/ga4gh/tes/models/tes_service_info.py @@ -178,7 +178,7 @@ class TesServiceInfo(BaseModel): ) @validator('contactUrl') - def validate_url_and_email(cls, v): + def validate_url_and_email(cls, v: str) -> str: """Validate the contactURL format based on RFC 3986 or 2368 standard.""" url_validator = RFC3986Validator() email_validator = RFC2386Validator() @@ -190,4 +190,6 @@ def validate_url_and_email(cls, v): return url_validator.validate(cls, v) logger.error('contactUrl must be based on RFC 3986 or 2368 standard.') - raise ValidationError('contactUrl must be based on RFC 3986 or 2368 standard.') + raise ValidationError( + 'contactUrl must be based on RFC 3986 or 2368 standard.', model=cls + ) diff --git a/tesk/api/ga4gh/tes/models/validators/base/base_validator.py b/tesk/api/ga4gh/tes/models/validators/base/base_validator.py index dd0206d..f5bf3f9 100644 --- a/tesk/api/ga4gh/tes/models/validators/base/base_validator.py +++ b/tesk/api/ga4gh/tes/models/validators/base/base_validator.py @@ -48,7 +48,7 @@ def validation_logic(self, v: T) -> bool: """ pass - def _raise_error(self, cls: Any, v: T): + def _raise_error(self, cls: Any, v: T) -> None: """Raise a validation error. Args: diff --git a/tesk/api/ga4gh/tes/models/validators/rfc3339_validator.py b/tesk/api/ga4gh/tes/models/validators/rfc3339_validator.py index 05ce59e..4f19093 100644 --- a/tesk/api/ga4gh/tes/models/validators/rfc3339_validator.py +++ b/tesk/api/ga4gh/tes/models/validators/rfc3339_validator.py @@ -52,6 +52,4 @@ def validation_logic(self, v: str) -> bool: # Year 0 is not valid a valid date return False (_, max_day) = calendar.monthrange(year, month) - if not 1 <= day <= max_day: - return False - return True + return 1 <= day <= max_day diff --git a/tesk/api/ga4gh/tes/service_info/service_info.py b/tesk/api/ga4gh/tes/service_info/service_info.py index 57e8955..9a6058e 100644 --- a/tesk/api/ga4gh/tes/service_info/service_info.py +++ b/tesk/api/ga4gh/tes/service_info/service_info.py @@ -1,4 +1,8 @@ -"""Service info for TES API.""" +"""Service info for TES API. + +This module provides the TesServiceInfo class, which is +the response to the service info request for the TES API. +""" from tesk.api.ga4gh.tes.models.tes_service_info import TesServiceInfo from tesk.api.ga4gh.tes.models.tes_service_info_organization import (