reachcollective-utils 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,147 @@
1
+ Metadata-Version: 2.3
2
+ Name: reachcollective-utils
3
+ Version: 0.1.0
4
+ Summary: Utilities for developing RESTful API, Open API projects with FastAPI and Python
5
+ Author: Juan Minaya
6
+ Author-email: minayaleon@gmail.com
7
+ Requires-Python: >=3.12
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.12
10
+ Classifier: Programming Language :: Python :: 3.13
11
+ Requires-Dist: fastapi[standard] (>=0.115.8,<0.116.0)
12
+ Requires-Dist: pydantic (>=2.10.6,<3.0.0)
13
+ Requires-Dist: sqlmodel (>=0.0.22,<0.0.23)
14
+ Description-Content-Type: text/markdown
15
+
16
+ # ReachCollective / APIs / Utils
17
+
18
+ # Requirements
19
+ - sqlmodel (>=0.0.22,<0.0.23)
20
+ - fastapi (>=0.115.8,<0.116.0)
21
+ - pydantic (>=2.10.6,<3.0.0)
22
+
23
+ # Installation
24
+
25
+ ### Poetry
26
+ ```
27
+ poetry add reachcollective-utils
28
+
29
+ ```
30
+ ### Pip
31
+ ```
32
+ pip install reachcollective-utils
33
+ ```
34
+
35
+ # Examples
36
+
37
+ ## DataGrid
38
+ List and paginate data from a model, accepting filters, sorting, and relationships
39
+
40
+ ### 1. Modify models
41
+ Add `to_dcit()` function to each SQL Model, `to_dcit()` gets relationships.
42
+
43
+ ```python
44
+
45
+ class Post(SQLModel, table=True):
46
+ __tablename__ = "posts"
47
+
48
+ id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
49
+ name: str | None
50
+ status: str | None
51
+ comments: list["Comment"] | None = Relationship(back_populates="post", sa_relationship_kwargs={"lazy": "raise"})
52
+
53
+ def to_dict(self, *args, **kwargs):
54
+ data = super().model_dump(*args, **kwargs)
55
+ if 'comments' in self.__dict__:
56
+ data['comments'] = []
57
+ if self.quota_groups:
58
+ data['comments'] = [quota_group.to_dict(*args, **kwargs) for quota_group in self.quota_groups]
59
+
60
+ return data
61
+
62
+
63
+ class Comment(SQLModel, table=True):
64
+ __tablename__ = "comments"
65
+
66
+ id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
67
+ description: str | None
68
+ post: Optional["Survey"] = Relationship(back_populates="comments", sa_relationship_kwargs={"lazy": "raise"})
69
+
70
+ def to_dict(self, *args, **kwargs):
71
+ data = super().model_dump(*args, **kwargs)
72
+ if 'post' in self.__dict__:
73
+ data['post'] = {}
74
+ if self.survey:
75
+ data['post'] = self.survey.model_dump(*args, **kwargs)
76
+
77
+ return data
78
+ ```
79
+
80
+ ### 2. Basic use
81
+ ```python
82
+ # URL: /posts?sort=-created&filter[status]=active&view=paginate
83
+
84
+
85
+ from app.models import Post
86
+ from reachcollective.utils import DataGrid
87
+
88
+ @router.get('/posts', status_code=HTTPStatus.OK)
89
+ async def list_(
90
+ request: Request,
91
+ db: AsyncSession = Depends(get_session)
92
+ ):
93
+ return await DataGrid(db, Post, request).init().get()
94
+ ```
95
+
96
+ ### 2. Custom filters
97
+ ```python
98
+ # URL: /posts?sort=-created&filter[status]=active&filter[name]=demo&view=paginate
99
+
100
+
101
+ from app.models import Post
102
+ from reachcollective.utils import DataGrid
103
+
104
+ @router.get('/posts', status_code=HTTPStatus.OK)
105
+ async def list_(
106
+ request: Request,
107
+ db: AsyncSession = Depends(get_session)
108
+ ):
109
+ datagrid = DataGrid(db, Post, request)
110
+ datagrid.qp.filters.personalize = ['name']
111
+ datagrid.init()
112
+
113
+ for key, value in datagrid.params['filters']['customize'].items():
114
+ match key:
115
+ case 'name':
116
+ datagrid.qb.stmt = datagrid.qb.stmt.where(Post.name.ilike(f'%{value}%'))
117
+
118
+ return await datagrid.get()
119
+ ```
120
+
121
+ ### 3. Result
122
+ ```json
123
+ {
124
+ "current_page": 1,
125
+ "data": [
126
+ {
127
+ "description": "",
128
+ "id": "18a6ac49-cb2f-41c7-ac14-ff5360210c65",
129
+ "name": "demo",
130
+ "status": "active"
131
+ },
132
+ {
133
+ "id": "81f08423-288f-4e74-bcbb-edf5ef37f1fe",
134
+ "name": "demo",
135
+ "status": "active"
136
+ },
137
+ {
138
+ "d": "a017c74f-f0bb-4f65-9faf-36581812bbe7",
139
+ "name": "Test 4",
140
+ "status": "active"
141
+ }
142
+ ],
143
+ "total": 683,
144
+ "per_page": 15,
145
+ "total_pages": 46
146
+ }
147
+ ```
@@ -0,0 +1,132 @@
1
+ # ReachCollective / APIs / Utils
2
+
3
+ # Requirements
4
+ - sqlmodel (>=0.0.22,<0.0.23)
5
+ - fastapi (>=0.115.8,<0.116.0)
6
+ - pydantic (>=2.10.6,<3.0.0)
7
+
8
+ # Installation
9
+
10
+ ### Poetry
11
+ ```
12
+ poetry add reachcollective-utils
13
+
14
+ ```
15
+ ### Pip
16
+ ```
17
+ pip install reachcollective-utils
18
+ ```
19
+
20
+ # Examples
21
+
22
+ ## DataGrid
23
+ List and paginate data from a model, accepting filters, sorting, and relationships
24
+
25
+ ### 1. Modify models
26
+ Add `to_dcit()` function to each SQL Model, `to_dcit()` gets relationships.
27
+
28
+ ```python
29
+
30
+ class Post(SQLModel, table=True):
31
+ __tablename__ = "posts"
32
+
33
+ id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
34
+ name: str | None
35
+ status: str | None
36
+ comments: list["Comment"] | None = Relationship(back_populates="post", sa_relationship_kwargs={"lazy": "raise"})
37
+
38
+ def to_dict(self, *args, **kwargs):
39
+ data = super().model_dump(*args, **kwargs)
40
+ if 'comments' in self.__dict__:
41
+ data['comments'] = []
42
+ if self.quota_groups:
43
+ data['comments'] = [quota_group.to_dict(*args, **kwargs) for quota_group in self.quota_groups]
44
+
45
+ return data
46
+
47
+
48
+ class Comment(SQLModel, table=True):
49
+ __tablename__ = "comments"
50
+
51
+ id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
52
+ description: str | None
53
+ post: Optional["Survey"] = Relationship(back_populates="comments", sa_relationship_kwargs={"lazy": "raise"})
54
+
55
+ def to_dict(self, *args, **kwargs):
56
+ data = super().model_dump(*args, **kwargs)
57
+ if 'post' in self.__dict__:
58
+ data['post'] = {}
59
+ if self.survey:
60
+ data['post'] = self.survey.model_dump(*args, **kwargs)
61
+
62
+ return data
63
+ ```
64
+
65
+ ### 2. Basic use
66
+ ```python
67
+ # URL: /posts?sort=-created&filter[status]=active&view=paginate
68
+
69
+
70
+ from app.models import Post
71
+ from reachcollective.utils import DataGrid
72
+
73
+ @router.get('/posts', status_code=HTTPStatus.OK)
74
+ async def list_(
75
+ request: Request,
76
+ db: AsyncSession = Depends(get_session)
77
+ ):
78
+ return await DataGrid(db, Post, request).init().get()
79
+ ```
80
+
81
+ ### 2. Custom filters
82
+ ```python
83
+ # URL: /posts?sort=-created&filter[status]=active&filter[name]=demo&view=paginate
84
+
85
+
86
+ from app.models import Post
87
+ from reachcollective.utils import DataGrid
88
+
89
+ @router.get('/posts', status_code=HTTPStatus.OK)
90
+ async def list_(
91
+ request: Request,
92
+ db: AsyncSession = Depends(get_session)
93
+ ):
94
+ datagrid = DataGrid(db, Post, request)
95
+ datagrid.qp.filters.personalize = ['name']
96
+ datagrid.init()
97
+
98
+ for key, value in datagrid.params['filters']['customize'].items():
99
+ match key:
100
+ case 'name':
101
+ datagrid.qb.stmt = datagrid.qb.stmt.where(Post.name.ilike(f'%{value}%'))
102
+
103
+ return await datagrid.get()
104
+ ```
105
+
106
+ ### 3. Result
107
+ ```json
108
+ {
109
+ "current_page": 1,
110
+ "data": [
111
+ {
112
+ "description": "",
113
+ "id": "18a6ac49-cb2f-41c7-ac14-ff5360210c65",
114
+ "name": "demo",
115
+ "status": "active"
116
+ },
117
+ {
118
+ "id": "81f08423-288f-4e74-bcbb-edf5ef37f1fe",
119
+ "name": "demo",
120
+ "status": "active"
121
+ },
122
+ {
123
+ "d": "a017c74f-f0bb-4f65-9faf-36581812bbe7",
124
+ "name": "Test 4",
125
+ "status": "active"
126
+ }
127
+ ],
128
+ "total": 683,
129
+ "per_page": 15,
130
+ "total_pages": 46
131
+ }
132
+ ```
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "reachcollective-utils"
3
+ version = "0.1.0"
4
+ description = "Utilities for developing RESTful API, Open API projects with FastAPI and Python"
5
+ authors = [
6
+ {name = "Juan Minaya",email = "minayaleon@gmail.com"}
7
+ ]
8
+ readme = "README.md"
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "fastapi[standard] (>=0.115.8,<0.116.0)",
12
+ "sqlmodel (>=0.0.22,<0.0.23)",
13
+ "pydantic (>=2.10.6,<3.0.0)"
14
+ ]
15
+
16
+ [tool.poetry]
17
+ packages = [{include = "reachcollective", from = "src"}]
18
+
19
+
20
+ [build-system]
21
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
22
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,7 @@
1
+ from .datagrid.datagrid import DataGrid
2
+ from .datagrid.filters import Filters
3
+ from .datagrid.query_builder import QueryBuilder
4
+ from .datagrid.query_params import QueryParams
5
+ from .datagrid.paginator import Paginator
6
+ from .render import APIRender
7
+ from .exception import APIException
@@ -0,0 +1,32 @@
1
+ from fastapi import Request
2
+ from sqlmodel.ext.asyncio.session import AsyncSession
3
+
4
+ from .paginator import Paginator
5
+ from .query_builder import QueryBuilder
6
+ from .query_params import QueryParams
7
+
8
+
9
+ class DataGrid:
10
+ def __init__(self, db: AsyncSession, model, request: Request):
11
+
12
+ self.model = model
13
+ self.db = db
14
+ self.params = {}
15
+
16
+ self.qp: QueryParams = QueryParams(request)
17
+ self.qb: QueryBuilder | None = None
18
+
19
+ def init(self):
20
+ self.params = self.qp.get()
21
+ self.qb = QueryBuilder(self.db, self.model, self.params).init()
22
+
23
+ return self
24
+
25
+ async def get(self):
26
+ match self.qp.view:
27
+ case 'paginate':
28
+ return await Paginator(self.qb, self.qp).get()
29
+ case 'count':
30
+ return await self.qb.total()
31
+ case _:
32
+ return await self.qb.get()
@@ -0,0 +1,29 @@
1
+ class Filters:
2
+ def __init__(self, qp=None):
3
+ self.qp = qp
4
+ self.init = {}
5
+ self.equals = {}
6
+ self.personalize = []
7
+ self.customize = {}
8
+
9
+ def process(self):
10
+ for k, v in self.qp.items():
11
+ if k.find('filter') >= 0:
12
+ attr = k[k.find('[') + 1 : k.find(']')]
13
+ t = self.process_value(attr, v)
14
+ if attr in self.personalize:
15
+ self.customize = {**self.customize, **t}
16
+ else:
17
+ self.equals = {**self.equals, **t}
18
+
19
+ def get(self):
20
+ r = {'init': self.init, 'equals': self.equals, 'customize': self.customize}
21
+ return r
22
+
23
+ @staticmethod
24
+ def process_value(attr, value):
25
+ parts = value.split('|')
26
+ if len(parts) > 1:
27
+ value = parts
28
+
29
+ return {attr: value}
@@ -0,0 +1,45 @@
1
+ import math
2
+
3
+ from .query_builder import QueryBuilder
4
+ from .query_params import QueryParams
5
+
6
+
7
+ class Paginator:
8
+ def __init__(self, query_builder: QueryBuilder, query_params: QueryParams):
9
+ self.r = self.get_template()
10
+ self.qp = query_params
11
+ self.qb = query_builder
12
+
13
+ @staticmethod
14
+ def get_template() -> dict:
15
+ return {
16
+ 'current_page': 1,
17
+ 'data': [],
18
+ 'total': None,
19
+ 'per_page': None,
20
+ 'total_pages': None
21
+ }
22
+
23
+ async def build(self):
24
+ total = await self.qb.total()
25
+
26
+ # Current Page
27
+ self.r['current_page'] = self.qp.page
28
+
29
+ # Data
30
+ offset = self.qp.size * (self.qp.page - 1)
31
+ self.qb.stmt = self.qb.stmt.offset(offset).limit(self.qp.size)
32
+ self.r['data'] = await self.qb.get()
33
+
34
+ # Total
35
+ self.r['total'] = total
36
+
37
+ # Per Page
38
+ self.r['per_page'] = self.qp.size
39
+
40
+ # Total Page
41
+ self.r['total_pages'] = math.ceil(total / self.qp.size)
42
+
43
+ async def get(self) -> dict:
44
+ await self.build()
45
+ return self.r
@@ -0,0 +1,67 @@
1
+ from sqlmodel.ext.asyncio.session import AsyncSession
2
+ from sqlmodel import select
3
+ from sqlalchemy.orm import selectinload
4
+
5
+
6
+ class QueryBuilder:
7
+ def __init__(self, db: AsyncSession, model, params):
8
+ self.model = model
9
+ self.stmt = None
10
+ self.params = params
11
+ self.db = db
12
+
13
+ def init(self):
14
+ filters = {}
15
+ if ('filters' in self.params) and ('equals' in self.params['filters']):
16
+ filters = self.params['filters']['equals']
17
+ if 'init' in self.params['filters']:
18
+ filters = {**filters, **self.params['filters']['init']}
19
+
20
+ self.stmt = select(self.model)
21
+ self.apply_filters(filters)
22
+ self.apply_sort()
23
+
24
+ if self.params['with']:
25
+ relationships = self.params['with']
26
+ self.stmt = self.stmt.options(*[selectinload(getattr(self.model, relation)) for relation in relationships])
27
+
28
+ return self
29
+
30
+ def apply_filters(self, filters: dict[str, any]):
31
+ for key, value in filters.items():
32
+ if value:
33
+ column = getattr(self.model, key, None)
34
+ if column:
35
+ if isinstance(value, list):
36
+ self.stmt = self.stmt.where(column.in_(value))
37
+ else:
38
+ self.stmt = self.stmt.where(column == value)
39
+
40
+ def apply_sort(self):
41
+ if self.params['sort']:
42
+ col = self.params['sort'][0]['col']
43
+ dir_ = self.params['sort'][0]['dir']
44
+
45
+ column = getattr(self.model, col, None)
46
+ if column:
47
+ if dir_ == 'desc':
48
+ self.stmt = self.stmt.order_by(column.desc())
49
+ else:
50
+ self.stmt = self.stmt.order_by(column)
51
+
52
+ async def get(self, native = False):
53
+ result = await self.db.exec(self.stmt)
54
+ if native:
55
+ return result.all()
56
+ else:
57
+ rows = result.all()
58
+ # Check only the first record, if available
59
+ if rows and (not hasattr(rows[0], 'to_dict') or not callable(getattr(rows[0], 'to_dict', None))):
60
+ return rows
61
+
62
+ # If the first record has `to_dict()`, return the transformed list
63
+ return [o.to_dict() for o in rows]
64
+
65
+ async def total(self) -> int:
66
+ all_ = await self.get(True)
67
+ return len(all_)
@@ -0,0 +1,66 @@
1
+ from fastapi import Request
2
+
3
+ from .filters import Filters
4
+
5
+
6
+ class QueryParams:
7
+ def __init__(self, request: Request, qp=None):
8
+ self.qp = qp.copy() if qp else None
9
+ self.filters = Filters(self.qp)
10
+ self.sort = []
11
+ self.view = 'list'
12
+ self.request = request
13
+ self.page = 1
14
+ self.size = 15
15
+ self.r = {}
16
+
17
+ def process_sort(self) -> None:
18
+ if 'sort' in self.qp:
19
+ direction = 'asc'
20
+ col = self.qp['sort']
21
+ if self.qp['sort'].find('-', 0, 1) >= 0:
22
+ direction = 'desc'
23
+ col = self.qp['sort'][1:]
24
+
25
+ self.sort = [{'col': col, 'dir': direction, 'by': self.qp['sort']}]
26
+
27
+ def process(self) -> None:
28
+ self.process_sort()
29
+
30
+ if 'page' in self.qp:
31
+ self.page = int(self.qp['page'])
32
+
33
+ if 'size' in self.qp:
34
+ self.size = int(self.qp['size'])
35
+
36
+ if 'view' in self.qp:
37
+ self.view = self.qp['view']
38
+
39
+ def get(self):
40
+ self.qp = dict(self.request.query_params)
41
+ self.filters.qp = dict(self.request.query_params)
42
+
43
+ # Filters
44
+ self.process()
45
+ self.filters.process()
46
+ filters = self.filters.get()
47
+ if any(filters):
48
+ self.r['filters'] = filters
49
+
50
+ # With
51
+ parts = []
52
+ if 'with' in self.qp:
53
+ parts = self.qp['with'].split(',')
54
+ for idx, x in enumerate(parts):
55
+ if x.find('.'):
56
+ x = x.replace('.', '__')
57
+
58
+ parts[idx] = x.strip()
59
+
60
+ self.r['with'] = parts
61
+ self.r['sort'] = self.sort
62
+ self.r['view'] = self.view
63
+ self.r['page'] = self.page
64
+ self.r['size'] = self.size
65
+
66
+ return self.r
@@ -0,0 +1,18 @@
1
+ class APIException(Exception):
2
+ def __init__(self, message: str|None, code: int, detail: dict = None):
3
+ self.message = message
4
+ self.code = code
5
+ self.detail = detail or {}
6
+
7
+ def get_error(self) -> str|dict:
8
+ if self.detail:
9
+ # Check if resposne from External API attribute (response)
10
+ if 'response' in self.detail:
11
+ if self.detail['response']:
12
+ return self.detail['response']
13
+ else:
14
+ return self.message
15
+
16
+ return self.detail
17
+
18
+ return self.message
@@ -0,0 +1,161 @@
1
+ import json
2
+ from http import HTTPStatus
3
+ from typing import Any
4
+
5
+ from fastapi import Request, HTTPException
6
+ from fastapi.responses import JSONResponse
7
+ from pydantic import ValidationError
8
+
9
+ from .exception import APIException
10
+
11
+
12
+ class APIRender:
13
+ @staticmethod
14
+ def display(message, code=200):
15
+ message = APIRender.clean_message(message)
16
+ response = message if isinstance(message, dict) else {'title': message}
17
+ response = APIRender.format_message(response, code)
18
+ return JSONResponse(
19
+ status_code=code,
20
+ content=response
21
+ )
22
+
23
+ @staticmethod
24
+ def format_message(message: dict, code: int):
25
+ formatted_message = {
26
+ 'title': '',
27
+ 'type': 'about:blank',
28
+ 'detail': None,
29
+ 'status': code,
30
+ 'instance': '',
31
+ }
32
+
33
+ if 'title' in message:
34
+ formatted_message['title'] = message['title']
35
+
36
+ if not formatted_message['title'] and 'message' in message:
37
+ formatted_message['title'] = message['message']
38
+
39
+ if 'type' in message:
40
+ formatted_message['type'] = message['type']
41
+
42
+ if 'detail' in message:
43
+ # TSL Fix
44
+ if 'title' not in message and isinstance(message['detail'], str):
45
+ formatted_message['title'] = message['detail']
46
+ else:
47
+ formatted_message['detail'] = message['detail']
48
+
49
+ if 'status' in message:
50
+ formatted_message['status'] = message['status']
51
+
52
+ if 'instance' in message:
53
+ formatted_message['instance'] = message['instance']
54
+
55
+ return formatted_message
56
+
57
+ @staticmethod
58
+ def clean_message(message) -> str|dict:
59
+ # Check if empty
60
+ if not message:
61
+ return ''
62
+
63
+ # Check if Dictionary
64
+ if isinstance(message, dict):
65
+ return message
66
+
67
+ # Check if JSON String
68
+ try:
69
+ return json.loads(message)
70
+ except json.JSONDecodeError:
71
+ return message
72
+
73
+ @staticmethod
74
+ def error(message=None, code=500):
75
+ return APIRender.display(message, code)
76
+
77
+ @staticmethod
78
+ def get_plain_message(message=None, code=500) -> str:
79
+ message = APIRender.clean_message(message)
80
+
81
+ # RCP format 1
82
+ if 'detail' in message:
83
+ message = message['detail']
84
+
85
+ # RCP format 2
86
+ if 'message' in message:
87
+ message = message['message']
88
+
89
+ if not message:
90
+ match code:
91
+ case 401:
92
+ message = 'Unauthorized.'
93
+ case _:
94
+ message = 'Unknown error.'
95
+
96
+ return message
97
+
98
+ @staticmethod
99
+ def request_errors(e) -> JSONResponse:
100
+ message = 'The given data was invalid.'
101
+ formatted_errors = APIRender.clean_data(APIRender.process_errors(e))
102
+ first_error = formatted_errors[0]
103
+ if 'loc' in first_error and 'type' in first_error:
104
+ loc = first_error['loc']
105
+ type_ = first_error['type']
106
+ message = f'Field {loc} is {type_}.' if loc != 'body' else f'Body is {type_}.'
107
+
108
+ return APIRender.display({'title': message, 'detail': ''}, 422)
109
+
110
+ @staticmethod
111
+ def request_validation_handler(_: Request, e: ValidationError) -> JSONResponse:
112
+ return APIRender.request_errors(e)
113
+
114
+ @staticmethod
115
+ def http_exception_handler(_: Request, e: HTTPException):
116
+ message = APIRender.get_plain_message(e.detail, e.status_code)
117
+
118
+ if e.status_code == HTTPStatus.UNAUTHORIZED:
119
+ APIRender.display('Unauthorized.', HTTPStatus.UNAUTHORIZED)
120
+
121
+ return APIRender.display(message, e.status_code)
122
+
123
+ @staticmethod
124
+ def app_exception_handler(_: Request, e: APIException):
125
+ return APIRender.display(e.get_error(), e.code)
126
+
127
+ @staticmethod
128
+ def not_found(resource):
129
+ message = f'{resource} not found.'
130
+ return APIRender.display(message, 404)
131
+
132
+ @staticmethod
133
+ def loc_to_dot_sep(loc: tuple[str | int, ...]) -> str:
134
+ path = ''
135
+ for i, x in enumerate(loc):
136
+ if isinstance(x, str):
137
+ if i > 0:
138
+ path += '.'
139
+ path += x
140
+ elif isinstance(x, int):
141
+ path += f'[{x}]'
142
+ else:
143
+ raise TypeError('Unexpected type')
144
+ return path
145
+
146
+ @staticmethod
147
+ def process_errors(e: ValidationError) -> list[dict[str, Any]]:
148
+ new_errors: list[dict[str, Any]] = e.errors()
149
+ for error in new_errors:
150
+ error['loc'] = APIRender.loc_to_dot_sep(error['loc'])
151
+ return new_errors
152
+
153
+ @staticmethod
154
+ def clean_data(data):
155
+ cleaned_data = []
156
+ for item in data:
157
+ cleaned_item = item.copy()
158
+ if isinstance(cleaned_item.get('input'), type):
159
+ cleaned_item['input'] = str(cleaned_item['input'])
160
+ cleaned_data.append(cleaned_item)
161
+ return cleaned_data