From 17c7fef59743dbbdbd158f26e365e7b8409d92eb Mon Sep 17 00:00:00 2001 From: Thomas Pollet Date: Sun, 1 Sep 2024 07:32:02 +0200 Subject: [PATCH 1/5] refactoring --- pyproject.toml | 3 ++- safrs/_safrs_relationship.py | 50 +++++++++++++++++++++--------------- safrs/api_methods.py | 32 ++++++++++------------- safrs/attr_parse.py | 1 + safrs/base.py | 19 +++++++------- safrs/json_encoder.py | 13 +++++----- setup.py | 2 +- 7 files changed, 63 insertions(+), 57 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index db4d4b5..53d60c4 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,8 @@ classifiers = [ # Optional # Indicate who your project is intended for "Intended Audience :: Developers", - "Topic :: Software Development :: API Development", + "Topic :: Software Development :: Libraries", + "Framework :: Flask", "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.7", diff --git a/safrs/_safrs_relationship.py b/safrs/_safrs_relationship.py index 41e2ec9..cb8d8c5 100755 --- a/safrs/_safrs_relationship.py +++ b/safrs/_safrs_relationship.py @@ -1,4 +1,5 @@ from http import HTTPStatus +from typing import Dict, Tuple, Any from .util import classproperty @@ -6,29 +7,36 @@ class SAFRSRelationshipObject: """ Relationship object, used to emulate a SAFRSBase object for the swagger for relationship targets - so we can call the same methods on a relationship target as we do when using SAFRSBase + so we can call the same methods on a relationship target as we do when using SAFRSBase. """ - _s_class_name = None - __name__ = "name" - http_methods = {"GET", "POST", "PATCH", "DELETE"} - swagger_models = {"instance": None, "collection": None} + _s_class_name: str = None + __name__: str = "name" + http_methods: set = {"GET", "POST", "PATCH", "DELETE"} + swagger_models: Dict[str, Any] = {"instance": None, "collection": None} @classmethod - def _s_get_swagger_doc(cls, http_method): - """Create a swagger api model based on the sqlalchemy schema - if an instance exists in the DB, the first entry is used as example + def _s_get_swagger_doc(cls, http_method: str) -> Tuple[Dict[str, Any], Dict[str, Any]]: + """ + Create a swagger API model based on the SQLAlchemy schema. + If an instance exists in the DB, the first entry is used as an example. + :param http_method: HTTP method for which to generate the doc - :return: swagger body, responses + :return: Tuple containing the swagger body and responses """ - body = {} - responses = {} - object_name = cls.__name__ + body: Dict[str, Any] = {} + responses: Dict[str, Any] = {} + object_name: str = cls.__name__ - object_model = {} - responses = {str(HTTPStatus.OK.value): {"description": f"{object_name} object", "schema": object_model}} + object_model: Dict[str, Any] = {} + responses = { + str(HTTPStatus.OK.value): { + "description": f"{object_name} object", + "schema": object_model + } + } - if http_method.upper() in ("POST", "GET"): + if http_method.upper() in {"POST", "GET"}: responses = { str(HTTPStatus.OK.value): {"description": HTTPStatus.OK.description}, str(HTTPStatus.NOT_FOUND.value): {"description": HTTPStatus.NOT_FOUND.description}, @@ -37,29 +45,29 @@ def _s_get_swagger_doc(cls, http_method): return body, responses @classproperty - def _s_relationships(cls): + def _s_relationships(cls) -> Any: """ :return: The relationship names of the target """ return cls._target._s_relationships @classproperty - def _s_jsonapi_attrs(cls): + def _s_jsonapi_attrs(cls) -> Any: """ - :return: target JSON:API attributes + :return: Target JSON:API attributes """ return cls._target._s_jsonapi_attrs @classproperty - def _s_type(cls): + def _s_type(cls) -> str: """ :return: JSON:API type """ return cls._target._s_type @classproperty - def _s_class_name(cls): + def _s_class_name(cls) -> str: """ - :return: name of the target class + :return: Name of the target class """ return cls._target.__name__ diff --git a/safrs/api_methods.py b/safrs/api_methods.py index c78aee2..9c08937 100755 --- a/safrs/api_methods.py +++ b/safrs/api_methods.py @@ -1,6 +1,4 @@ -# -# jsonapi_rpc methods that can be added to the exposed classes -# +from typing import Any, Dict, List, Tuple from sqlalchemy import or_ from sqlalchemy.orm.session import make_transient import safrs @@ -11,7 +9,7 @@ @jsonapi_rpc(http_methods=["POST"]) -def duplicate(self): +def duplicate(self: Any) -> SAFRSFormattedResponse: """ description: Duplicate an object - copy it and give it a new id """ @@ -21,16 +19,15 @@ def duplicate(self): self.id = self.id_type() session.add(self) session.commit() - response = SAFRSFormattedResponse(self) - return response + return SAFRSFormattedResponse(self) @classmethod @jsonapi_rpc(http_methods=["POST"]) -def lookup_re_mysql(cls, **kwargs): # pragma: no cover +def lookup_re_mysql(cls: Any, **kwargs: Dict[str, str]) -> SAFRSFormattedResponse: # pragma: no cover """ pageable: True - description : Regex search all matching objects (works only in MySQL!!!) + description: Regex search all matching objects (works only in MySQL!!!) args: name: thom.* """ @@ -49,10 +46,10 @@ def lookup_re_mysql(cls, **kwargs): # pragma: no cover @classmethod @jsonapi_rpc(http_methods=["POST"]) -def startswith(cls, **kwargs): # pragma: no cover +def startswith(cls: Any, **kwargs: Dict[str, str]) -> SAFRSFormattedResponse: # pragma: no cover """ pageable: True - summary : lookup items where specified attributes starts with the argument string + summary: Lookup items where specified attributes start with the argument string args: attr_name: value """ @@ -79,7 +76,6 @@ def startswith(cls, **kwargs): # pragma: no cover meta = {} errors = None response = SAFRSFormattedResponse(data, meta, links, errors, count) - except Exception as exc: raise GenericError(f"Failed to execute query {exc}") return response @@ -87,10 +83,10 @@ def startswith(cls, **kwargs): # pragma: no cover @classmethod @jsonapi_rpc(http_methods=["POST"]) -def search(cls, **kwargs): # pragma: no cover +def search(cls: Any, **kwargs: Dict[str, str]) -> SAFRSFormattedResponse: # pragma: no cover """ pageable: True - description : lookup column names + description: Lookup column names args: query: val """ @@ -106,16 +102,15 @@ def search(cls, **kwargs): # pragma: no cover data = [item for item in instances] meta = {} errors = None - response = SAFRSFormattedResponse(data, meta, links, errors, count) - return response + return SAFRSFormattedResponse(data, meta, links, errors, count) @classmethod @jsonapi_rpc(http_methods=["POST"]) -def re_search(cls, **kwargs): # pragma: no cover +def re_search(cls: Any, **kwargs: Dict[str, str]) -> SAFRSFormattedResponse: # pragma: no cover """ pageable: True - description : lookup column names + description: Lookup column names args: query: search.*all """ @@ -126,5 +121,4 @@ def re_search(cls, **kwargs): # pragma: no cover data = [item for item in instances] meta = {} errors = None - response = SAFRSFormattedResponse(data, meta, links, errors, count) - return response + return SAFRSFormattedResponse(data, meta, links, errors, count) diff --git a/safrs/attr_parse.py b/safrs/attr_parse.py index 0ad7189..dec2ddb 100755 --- a/safrs/attr_parse.py +++ b/safrs/attr_parse.py @@ -77,3 +77,4 @@ def parse_attr(column, attr_val): attr_val = column.type.python_type(attr_val) return attr_val + diff --git a/safrs/base.py b/safrs/base.py index c5adfc7..40f2cb4 100755 --- a/safrs/base.py +++ b/safrs/base.py @@ -121,7 +121,7 @@ class SAFRSBase(Model): included_list = None - def __new__(cls, *args, **kwargs): + def __new__(cls, *args, **kwargs) -> SAFRSBase: """ If an object with given arguments already exists, this object is instantiated """ @@ -142,7 +142,7 @@ def __new__(cls, *args, **kwargs): return instance - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: """ Object initialization, called from backend or `_s_post` - set the named attributes and add the object to the database @@ -196,8 +196,8 @@ def __init__(self, *args, **kwargs): except sqlalchemy.exc.SQLAlchemyError as exc: # pragma: no cover # Exception may arise when a DB constrained has been violated (e.g. duplicate key) raise GenericError(exc) - - def __setattr__(self, attr_name, attr_val): + + def __setattr__(self, attr_name: str, attr_val: Any) -> Any: """ setattr behaves differently for `jsonapi_attr` decorated attributes """ @@ -208,7 +208,7 @@ def __setattr__(self, attr_name, attr_val): else: return super().__setattr__(attr_name, attr_val) - def _s_parse_attr_value(self, attr_name: str, attr_val: any): + def _s_parse_attr_value(self, attr_name: str, attr_val: Any) -> Any: """ Parse the given jsonapi attribute value so it can be stored in the db :param attr_name: attribute name @@ -235,14 +235,14 @@ def _s_parse_attr_value(self, attr_name: str, attr_val: any): return parse_attr(attr, attr_val) @classmethod - def _s_get(cls, **kwargs): + def _s_get(cls, **kwargs) -> SAFRSBase: """ This method is called when a collection is requested with a HTTP GET to the json api """ return cls.jsonapi_filter() @classmethod - def _s_post(cls, jsonapi_id=None, **params) -> SAFRSBase: + def _s_post(cls, jsonapi_id: Optional[str] = None, **params: Dict[str, Any]) -> SAFRSBase: """ This method is called when a new item is created with a POST to the json api @@ -292,7 +292,7 @@ def _s_post(cls, jsonapi_id=None, **params) -> SAFRSBase: return instance - def _s_patch(self, **attributes) -> SAFRSBase: + def _s_patch(self, **attributes: Dict[str, Any]) -> SAFRSBase: """ Update the object attributes :param **attributes: @@ -316,7 +316,7 @@ def _s_delete(self) -> None: """ safrs.DB.session.delete(self) - def _add_rels(self, **params) -> None: + def _add_rels(self, **params: Dict[str, Any]) -> None: """ Add relationship data provided in a POST, cfr. https://jsonapi.org/format/#crud-creating **params contains the (HTTP POST) parameters @@ -1227,3 +1227,4 @@ def encode(cls): result.append(included) return result + diff --git a/safrs/json_encoder.py b/safrs/json_encoder.py index 3ced79f..3ade7fb 100755 --- a/safrs/json_encoder.py +++ b/safrs/json_encoder.py @@ -10,6 +10,7 @@ from .config import is_debug from .base import SAFRSBase, Included from .jsonapi_formatting import jsonapi_format_response +from typing import Any class SAFRSFormattedResponse: @@ -24,13 +25,12 @@ class SAFRSFormattedResponse: result = None response = None - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any) -> None: """ - :param data: - :param meta: - :param links: - :param errors: - :param count: + Initialize the response object. + + :param args: Positional arguments for response formatting + :param kwargs: Keyword arguments for response formatting """ self.response = jsonapi_format_response(*args, **kwargs) @@ -161,3 +161,4 @@ class SAFRSJSONEncoder(_SAFRSJSONEncoder, json.JSONEncoder): """ pass + diff --git a/setup.py b/setup.py index d7e86f9..0384454 100755 --- a/setup.py +++ b/setup.py @@ -1,5 +1,5 @@ """ -python setup.py sdist +python -m build twine upload dist/* """ From e1b949c8635e5d8a08ea26c0f3f3dd2d372e4d43 Mon Sep 17 00:00:00 2001 From: Thomas Pollet Date: Mon, 2 Sep 2024 07:22:09 +0200 Subject: [PATCH 2/5] comments --- safrs/base.py | 193 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 184 insertions(+), 9 deletions(-) diff --git a/safrs/base.py b/safrs/base.py index 40f2cb4..e657338 100755 --- a/safrs/base.py +++ b/safrs/base.py @@ -2,6 +2,181 @@ # # pylint: disable=logging-format-interpolation,no-self-argument,no-member,line-too-long,fixme,protected-access # +""" +SAFRSBase class customizable attributes and methods, override these to customize the behavior of the SAFRSBase class. + +http_methods: +Type: List[str] +A list of HTTP methods that are allowed for this class when exposed in the API. +Common methods include 'GET', 'POST', 'PUT', 'DELETE', etc. +This property controls the types of operations that can be performed on instances +of the class via the API. + + +_s_expose: +Type: bool +Description: Indicates whether this class should be exposed in the API. + + +_s_upsert: +Type: bool +Description: Indicates whether to look up and use existing objects during creation. + + +_s_allow_add_rels: +Type: bool +Description: Allows relationships to be added in POST requests. + + +_s_pk_delimiter: +Type: str +Description: Delimiter used for primary keys. + + +_s_url_root: +Type: Optional[str] +Description: URL prefix shown in the "links" field. If not set, request.url_root will be used. + + +_s_columns: +Type: classproperty +Description: List of columns that are exposed by the API. + + +_s_relationships: +Type: hybrid_property +Description: Dictionary of relationships used for JSON:API (de)serialization. + + +_s_jsonapi_attrs: +Type: hybrid_property +Description: Dictionary of exposed attribute names and values. + + +_s_auto_commit: +Type: classproperty +Description: Indicates whether the instance should be automatically committed. + + +_s_check_perm: +Type: hybrid_method +Description: Checks the (instance-level) column permission. + + +_s_jsonapi_encode: +Type: hybrid_method +Description: Encodes the object according to the JSON:API specification. + + +_s_get_related: +Type: method +Description: Returns a dictionary of relationship names to related instances. + + +_s_count: +Type: classmethod +Description: Returns the count of instances in the table. + + +_s_sample_dict: +Type: classmethod +Description: Returns a sample dictionary to be used as an example "attributes" payload in the Swagger example. + + +_s_object_id: +Type: classproperty +Description: Returns the Flask URL parameter name of the object. + + +_s_get_jsonapi_rpc_methods: +Type: classmethod +Description: Returns a list of JSON:API RPC methods for this class. + + +_s_get_swagger_doc: +Type: classmethod +Description: Returns the Swagger body and response dictionaries for the specified HTTP method. + + +_s_sample_id: +Type: classmethod +Description: Returns a sample ID for the API documentation. + + +_s_url: +Type: hybrid_property +Description: Returns the endpoint URL of this instance. + + +_s_meta: +Type: classmethod +Description: Returns the "meta" part of the response. + + +_s_query: +Type: classproperty +Description: Returns the SQLAlchemy query object. + + +_s_class_name: +Type: classproperty +Description: Returns the name of the instances. + + +_s_collection_name: +Type: classproperty +Description: Returns the name of the collection, used to construct the endpoint. + + +_s_type: +Type: classproperty +Description: Returns the JSON:API "type", i.e., the table name if this is a DB model, the class name otherwise. + + +_s_expunge: +Type: method +Description: Expunges an object from its session. + + +_s_get_instance_by_id: +Type: classmethod +Description: Returns the query object for the specified JSON:API ID. + + +_s_parse_attr_value: +Type: method +Description: Parses the given JSON:API attribute value so it can be stored in the DB. + + +_s_post: +Type: classmethod +Description: Called when a new item is created with a POST to the JSON:API. + + +_s_patch: +Type: method +Description: Updates the object attributes. + + +_s_delete: +Type: method +Description: Deletes the instance from the database. + + +_s_get: +Type: classmethod +Description: Called when a collection is requested with an HTTP GET to the JSON:API. + + +_s_clone: +Type: method +Description: Clones an object by copying the parameters and creating a new ID. + + +_s_filter: +Type: classmethod +Description: Applies filters to the query. +""" from __future__ import annotations import inspect import datetime @@ -121,7 +296,7 @@ class SAFRSBase(Model): included_list = None - def __new__(cls, *args, **kwargs) -> SAFRSBase: + def __new__(cls, *args, **kwargs): """ If an object with given arguments already exists, this object is instantiated """ @@ -142,7 +317,7 @@ def __new__(cls, *args, **kwargs) -> SAFRSBase: return instance - def __init__(self, *args, **kwargs) -> None: + def __init__(self, *args, **kwargs): """ Object initialization, called from backend or `_s_post` - set the named attributes and add the object to the database @@ -196,8 +371,8 @@ def __init__(self, *args, **kwargs) -> None: except sqlalchemy.exc.SQLAlchemyError as exc: # pragma: no cover # Exception may arise when a DB constrained has been violated (e.g. duplicate key) raise GenericError(exc) - - def __setattr__(self, attr_name: str, attr_val: Any) -> Any: + + def __setattr__(self, attr_name, attr_val): """ setattr behaves differently for `jsonapi_attr` decorated attributes """ @@ -208,7 +383,7 @@ def __setattr__(self, attr_name: str, attr_val: Any) -> Any: else: return super().__setattr__(attr_name, attr_val) - def _s_parse_attr_value(self, attr_name: str, attr_val: Any) -> Any: + def _s_parse_attr_value(self, attr_name: str, attr_val: any): """ Parse the given jsonapi attribute value so it can be stored in the db :param attr_name: attribute name @@ -235,14 +410,14 @@ def _s_parse_attr_value(self, attr_name: str, attr_val: Any) -> Any: return parse_attr(attr, attr_val) @classmethod - def _s_get(cls, **kwargs) -> SAFRSBase: + def _s_get(cls, **kwargs): """ This method is called when a collection is requested with a HTTP GET to the json api """ return cls.jsonapi_filter() @classmethod - def _s_post(cls, jsonapi_id: Optional[str] = None, **params: Dict[str, Any]) -> SAFRSBase: + def _s_post(cls, jsonapi_id=None, **params) -> SAFRSBase: """ This method is called when a new item is created with a POST to the json api @@ -292,7 +467,7 @@ def _s_post(cls, jsonapi_id: Optional[str] = None, **params: Dict[str, Any]) -> return instance - def _s_patch(self, **attributes: Dict[str, Any]) -> SAFRSBase: + def _s_patch(self, **attributes) -> SAFRSBase: """ Update the object attributes :param **attributes: @@ -316,7 +491,7 @@ def _s_delete(self) -> None: """ safrs.DB.session.delete(self) - def _add_rels(self, **params: Dict[str, Any]) -> None: + def _add_rels(self, **params) -> None: """ Add relationship data provided in a POST, cfr. https://jsonapi.org/format/#crud-creating **params contains the (HTTP POST) parameters From d619add2d53ead7f0e17dfd82316a347bcf105bf Mon Sep 17 00:00:00 2001 From: Thomas Pollet Date: Mon, 2 Sep 2024 07:54:31 +0200 Subject: [PATCH 3/5] black formatting --- safrs/_safrs_relationship.py | 7 +----- safrs/attr_parse.py | 1 - safrs/base.py | 42 +++++++++++++++++------------------- safrs/json_encoder.py | 1 - 4 files changed, 21 insertions(+), 30 deletions(-) diff --git a/safrs/_safrs_relationship.py b/safrs/_safrs_relationship.py index cb8d8c5..23482a5 100755 --- a/safrs/_safrs_relationship.py +++ b/safrs/_safrs_relationship.py @@ -29,12 +29,7 @@ def _s_get_swagger_doc(cls, http_method: str) -> Tuple[Dict[str, Any], Dict[str, object_name: str = cls.__name__ object_model: Dict[str, Any] = {} - responses = { - str(HTTPStatus.OK.value): { - "description": f"{object_name} object", - "schema": object_model - } - } + responses = {str(HTTPStatus.OK.value): {"description": f"{object_name} object", "schema": object_model}} if http_method.upper() in {"POST", "GET"}: responses = { diff --git a/safrs/attr_parse.py b/safrs/attr_parse.py index dec2ddb..0ad7189 100755 --- a/safrs/attr_parse.py +++ b/safrs/attr_parse.py @@ -77,4 +77,3 @@ def parse_attr(column, attr_val): attr_val = column.type.python_type(attr_val) return attr_val - diff --git a/safrs/base.py b/safrs/base.py index e657338..a6d73f7 100755 --- a/safrs/base.py +++ b/safrs/base.py @@ -12,6 +12,26 @@ This property controls the types of operations that can be performed on instances of the class via the API. + +_s_post: +Type: classmethod +Description: Called when a new item is created with a POST to the JSON:API. + + +_s_patch: +Type: method +Description: Updates the object attributes. + + +_s_delete: +Type: method +Description: Deletes the instance from the database. + + +_s_get: +Type: classmethod +Description: Called when a collection is requested with an HTTP GET to the JSON:API. + _s_expose: Type: bool @@ -147,27 +167,6 @@ Type: method Description: Parses the given JSON:API attribute value so it can be stored in the DB. - -_s_post: -Type: classmethod -Description: Called when a new item is created with a POST to the JSON:API. - - -_s_patch: -Type: method -Description: Updates the object attributes. - - -_s_delete: -Type: method -Description: Deletes the instance from the database. - - -_s_get: -Type: classmethod -Description: Called when a collection is requested with an HTTP GET to the JSON:API. - - _s_clone: Type: method Description: Clones an object by copying the parameters and creating a new ID. @@ -1402,4 +1401,3 @@ def encode(cls): result.append(included) return result - diff --git a/safrs/json_encoder.py b/safrs/json_encoder.py index 3ade7fb..6a5eb42 100755 --- a/safrs/json_encoder.py +++ b/safrs/json_encoder.py @@ -161,4 +161,3 @@ class SAFRSJSONEncoder(_SAFRSJSONEncoder, json.JSONEncoder): """ pass - From 9432bd06b7cfd837a83ea09495bb7e0366af40d6 Mon Sep 17 00:00:00 2001 From: Thomas Pollet Date: Thu, 5 Sep 2024 16:21:31 +0200 Subject: [PATCH 4/5] remove unused import --- safrs/api_methods.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/safrs/api_methods.py b/safrs/api_methods.py index 9c08937..8d8ff9b 100755 --- a/safrs/api_methods.py +++ b/safrs/api_methods.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List, Tuple +from typing import Any, Dict, Tuple from sqlalchemy import or_ from sqlalchemy.orm.session import make_transient import safrs From 53ead50384eb9e4df3364e9085756bbd1c92a50c Mon Sep 17 00:00:00 2001 From: Thomas Pollet Date: Thu, 5 Sep 2024 16:26:48 +0200 Subject: [PATCH 5/5] remove unused import --- safrs/api_methods.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/safrs/api_methods.py b/safrs/api_methods.py index 8d8ff9b..047f82d 100755 --- a/safrs/api_methods.py +++ b/safrs/api_methods.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, Tuple +from typing import Any, Dict from sqlalchemy import or_ from sqlalchemy.orm.session import make_transient import safrs