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.
@@ -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
@@ -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 ""
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/bazis-bulk.svg)](https://pypi.org/project/bazis-bulk/)
29
+ [![Python Versions](https://img.shields.io/pypi/pyversions/bazis-bulk.svg)](https://pypi.org/project/bazis-bulk/)
30
+ [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ bazis