bazis-bulk 2.2.0__py3-none-any.whl
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.
- bazis/contrib/bulk/__init__.py +19 -0
- bazis/contrib/bulk/_version.py +34 -0
- bazis/contrib/bulk/locale/en/LC_MESSAGES/django.mo +0 -0
- bazis/contrib/bulk/locale/en/LC_MESSAGES/django.po +23 -0
- bazis/contrib/bulk/locale/ru/LC_MESSAGES/django.mo +0 -0
- bazis/contrib/bulk/locale/ru/LC_MESSAGES/django.po +25 -0
- bazis/contrib/bulk/router.py +34 -0
- bazis/contrib/bulk/routes.py +121 -0
- bazis/contrib/bulk/schemas.py +31 -0
- bazis/contrib/bulk/utils.py +193 -0
- bazis_bulk-2.2.0.dist-info/METADATA +825 -0
- bazis_bulk-2.2.0.dist-info/RECORD +14 -0
- bazis_bulk-2.2.0.dist-info/WHEEL +5 -0
- bazis_bulk-2.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Copyright 2026 EcoFuture Technology Services LLC and contributors
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
17
|
+
__version__ = version('bazis-bulk')
|
|
18
|
+
except PackageNotFoundError:
|
|
19
|
+
__version__ = 'dev'
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# file generated by setuptools-scm
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"__version__",
|
|
6
|
+
"__version_tuple__",
|
|
7
|
+
"version",
|
|
8
|
+
"version_tuple",
|
|
9
|
+
"__commit_id__",
|
|
10
|
+
"commit_id",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
TYPE_CHECKING = False
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from typing import Tuple
|
|
16
|
+
from typing import Union
|
|
17
|
+
|
|
18
|
+
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
19
|
+
COMMIT_ID = Union[str, None]
|
|
20
|
+
else:
|
|
21
|
+
VERSION_TUPLE = object
|
|
22
|
+
COMMIT_ID = object
|
|
23
|
+
|
|
24
|
+
version: str
|
|
25
|
+
__version__: str
|
|
26
|
+
__version_tuple__: VERSION_TUPLE
|
|
27
|
+
version_tuple: VERSION_TUPLE
|
|
28
|
+
commit_id: COMMIT_ID
|
|
29
|
+
__commit_id__: COMMIT_ID
|
|
30
|
+
|
|
31
|
+
__version__ = version = '2.2.0'
|
|
32
|
+
__version_tuple__ = version_tuple = (2, 2, 0)
|
|
33
|
+
|
|
34
|
+
__commit_id__ = commit_id = None
|
|
Binary file
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# SOME DESCRIPTIVE TITLE.
|
|
2
|
+
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
|
3
|
+
# This file is distributed under the same license as the PACKAGE package.
|
|
4
|
+
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
|
|
5
|
+
#
|
|
6
|
+
#, fuzzy
|
|
7
|
+
msgid ""
|
|
8
|
+
msgstr ""
|
|
9
|
+
"Project-Id-Version: PACKAGE VERSION\n"
|
|
10
|
+
"Report-Msgid-Bugs-To: \n"
|
|
11
|
+
"POT-Creation-Date: 2024-07-04 07:31+0000\n"
|
|
12
|
+
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
|
13
|
+
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
|
14
|
+
"Language-Team: LANGUAGE <LL@li.org>\n"
|
|
15
|
+
"Language: \n"
|
|
16
|
+
"MIME-Version: 1.0\n"
|
|
17
|
+
"Content-Type: text/plain; charset=UTF-8\n"
|
|
18
|
+
"Content-Transfer-Encoding: 8bit\n"
|
|
19
|
+
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
|
20
|
+
|
|
21
|
+
#: routes.py:10
|
|
22
|
+
msgid "Bulk requests"
|
|
23
|
+
msgstr ""
|
|
Binary file
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# SOME DESCRIPTIVE TITLE.
|
|
2
|
+
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
|
3
|
+
# This file is distributed under the same license as the PACKAGE package.
|
|
4
|
+
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
|
|
5
|
+
#
|
|
6
|
+
#, fuzzy
|
|
7
|
+
msgid ""
|
|
8
|
+
msgstr ""
|
|
9
|
+
"Project-Id-Version: PACKAGE VERSION\n"
|
|
10
|
+
"Report-Msgid-Bugs-To: \n"
|
|
11
|
+
"POT-Creation-Date: 2024-07-04 07:31+0000\n"
|
|
12
|
+
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
|
13
|
+
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
|
14
|
+
"Language-Team: LANGUAGE <LL@li.org>\n"
|
|
15
|
+
"Language: \n"
|
|
16
|
+
"MIME-Version: 1.0\n"
|
|
17
|
+
"Content-Type: text/plain; charset=UTF-8\n"
|
|
18
|
+
"Content-Transfer-Encoding: 8bit\n"
|
|
19
|
+
"Plural-Forms: nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n"
|
|
20
|
+
"%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || (n"
|
|
21
|
+
"%100>=11 && n%100<=14)? 2 : 3);\n"
|
|
22
|
+
|
|
23
|
+
#: routes.py:10
|
|
24
|
+
msgid "Bulk requests"
|
|
25
|
+
msgstr "Пакетные запросы"
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Copyright 2026 EcoFuture Technology Services LLC and contributors
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
from bazis.core.app import app
|
|
16
|
+
|
|
17
|
+
from .routes import router # noqa: F401
|
|
18
|
+
from .utils import threadpool_vars_prepare
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ThreadpoolVarsPrepareMiddleware:
|
|
22
|
+
"""
|
|
23
|
+
The middleware calls the initializing function for the context variables of worker threads
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, app) -> None:
|
|
27
|
+
self.app = app
|
|
28
|
+
|
|
29
|
+
async def __call__(self, scope, receive, send) -> None:
|
|
30
|
+
threadpool_vars_prepare()
|
|
31
|
+
await self.app(scope, receive, send)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
app.add_middleware(ThreadpoolVarsPrepareMiddleware)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# Copyright 2026 EcoFuture Technology Services LLC and contributors
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from urllib.parse import urlparse
|
|
17
|
+
|
|
18
|
+
from django.utils.translation import gettext_lazy as _
|
|
19
|
+
|
|
20
|
+
from fastapi import Request, Response
|
|
21
|
+
|
|
22
|
+
from bazis.core.routing import BazisRouter
|
|
23
|
+
|
|
24
|
+
from . import schemas
|
|
25
|
+
from .utils import ThreadDedicated, ThreadsPool
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
router = BazisRouter(tags=[_('Bulk requests')])
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class BulkRollbackError(Exception): ...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@router.post('/bulk/', response_model=list[schemas.BulkResponseItemSchema])
|
|
35
|
+
async def bulk(
|
|
36
|
+
request: Request,
|
|
37
|
+
response: Response,
|
|
38
|
+
items: list[schemas.BulkRequestItemSchema],
|
|
39
|
+
is_atomic: bool = True,
|
|
40
|
+
):
|
|
41
|
+
from bazis.core.app import app
|
|
42
|
+
|
|
43
|
+
# collect the list of responses
|
|
44
|
+
results = []
|
|
45
|
+
|
|
46
|
+
if is_atomic:
|
|
47
|
+
thread_behavior = ThreadDedicated()
|
|
48
|
+
else:
|
|
49
|
+
thread_behavior = ThreadsPool()
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
async with thread_behavior as thread:
|
|
53
|
+
for item in items: # type: schemas.BulkRequestItemSchema
|
|
54
|
+
# parse the endpoint
|
|
55
|
+
url = urlparse(item.endpoint)
|
|
56
|
+
|
|
57
|
+
# build the scope
|
|
58
|
+
scope = {
|
|
59
|
+
'type': request.scope.get('type'),
|
|
60
|
+
'asgi': request.scope.get('asgi'),
|
|
61
|
+
'http_version': request.scope.get('http_version'),
|
|
62
|
+
'server': request.scope.get('server'),
|
|
63
|
+
'client': request.scope.get('client'),
|
|
64
|
+
'scheme': request.scope.get('scheme'),
|
|
65
|
+
'headers': request.scope['headers'],
|
|
66
|
+
'method': item.method.upper(),
|
|
67
|
+
'query_string': url.query and url.query.encode(),
|
|
68
|
+
'path': url.path,
|
|
69
|
+
'raw_path': url.path,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
# build the response
|
|
73
|
+
result = {
|
|
74
|
+
'endpoint': item.endpoint,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async def receive(_item=item):
|
|
78
|
+
return {
|
|
79
|
+
'type': 'http.request',
|
|
80
|
+
'body': json.dumps(
|
|
81
|
+
_item.body,
|
|
82
|
+
ensure_ascii=False,
|
|
83
|
+
allow_nan=False,
|
|
84
|
+
).encode("utf-8"),
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async def sender(_data, _result=result):
|
|
88
|
+
if _data['type'] == 'http.response.start':
|
|
89
|
+
_result['status'] = _data['status']
|
|
90
|
+
_result['headers'] = _data['headers']
|
|
91
|
+
if _data['type'] == 'http.response.body':
|
|
92
|
+
# determine the content type
|
|
93
|
+
content_type = dict(_result['headers']).get(b'content-type')
|
|
94
|
+
if content_type and b'json' in content_type:
|
|
95
|
+
_result['response'] = (
|
|
96
|
+
json.loads(_data['body']) if _data['body'] else None
|
|
97
|
+
)
|
|
98
|
+
else:
|
|
99
|
+
_result['response'] = _data['body']
|
|
100
|
+
|
|
101
|
+
# run the route execution in a dedicated thread (since we are in the context of this thread)
|
|
102
|
+
await app.__call__(scope, receive, sender)
|
|
103
|
+
# if an exception occurred inside the dedicated thread - the transaction needs to be restarted
|
|
104
|
+
await thread.check()
|
|
105
|
+
|
|
106
|
+
# for any incorrect response of a package item - we make the overall package status non-working
|
|
107
|
+
if is_atomic and result['status'] >= 400:
|
|
108
|
+
response.status_code = 400
|
|
109
|
+
results.append(result)
|
|
110
|
+
|
|
111
|
+
if not response.status_code:
|
|
112
|
+
response.status_code = 200
|
|
113
|
+
|
|
114
|
+
# if the status is non-working - roll back the transaction
|
|
115
|
+
if response.status_code >= 400:
|
|
116
|
+
raise BulkRollbackError
|
|
117
|
+
|
|
118
|
+
except BulkRollbackError:
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
return results
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Copyright 2026 EcoFuture Technology Services LLC and contributors
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BulkRequestItemSchema(BaseModel):
|
|
21
|
+
endpoint: str
|
|
22
|
+
method: str = 'GET'
|
|
23
|
+
body: dict | None = None
|
|
24
|
+
headers: list[tuple[str, Any]] | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BulkResponseItemSchema(BaseModel):
|
|
28
|
+
endpoint: str
|
|
29
|
+
status: int
|
|
30
|
+
response: str | dict | None
|
|
31
|
+
headers: list[tuple[str, Any]]
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# Copyright 2026 EcoFuture Technology Services LLC and contributors
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import sys
|
|
17
|
+
from collections import deque
|
|
18
|
+
from contextvars import ContextVar, copy_context
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from django.db import transaction
|
|
22
|
+
|
|
23
|
+
from anyio._backends._asyncio import (
|
|
24
|
+
AsyncIOBackend,
|
|
25
|
+
_threadpool_idle_workers,
|
|
26
|
+
_threadpool_workers,
|
|
27
|
+
find_root_task,
|
|
28
|
+
)
|
|
29
|
+
from anyio._backends._asyncio import WorkerThread as BaseWorkerThread
|
|
30
|
+
from sniffio import current_async_library_cvar
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
worker_dedicated = ContextVar('worker_dedicated')
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# BaseWorkerThread_run = BaseWorkerThread.run
|
|
37
|
+
# def run_close_all(self) -> None:
|
|
38
|
+
# """
|
|
39
|
+
# We patch the synchronous task thread execution method so that connections are closed at the end of the thread's life,
|
|
40
|
+
# created in this thread
|
|
41
|
+
# """
|
|
42
|
+
# try:
|
|
43
|
+
# BaseWorkerThread_run(self)
|
|
44
|
+
# finally:
|
|
45
|
+
# connections.close_all()
|
|
46
|
+
# BaseWorkerThread.run = run_close_all
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class IdleWorkersDeque(deque):
|
|
50
|
+
"""
|
|
51
|
+
A patched double-ended queue that is oriented towards working with run_sync_in_worker_thread
|
|
52
|
+
Bypasses limitations, allowing work only with a dedicated thread
|
|
53
|
+
if it exists in the current execution context
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def pop(self):
|
|
57
|
+
try:
|
|
58
|
+
return worker_dedicated.get()
|
|
59
|
+
except LookupError:
|
|
60
|
+
return super().pop()
|
|
61
|
+
|
|
62
|
+
def __bool__(self):
|
|
63
|
+
try:
|
|
64
|
+
return bool(worker_dedicated.get())
|
|
65
|
+
except LookupError:
|
|
66
|
+
return bool(len(self))
|
|
67
|
+
|
|
68
|
+
def __getitem__(self, *args):
|
|
69
|
+
try:
|
|
70
|
+
return worker_dedicated.get()
|
|
71
|
+
except LookupError:
|
|
72
|
+
return super().__getitem__(*args)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def threadpool_vars_prepare():
|
|
76
|
+
"""
|
|
77
|
+
Patching environment service variables to enable working with a dedicated thread
|
|
78
|
+
"""
|
|
79
|
+
try:
|
|
80
|
+
_threadpool_idle_workers.get()
|
|
81
|
+
_threadpool_workers.get()
|
|
82
|
+
except LookupError:
|
|
83
|
+
_threadpool_idle_workers.set(IdleWorkersDeque())
|
|
84
|
+
_threadpool_workers.set(set())
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ThreadsPool:
|
|
88
|
+
"""
|
|
89
|
+
Standard behavior of the thread pool
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
async def check(self): ...
|
|
93
|
+
|
|
94
|
+
async def __aenter__(self):
|
|
95
|
+
return self
|
|
96
|
+
|
|
97
|
+
async def __aexit__(self, exc_type, exc_value, traceback): ...
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class DedicatedWorkerThread(BaseWorkerThread):
|
|
101
|
+
"""
|
|
102
|
+
In this implementation, idle_workers does not receive the current worker after executing a single task.
|
|
103
|
+
In the native implementation, between tasks, a task from a neighboring context may slip in
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def __init__(self, *args, **kwargs):
|
|
107
|
+
pass
|
|
108
|
+
super().__init__(*args, **kwargs)
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def idle_since(self):
|
|
112
|
+
return AsyncIOBackend.current_time()
|
|
113
|
+
|
|
114
|
+
@idle_since.setter
|
|
115
|
+
def idle_since(self, value):
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
def _report_result(
|
|
119
|
+
self, future: asyncio.Future, result: Any, exc: BaseException | None
|
|
120
|
+
) -> None:
|
|
121
|
+
if not future.cancelled():
|
|
122
|
+
if exc is not None:
|
|
123
|
+
future.set_exception(exc)
|
|
124
|
+
else:
|
|
125
|
+
future.set_result(result)
|
|
126
|
+
|
|
127
|
+
def stop(self, f: asyncio.Task | None = None) -> None:
|
|
128
|
+
self.stopping = True
|
|
129
|
+
self.queue.put_nowait(None)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class ThreadDedicated(ThreadsPool):
|
|
133
|
+
"""
|
|
134
|
+
FastApi executes synchronous routes inside a thread pool. However, if several synchronous routes need
|
|
135
|
+
to be executed within a single transaction, then the thread must also be the same.
|
|
136
|
+
This context manager sets up a custom dedicated thread
|
|
137
|
+
in the low-level library anyio._backends._asyncio, in the method of which
|
|
138
|
+
the route is executed: anyio._backends._asyncio.run_sync_in_worker_thread.
|
|
139
|
+
Thus, the goal of executing all routes in a single transaction is achieved.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
def __init__(self, using=None):
|
|
143
|
+
self.atomic = transaction.atomic(using=using)
|
|
144
|
+
self.worker = None
|
|
145
|
+
self.worker_token = None
|
|
146
|
+
|
|
147
|
+
def _transaction_start(self):
|
|
148
|
+
self.atomic.__enter__()
|
|
149
|
+
|
|
150
|
+
def _transaction_commit(self):
|
|
151
|
+
self.atomic.__exit__(None, None, None)
|
|
152
|
+
|
|
153
|
+
def _transaction_rollback(self, exc_type, exc_value, traceback):
|
|
154
|
+
self.atomic.__exit__(exc_type, exc_value, traceback)
|
|
155
|
+
|
|
156
|
+
def _transaction_clean_rollback(self):
|
|
157
|
+
if transaction.get_rollback():
|
|
158
|
+
self.atomic.__exit__(*sys.exc_info())
|
|
159
|
+
self.atomic.__enter__()
|
|
160
|
+
|
|
161
|
+
async def _task_push(self, func, *args):
|
|
162
|
+
if self.worker:
|
|
163
|
+
future: asyncio.Future = asyncio.Future()
|
|
164
|
+
context = copy_context()
|
|
165
|
+
self.worker.queue.put_nowait((context, func, args, future, None))
|
|
166
|
+
await future
|
|
167
|
+
|
|
168
|
+
async def check(self):
|
|
169
|
+
await self._task_push(self._transaction_clean_rollback)
|
|
170
|
+
|
|
171
|
+
async def __aenter__(self):
|
|
172
|
+
current_async_library_cvar.set('asyncio')
|
|
173
|
+
|
|
174
|
+
workers = _threadpool_workers.get()
|
|
175
|
+
idle_workers = _threadpool_idle_workers.get()
|
|
176
|
+
|
|
177
|
+
root_task = find_root_task()
|
|
178
|
+
self.worker = DedicatedWorkerThread(root_task, workers, idle_workers)
|
|
179
|
+
self.worker.start()
|
|
180
|
+
self.worker_token = worker_dedicated.set(self.worker)
|
|
181
|
+
|
|
182
|
+
await self._task_push(self._transaction_start)
|
|
183
|
+
return self
|
|
184
|
+
|
|
185
|
+
async def __aexit__(self, exc_type, exc_value, traceback):
|
|
186
|
+
if exc_type:
|
|
187
|
+
await self._task_push(self._transaction_rollback, exc_type, exc_value, traceback)
|
|
188
|
+
else:
|
|
189
|
+
await self._task_push(self._transaction_commit)
|
|
190
|
+
|
|
191
|
+
worker_dedicated.reset(self.worker_token)
|
|
192
|
+
|
|
193
|
+
self.worker.stop()
|
|
@@ -0,0 +1,825 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bazis-bulk
|
|
3
|
+
Version: 2.2.0
|
|
4
|
+
Summary: Bulk operations module for Bazis framework.
|
|
5
|
+
Author-email: Ilya Kharyn <ilya.tt07@gmail.com>
|
|
6
|
+
Maintainer-email: Ilya Kharyn <ilya.tt07@gmail.com>
|
|
7
|
+
Project-URL: Home, https://github.com/ecofuture-tech/bazis-bulk
|
|
8
|
+
Keywords: bazis,django,fastapi,pydantic,framework,jsonapi,bulk
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Framework :: Django
|
|
17
|
+
Classifier: Framework :: FastAPI
|
|
18
|
+
Requires-Python: >=3.12
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Requires-Dist: bazis
|
|
21
|
+
Provides-Extra: test
|
|
22
|
+
Requires-Dist: bazis-test-utils; extra == "test"
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: ruff; extra == "dev"
|
|
25
|
+
|
|
26
|
+
# Bazis Bulk
|
|
27
|
+
|
|
28
|
+
[](https://pypi.org/project/bazis-bulk/)
|
|
29
|
+
[](https://pypi.org/project/bazis-bulk/)
|
|
30
|
+
[](https://opensource.org/licenses/Apache-2.0)
|
|
31
|
+
|
|
32
|
+
An extension package for Bazis, providing batch request processing with transaction support and asynchronous execution.
|
|
33
|
+
|
|
34
|
+
## Quick Start
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# Install package
|
|
38
|
+
uv add bazis-bulk
|
|
39
|
+
|
|
40
|
+
# Register route
|
|
41
|
+
# router.py
|
|
42
|
+
from bazis.core.routing import BazisRouter
|
|
43
|
+
|
|
44
|
+
router = BazisRouter(prefix='/api/v1')
|
|
45
|
+
router.register('bazis.contrib.bulk.router')
|
|
46
|
+
|
|
47
|
+
# Usage example
|
|
48
|
+
curl -X POST http://localhost/api/v1/bulk/ \
|
|
49
|
+
-H "Authorization: Bearer YOUR_TOKEN" \
|
|
50
|
+
-H "Content-Type: application/json" \
|
|
51
|
+
-d '[
|
|
52
|
+
{
|
|
53
|
+
"endpoint": "/api/v1/entity/parent_entity/",
|
|
54
|
+
"method": "POST",
|
|
55
|
+
"body": {
|
|
56
|
+
"data": {
|
|
57
|
+
"type": "entity.parent_entity",
|
|
58
|
+
"bs:action": "add",
|
|
59
|
+
"attributes": {"name": "Parent 1"}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"endpoint": "/api/v1/entity/child_entity/",
|
|
65
|
+
"method": "POST",
|
|
66
|
+
"body": {
|
|
67
|
+
"data": {
|
|
68
|
+
"type": "entity.child_entity",
|
|
69
|
+
"bs:action": "add",
|
|
70
|
+
"attributes": {"child_name": "Child 1"}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
]'
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Table of Contents
|
|
78
|
+
|
|
79
|
+
- [Description](#description)
|
|
80
|
+
- [Requirements](#requirements)
|
|
81
|
+
- [Installation](#installation)
|
|
82
|
+
- [Usage](#usage)
|
|
83
|
+
- [Route Registration](#route-registration)
|
|
84
|
+
- [Request Format](#request-format)
|
|
85
|
+
- [Request Parameters](#request-parameters)
|
|
86
|
+
- [Response Format](#response-format)
|
|
87
|
+
- [Transactional Mode](#transactional-mode)
|
|
88
|
+
- [Non-transactional Mode](#non-transactional-mode)
|
|
89
|
+
- [Examples](#examples)
|
|
90
|
+
- [License](#license)
|
|
91
|
+
- [Links](#links)
|
|
92
|
+
|
|
93
|
+
## Description
|
|
94
|
+
|
|
95
|
+
**Bazis Bulk** is an extension package for the Bazis framework that allows executing multiple API requests in a single HTTP request. The package includes:
|
|
96
|
+
|
|
97
|
+
- **Batch request execution** — send multiple operations in one request
|
|
98
|
+
- **Transactional mode** — all operations execute within a single transaction (atomicity)
|
|
99
|
+
- **Non-transactional mode** — operations execute independently
|
|
100
|
+
- **Support for all HTTP methods** — GET, POST, PATCH, PUT, DELETE
|
|
101
|
+
- **JSON:API support** — work with `included` resources and relationships
|
|
102
|
+
- **Dedicated thread for transactions** — guaranteed transaction isolation
|
|
103
|
+
|
|
104
|
+
**Typical use cases**:
|
|
105
|
+
- Creating related entities in one request
|
|
106
|
+
- Bulk record updates
|
|
107
|
+
- Atomic operations on multiple resources
|
|
108
|
+
- Reducing HTTP request count (lower latency)
|
|
109
|
+
|
|
110
|
+
**This package requires the base `bazis` package to be installed.**
|
|
111
|
+
|
|
112
|
+
## Requirements
|
|
113
|
+
|
|
114
|
+
- **Python**: 3.12+
|
|
115
|
+
- **bazis**: latest version
|
|
116
|
+
- **PostgreSQL**: 12+
|
|
117
|
+
|
|
118
|
+
## Installation
|
|
119
|
+
|
|
120
|
+
### Using uv (recommended)
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
uv add bazis-bulk
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Using pip
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
pip install bazis-bulk
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Usage
|
|
133
|
+
|
|
134
|
+
### Route Registration
|
|
135
|
+
|
|
136
|
+
Add the route to your main `router.py`:
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from bazis.core.routing import BazisRouter
|
|
140
|
+
|
|
141
|
+
router = BazisRouter(prefix='/api/v1')
|
|
142
|
+
|
|
143
|
+
# Register bulk route
|
|
144
|
+
router.register('bazis.contrib.bulk.router')
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
This creates the endpoint: `POST /api/v1/bulk/`
|
|
148
|
+
|
|
149
|
+
### Request Format
|
|
150
|
+
|
|
151
|
+
A bulk request is an array of objects, where each object describes a separate HTTP request.
|
|
152
|
+
|
|
153
|
+
**Single element structure**:
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
{
|
|
157
|
+
"endpoint": string, // Endpoint path (required)
|
|
158
|
+
"method": string, // HTTP method: GET, POST, PATCH, PUT, DELETE (required)
|
|
159
|
+
"body": object, // Request body in JSON:API format (optional)
|
|
160
|
+
"headers": array // Additional headers (optional, currently ignored)
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
**Example**:
|
|
165
|
+
|
|
166
|
+
```json
|
|
167
|
+
[
|
|
168
|
+
{
|
|
169
|
+
"endpoint": "/api/v1/entity/parent_entity/",
|
|
170
|
+
"method": "POST",
|
|
171
|
+
"body": {
|
|
172
|
+
"data": {
|
|
173
|
+
"type": "entity.parent_entity",
|
|
174
|
+
"bs:action": "add",
|
|
175
|
+
"attributes": {
|
|
176
|
+
"name": "New Parent"
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
]
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### Request Parameters
|
|
185
|
+
|
|
186
|
+
#### is_atomic (query parameter)
|
|
187
|
+
|
|
188
|
+
Defines the request execution mode:
|
|
189
|
+
|
|
190
|
+
- `is_atomic=true` (default) — **transactional mode**
|
|
191
|
+
- All operations execute within a single transaction
|
|
192
|
+
- Any operation error rolls back the entire transaction
|
|
193
|
+
- Response status: 400 if errors occur
|
|
194
|
+
|
|
195
|
+
- `is_atomic=false` — **non-transactional mode**
|
|
196
|
+
- Operations execute independently
|
|
197
|
+
- Error in one operation doesn't affect others
|
|
198
|
+
- Response status: 200 even with errors in individual operations
|
|
199
|
+
|
|
200
|
+
**Examples**:
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
# Transactional mode (default)
|
|
204
|
+
POST /api/v1/bulk/
|
|
205
|
+
POST /api/v1/bulk/?is_atomic=true
|
|
206
|
+
|
|
207
|
+
# Non-transactional mode
|
|
208
|
+
POST /api/v1/bulk/?is_atomic=false
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### Response Format
|
|
212
|
+
|
|
213
|
+
Each response item contains the original endpoint, HTTP status, headers, and the parsed body.
|
|
214
|
+
|
|
215
|
+
**Single element structure**:
|
|
216
|
+
|
|
217
|
+
```typescript
|
|
218
|
+
{
|
|
219
|
+
"endpoint": string, // Endpoint path (required)
|
|
220
|
+
"status": number, // HTTP status code (required)
|
|
221
|
+
"headers": array, // ASGI response headers as [name, value] pairs
|
|
222
|
+
"response": object // Parsed JSON for JSON responses, raw body otherwise (may be null)
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Headers are returned as emitted by the ASGI app (typically byte pairs).
|
|
227
|
+
|
|
228
|
+
### Transactional Mode
|
|
229
|
+
|
|
230
|
+
In transactional mode, all operations execute in a dedicated thread with a single database transaction.
|
|
231
|
+
|
|
232
|
+
**Features**:
|
|
233
|
+
|
|
234
|
+
- All operations either succeed completely or rollback entirely
|
|
235
|
+
- Any operation error causes transaction rollback
|
|
236
|
+
- Overall response status: 400 if errors occur
|
|
237
|
+
|
|
238
|
+
**Request example**:
|
|
239
|
+
|
|
240
|
+
```bash
|
|
241
|
+
POST /api/v1/bulk/?is_atomic=true
|
|
242
|
+
Content-Type: application/json
|
|
243
|
+
Authorization: Bearer YOUR_TOKEN
|
|
244
|
+
|
|
245
|
+
[
|
|
246
|
+
{
|
|
247
|
+
"endpoint": "/api/v1/orders/order/",
|
|
248
|
+
"method": "POST",
|
|
249
|
+
"body": {
|
|
250
|
+
"data": {
|
|
251
|
+
"type": "myapp.order",
|
|
252
|
+
"bs:action": "add",
|
|
253
|
+
"attributes": {
|
|
254
|
+
"description": "Order 1",
|
|
255
|
+
"amount": 1000
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
"endpoint": "/api/v1/orders/order/",
|
|
262
|
+
"method": "POST",
|
|
263
|
+
"body": {
|
|
264
|
+
"data": {
|
|
265
|
+
"type": "myapp.order",
|
|
266
|
+
"bs:action": "add",
|
|
267
|
+
"attributes": {
|
|
268
|
+
"description": "Order 2",
|
|
269
|
+
"amount": 2000
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
]
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
**Success response** (status 200):
|
|
278
|
+
|
|
279
|
+
```json
|
|
280
|
+
[
|
|
281
|
+
{
|
|
282
|
+
"endpoint": "/api/v1/orders/order/",
|
|
283
|
+
"status": 201,
|
|
284
|
+
"headers": [
|
|
285
|
+
["content-type", "application/vnd.api+json"]
|
|
286
|
+
],
|
|
287
|
+
"response": {
|
|
288
|
+
"data": {
|
|
289
|
+
"type": "myapp.order",
|
|
290
|
+
"id": "123e4567-e89b-12d3-a456-426614174000",
|
|
291
|
+
"attributes": {
|
|
292
|
+
"description": "Order 1",
|
|
293
|
+
"amount": 1000
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
"endpoint": "/api/v1/orders/order/",
|
|
300
|
+
"status": 201,
|
|
301
|
+
"response": {
|
|
302
|
+
"data": {
|
|
303
|
+
"type": "myapp.order",
|
|
304
|
+
"id": "987e6543-e21b-32d1-b654-426614174001",
|
|
305
|
+
"attributes": {
|
|
306
|
+
"description": "Order 2",
|
|
307
|
+
"amount": 2000
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
]
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
**Error response** (status 400, all operations rolled back):
|
|
316
|
+
|
|
317
|
+
```json
|
|
318
|
+
[
|
|
319
|
+
{
|
|
320
|
+
"endpoint": "/api/v1/orders/order/",
|
|
321
|
+
"status": 201,
|
|
322
|
+
"response": {
|
|
323
|
+
"data": {
|
|
324
|
+
"type": "myapp.order",
|
|
325
|
+
"id": "123e4567-e89b-12d3-a456-426614174000",
|
|
326
|
+
"attributes": {
|
|
327
|
+
"description": "Order 1"
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
"endpoint": "/api/v1/orders/order/",
|
|
334
|
+
"status": 403,
|
|
335
|
+
"response": {
|
|
336
|
+
"errors": [
|
|
337
|
+
{
|
|
338
|
+
"status": 403,
|
|
339
|
+
"detail": "Permission denied"
|
|
340
|
+
}
|
|
341
|
+
]
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
]
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
### Non-transactional Mode
|
|
348
|
+
|
|
349
|
+
In non-transactional mode, each operation executes independently in a thread pool.
|
|
350
|
+
|
|
351
|
+
**Features**:
|
|
352
|
+
|
|
353
|
+
- Operations execute independently
|
|
354
|
+
- Error in one operation doesn't affect others
|
|
355
|
+
- Overall response status: always 200
|
|
356
|
+
|
|
357
|
+
**Request example**:
|
|
358
|
+
|
|
359
|
+
```bash
|
|
360
|
+
POST /api/v1/bulk/?is_atomic=false
|
|
361
|
+
Content-Type: application/json
|
|
362
|
+
Authorization: Bearer YOUR_TOKEN
|
|
363
|
+
|
|
364
|
+
[
|
|
365
|
+
{
|
|
366
|
+
"endpoint": "/api/v1/orders/order/",
|
|
367
|
+
"method": "POST",
|
|
368
|
+
"body": {
|
|
369
|
+
"data": {
|
|
370
|
+
"type": "myapp.order",
|
|
371
|
+
"bs:action": "add",
|
|
372
|
+
"attributes": {"description": "Order 1"}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
"endpoint": "/api/v1/orders/order/999/",
|
|
378
|
+
"method": "DELETE",
|
|
379
|
+
"body": {}
|
|
380
|
+
}
|
|
381
|
+
]
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
**Response** (status 200, even with errors):
|
|
385
|
+
|
|
386
|
+
```json
|
|
387
|
+
[
|
|
388
|
+
{
|
|
389
|
+
"endpoint": "/api/v1/orders/order/",
|
|
390
|
+
"status": 201,
|
|
391
|
+
"response": {
|
|
392
|
+
"data": {
|
|
393
|
+
"type": "myapp.order",
|
|
394
|
+
"id": "123e4567-e89b-12d3-a456-426614174000"
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
"endpoint": "/api/v1/orders/order/999/",
|
|
400
|
+
"status": 404,
|
|
401
|
+
"response": {
|
|
402
|
+
"errors": [
|
|
403
|
+
{
|
|
404
|
+
"status": 404,
|
|
405
|
+
"detail": "Not found"
|
|
406
|
+
}
|
|
407
|
+
]
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
]
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
## Examples
|
|
414
|
+
|
|
415
|
+
### Example 1: Creating Related Entities
|
|
416
|
+
|
|
417
|
+
Creating a parent entity and two child entities in one transaction:
|
|
418
|
+
|
|
419
|
+
```json
|
|
420
|
+
[
|
|
421
|
+
{
|
|
422
|
+
"endpoint": "/api/v1/entity/parent_entity/",
|
|
423
|
+
"method": "POST",
|
|
424
|
+
"body": {
|
|
425
|
+
"data": {
|
|
426
|
+
"type": "entity.parent_entity",
|
|
427
|
+
"bs:action": "add",
|
|
428
|
+
"attributes": {
|
|
429
|
+
"name": "Parent Entity"
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
},
|
|
434
|
+
{
|
|
435
|
+
"endpoint": "/api/v1/entity/child_entity/",
|
|
436
|
+
"method": "POST",
|
|
437
|
+
"body": {
|
|
438
|
+
"data": {
|
|
439
|
+
"type": "entity.child_entity",
|
|
440
|
+
"bs:action": "add",
|
|
441
|
+
"attributes": {
|
|
442
|
+
"child_name": "Child 1"
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
"endpoint": "/api/v1/entity/child_entity/",
|
|
449
|
+
"method": "POST",
|
|
450
|
+
"body": {
|
|
451
|
+
"data": {
|
|
452
|
+
"type": "entity.child_entity",
|
|
453
|
+
"bs:action": "add",
|
|
454
|
+
"attributes": {
|
|
455
|
+
"child_name": "Child 2"
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
]
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
### Example 2: Update with Included Resources
|
|
464
|
+
|
|
465
|
+
Updating a parent entity and its related children:
|
|
466
|
+
|
|
467
|
+
```json
|
|
468
|
+
[
|
|
469
|
+
{
|
|
470
|
+
"endpoint": "/api/v1/entity/parent_entity/123/?include=extended_entity,dependent_entities",
|
|
471
|
+
"method": "PATCH",
|
|
472
|
+
"body": {
|
|
473
|
+
"data": {
|
|
474
|
+
"id": "123",
|
|
475
|
+
"type": "entity.parent_entity",
|
|
476
|
+
"bs:action": "change",
|
|
477
|
+
"attributes": {
|
|
478
|
+
"name": "Updated Parent"
|
|
479
|
+
}
|
|
480
|
+
},
|
|
481
|
+
"included": [
|
|
482
|
+
{
|
|
483
|
+
"id": "456",
|
|
484
|
+
"type": "entity.extended_entity",
|
|
485
|
+
"bs:action": "change",
|
|
486
|
+
"attributes": {
|
|
487
|
+
"extended_name": "Updated Extended"
|
|
488
|
+
}
|
|
489
|
+
},
|
|
490
|
+
{
|
|
491
|
+
"type": "entity.dependent_entity",
|
|
492
|
+
"bs:action": "add",
|
|
493
|
+
"attributes": {
|
|
494
|
+
"dependent_name": "New Dependent"
|
|
495
|
+
},
|
|
496
|
+
"relationships": {
|
|
497
|
+
"parent_entity": {
|
|
498
|
+
"data": {
|
|
499
|
+
"id": "123",
|
|
500
|
+
"type": "entity.parent_entity"
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
]
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
]
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
### Example 3: Bulk Update
|
|
512
|
+
|
|
513
|
+
Updating multiple records simultaneously:
|
|
514
|
+
|
|
515
|
+
```json
|
|
516
|
+
[
|
|
517
|
+
{
|
|
518
|
+
"endpoint": "/api/v1/entity/child_entity/child-1/",
|
|
519
|
+
"method": "PATCH",
|
|
520
|
+
"body": {
|
|
521
|
+
"data": {
|
|
522
|
+
"id": "child-1",
|
|
523
|
+
"type": "entity.child_entity",
|
|
524
|
+
"bs:action": "change",
|
|
525
|
+
"attributes": {
|
|
526
|
+
"child_name": "Updated Child 1"
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
},
|
|
531
|
+
{
|
|
532
|
+
"endpoint": "/api/v1/entity/child_entity/child-2/",
|
|
533
|
+
"method": "PATCH",
|
|
534
|
+
"body": {
|
|
535
|
+
"data": {
|
|
536
|
+
"id": "child-2",
|
|
537
|
+
"type": "entity.child_entity",
|
|
538
|
+
"bs:action": "change",
|
|
539
|
+
"attributes": {
|
|
540
|
+
"child_name": "Updated Child 2"
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
},
|
|
545
|
+
{
|
|
546
|
+
"endpoint": "/api/v1/entity/child_entity/child-3/",
|
|
547
|
+
"method": "PATCH",
|
|
548
|
+
"body": {
|
|
549
|
+
"data": {
|
|
550
|
+
"id": "child-3",
|
|
551
|
+
"type": "entity.child_entity",
|
|
552
|
+
"bs:action": "change",
|
|
553
|
+
"attributes": {
|
|
554
|
+
"child_name": "Updated Child 3"
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
]
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
### Example 4: Mixed Operations
|
|
563
|
+
|
|
564
|
+
Create, update, and delete in one request:
|
|
565
|
+
|
|
566
|
+
```json
|
|
567
|
+
[
|
|
568
|
+
{
|
|
569
|
+
"endpoint": "/api/v1/entity/parent_entity/",
|
|
570
|
+
"method": "POST",
|
|
571
|
+
"body": {
|
|
572
|
+
"data": {
|
|
573
|
+
"type": "entity.parent_entity",
|
|
574
|
+
"bs:action": "add",
|
|
575
|
+
"attributes": {"name": "New Parent"}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
},
|
|
579
|
+
{
|
|
580
|
+
"endpoint": "/api/v1/entity/parent_entity/existing-id/",
|
|
581
|
+
"method": "PATCH",
|
|
582
|
+
"body": {
|
|
583
|
+
"data": {
|
|
584
|
+
"id": "existing-id",
|
|
585
|
+
"type": "entity.parent_entity",
|
|
586
|
+
"bs:action": "change",
|
|
587
|
+
"attributes": {"price": "845.42"}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
},
|
|
591
|
+
{
|
|
592
|
+
"endpoint": "/api/v1/entity/child_entity/old-id/",
|
|
593
|
+
"method": "DELETE",
|
|
594
|
+
"body": {}
|
|
595
|
+
}
|
|
596
|
+
]
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
### Example 5: JavaScript Client
|
|
600
|
+
|
|
601
|
+
```javascript
|
|
602
|
+
class BulkClient {
|
|
603
|
+
constructor(apiUrl, token) {
|
|
604
|
+
this.apiUrl = apiUrl;
|
|
605
|
+
this.token = token;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
async executeBulk(operations, isAtomic = true) {
|
|
609
|
+
const url = `${this.apiUrl}/bulk/?is_atomic=${isAtomic}`;
|
|
610
|
+
|
|
611
|
+
const response = await fetch(url, {
|
|
612
|
+
method: 'POST',
|
|
613
|
+
headers: {
|
|
614
|
+
'Authorization': `Bearer ${this.token}`,
|
|
615
|
+
'Content-Type': 'application/json'
|
|
616
|
+
},
|
|
617
|
+
body: JSON.stringify(operations)
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
if (!response.ok) {
|
|
621
|
+
throw new Error(`Bulk request failed: ${response.status}`);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
return await response.json();
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async createMultiple(entities, isAtomic = true) {
|
|
628
|
+
const operations = entities.map(entity => ({
|
|
629
|
+
endpoint: entity.endpoint,
|
|
630
|
+
method: 'POST',
|
|
631
|
+
body: {
|
|
632
|
+
data: {
|
|
633
|
+
type: entity.type,
|
|
634
|
+
'bs:action': 'add',
|
|
635
|
+
attributes: entity.attributes,
|
|
636
|
+
relationships: entity.relationships
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}));
|
|
640
|
+
|
|
641
|
+
return await this.executeBulk(operations, isAtomic);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
async updateMultiple(updates, isAtomic = true) {
|
|
645
|
+
const operations = updates.map(update => ({
|
|
646
|
+
endpoint: `${update.endpoint}/${update.id}/`,
|
|
647
|
+
method: 'PATCH',
|
|
648
|
+
body: {
|
|
649
|
+
data: {
|
|
650
|
+
id: update.id,
|
|
651
|
+
type: update.type,
|
|
652
|
+
'bs:action': 'change',
|
|
653
|
+
attributes: update.attributes
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}));
|
|
657
|
+
|
|
658
|
+
return await this.executeBulk(operations, isAtomic);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Usage
|
|
663
|
+
const bulk = new BulkClient('http://api.example.com/api/v1', jwtToken);
|
|
664
|
+
|
|
665
|
+
// Create multiple entities atomically
|
|
666
|
+
const results = await bulk.createMultiple([
|
|
667
|
+
{
|
|
668
|
+
endpoint: '/api/v1/orders/order',
|
|
669
|
+
type: 'myapp.order',
|
|
670
|
+
attributes: { description: 'Order 1', amount: 1000 }
|
|
671
|
+
},
|
|
672
|
+
{
|
|
673
|
+
endpoint: '/api/v1/orders/order',
|
|
674
|
+
type: 'myapp.order',
|
|
675
|
+
attributes: { description: 'Order 2', amount: 2000 }
|
|
676
|
+
}
|
|
677
|
+
], true);
|
|
678
|
+
|
|
679
|
+
console.log('Created:', results);
|
|
680
|
+
|
|
681
|
+
// Bulk update without transaction
|
|
682
|
+
await bulk.updateMultiple([
|
|
683
|
+
{
|
|
684
|
+
endpoint: '/api/v1/orders/order',
|
|
685
|
+
id: 'order-1',
|
|
686
|
+
type: 'myapp.order',
|
|
687
|
+
attributes: { status: 'completed' }
|
|
688
|
+
},
|
|
689
|
+
{
|
|
690
|
+
endpoint: '/api/v1/orders/order',
|
|
691
|
+
id: 'order-2',
|
|
692
|
+
type: 'myapp.order',
|
|
693
|
+
attributes: { status: 'completed' }
|
|
694
|
+
}
|
|
695
|
+
], false);
|
|
696
|
+
```
|
|
697
|
+
|
|
698
|
+
### Example 6: Python Client
|
|
699
|
+
|
|
700
|
+
```python
|
|
701
|
+
import requests
|
|
702
|
+
from typing import List, Dict, Any
|
|
703
|
+
|
|
704
|
+
class BulkClient:
|
|
705
|
+
def __init__(self, api_url: str, token: str):
|
|
706
|
+
self.api_url = api_url
|
|
707
|
+
self.token = token
|
|
708
|
+
self.headers = {
|
|
709
|
+
'Authorization': f'Bearer {token}',
|
|
710
|
+
'Content-Type': 'application/json'
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
def execute_bulk(
|
|
714
|
+
self,
|
|
715
|
+
operations: List[Dict[str, Any]],
|
|
716
|
+
is_atomic: bool = True
|
|
717
|
+
) -> List[Dict[str, Any]]:
|
|
718
|
+
"""Execute bulk request"""
|
|
719
|
+
url = f"{self.api_url}/bulk/?is_atomic={str(is_atomic).lower()}"
|
|
720
|
+
|
|
721
|
+
response = requests.post(
|
|
722
|
+
url,
|
|
723
|
+
headers=self.headers,
|
|
724
|
+
json=operations
|
|
725
|
+
)
|
|
726
|
+
response.raise_for_status()
|
|
727
|
+
|
|
728
|
+
return response.json()
|
|
729
|
+
|
|
730
|
+
def create_with_related(
|
|
731
|
+
self,
|
|
732
|
+
parent_data: Dict[str, Any],
|
|
733
|
+
children_data: List[Dict[str, Any]]
|
|
734
|
+
) -> List[Dict[str, Any]]:
|
|
735
|
+
"""Create parent entity with children in one transaction"""
|
|
736
|
+
operations = [
|
|
737
|
+
{
|
|
738
|
+
'endpoint': parent_data['endpoint'],
|
|
739
|
+
'method': 'POST',
|
|
740
|
+
'body': {
|
|
741
|
+
'data': {
|
|
742
|
+
'type': parent_data['type'],
|
|
743
|
+
'bs:action': 'add',
|
|
744
|
+
'attributes': parent_data['attributes']
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
]
|
|
749
|
+
|
|
750
|
+
for child in children_data:
|
|
751
|
+
operations.append({
|
|
752
|
+
'endpoint': child['endpoint'],
|
|
753
|
+
'method': 'POST',
|
|
754
|
+
'body': {
|
|
755
|
+
'data': {
|
|
756
|
+
'type': child['type'],
|
|
757
|
+
'bs:action': 'add',
|
|
758
|
+
'attributes': child['attributes'],
|
|
759
|
+
'relationships': child.get('relationships', {})
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
})
|
|
763
|
+
|
|
764
|
+
return self.execute_bulk(operations, is_atomic=True)
|
|
765
|
+
|
|
766
|
+
# Usage
|
|
767
|
+
bulk = BulkClient('http://api.example.com/api/v1', jwt_token)
|
|
768
|
+
|
|
769
|
+
# Create order with items
|
|
770
|
+
results = bulk.create_with_related(
|
|
771
|
+
parent_data={
|
|
772
|
+
'endpoint': '/api/v1/orders/order',
|
|
773
|
+
'type': 'myapp.order',
|
|
774
|
+
'attributes': {
|
|
775
|
+
'description': 'New Order',
|
|
776
|
+
'customer': 'John Doe'
|
|
777
|
+
}
|
|
778
|
+
},
|
|
779
|
+
children_data=[
|
|
780
|
+
{
|
|
781
|
+
'endpoint': '/api/v1/orders/orderitem',
|
|
782
|
+
'type': 'myapp.orderitem',
|
|
783
|
+
'attributes': {
|
|
784
|
+
'product': 'Product 1',
|
|
785
|
+
'quantity': 2,
|
|
786
|
+
'price': '100.00'
|
|
787
|
+
}
|
|
788
|
+
},
|
|
789
|
+
{
|
|
790
|
+
'endpoint': '/api/v1/orders/orderitem',
|
|
791
|
+
'type': 'myapp.orderitem',
|
|
792
|
+
'attributes': {
|
|
793
|
+
'product': 'Product 2',
|
|
794
|
+
'quantity': 1,
|
|
795
|
+
'price': '50.00'
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
]
|
|
799
|
+
)
|
|
800
|
+
|
|
801
|
+
print(f"Created order with {len(results) - 1} items")
|
|
802
|
+
```
|
|
803
|
+
|
|
804
|
+
## License
|
|
805
|
+
|
|
806
|
+
Apache License 2.0
|
|
807
|
+
|
|
808
|
+
See [LICENSE](LICENSE) file for details.
|
|
809
|
+
|
|
810
|
+
## Links
|
|
811
|
+
|
|
812
|
+
- [Bazis Documentation](https://github.com/ecofuture-tech/bazis) — main repository
|
|
813
|
+
- [Bazis Bulk Repository](https://github.com/ecofuture-tech/bazis-bulk) — package repository
|
|
814
|
+
- [Issue Tracker](https://github.com/ecofuture-tech/bazis-bulk/issues) — report bugs or request features
|
|
815
|
+
|
|
816
|
+
## Support
|
|
817
|
+
|
|
818
|
+
If you have questions or issues:
|
|
819
|
+
- Review the [Bazis documentation](https://github.com/ecofuture-tech/bazis)
|
|
820
|
+
- Search through [existing issues](https://github.com/ecofuture-tech/bazis-bulk/issues)
|
|
821
|
+
- Create a [new issue](https://github.com/ecofuture-tech/bazis-bulk/issues/new) with detailed information
|
|
822
|
+
|
|
823
|
+
---
|
|
824
|
+
|
|
825
|
+
Made with ❤️ by the Bazis team
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
bazis/contrib/bulk/__init__.py,sha256=iaqxVnQxmpxbVMx2W-eBDvI_04GlA13Ojda5Swdio7g,778
|
|
2
|
+
bazis/contrib/bulk/_version.py,sha256=6OGz4a0gjMGlckPyPCNiJDWyFDO-tWO8O_ZNx4ajT2Y,704
|
|
3
|
+
bazis/contrib/bulk/router.py,sha256=M9ksiD9bi25TCnRjukq2I91_nbWJlaLzvaazQz7wO5w,1138
|
|
4
|
+
bazis/contrib/bulk/routes.py,sha256=ueeE6HiTHokDsnWrYl2D70dSVQy8-eb3WTRWlowUyMY,4437
|
|
5
|
+
bazis/contrib/bulk/schemas.py,sha256=6YaAwyRP4y8t-uqRqace_la9pxgb6dQak4EEFPT3C2Y,976
|
|
6
|
+
bazis/contrib/bulk/utils.py,sha256=zKCGQA-ptQyk4diy2GrarbxiPRwoUeYEQcvXPhuA5aY,5985
|
|
7
|
+
bazis/contrib/bulk/locale/en/LC_MESSAGES/django.mo,sha256=N1pb17IfLd0ASiKO8d68-B4ygSpDkhKOCs8YTzMXQo0,380
|
|
8
|
+
bazis/contrib/bulk/locale/en/LC_MESSAGES/django.po,sha256=bQnd0ts6l9WguZkGDd-iwum40cTAo67DvPK9wqqSyNw,676
|
|
9
|
+
bazis/contrib/bulk/locale/ru/LC_MESSAGES/django.mo,sha256=sd5jWcMlx4L-UqS82VO2cYKmKhrvDiSFuYXzAWt8lGg,588
|
|
10
|
+
bazis/contrib/bulk/locale/ru/LC_MESSAGES/django.po,sha256=b1XEGpECiweGTKtluO1x4Oo9k3GqJ1WcKpsjXTMP9Oc,851
|
|
11
|
+
bazis_bulk-2.2.0.dist-info/METADATA,sha256=qBdZ4uOZJlj3JrHbtJMqSHFnNAXp9LuBGKUlJUsN6S0,19162
|
|
12
|
+
bazis_bulk-2.2.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
13
|
+
bazis_bulk-2.2.0.dist-info/top_level.txt,sha256=WgdrPZTZBMG8i_EqxA3vU5qI4ETQ_RsqKqSqsfIApHY,6
|
|
14
|
+
bazis_bulk-2.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bazis
|