Coverage for gws-app/gws/plugin/upload_helper/__init__.py: 95%
94 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 12:46 +0200
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 12:46 +0200
1"""Manage chunked uploads.
3In your action, declare an endpoint with ``p: ChunkRequest`` as a parameter. This endpoint should invoke ``handle_chunk_request``::
5 import gws.plugin.upload_helper as uh
8 @gws.ext.command.api('myUpload')
9 def do_upload(self, req, p: uh.ChunkRequest) -> uh.ChunkResponse:
10 # check permissions, etc...
11 helper = self.root.app.helper('upload')
12 return helper.handle_chunk_request(req, p)
13 ...
15The client sends chunks to this endpoint, one by one. Each chunk contains the file name and total size. The first chunk has an empty ``uploadUid``, indicating a new upload. Subsequent chunks must provide a valid ``uploadUid``. The handler responds with an ``uploadUid``. Each chunk must have a serial number, starting from 0. Chunks can come in any order.
17Once the client decides that the upload is complete, it proceeds with invoking some other endpoint of your action, mentioning the ``uploadUid`` returned by the first chunk. The endpoint should invoke ``get_upload`` to retrieve the final file. The file is stored in a temporary location and should be moved to a permanent location if necessary::
19 @gws.ext.command.api('myProcessUploadedFile')
20 def do_process(self, req, p: MyProcessRequest):
21 helper = self.root.app.helper('upload')
22 try:
23 upload = helper.get_upload(p.uploadUid)
24 except uh.Error:
25 ...upload not ready yet...
26 ...process(upload.path)
30"""
32import shutil
34import gws
35import gws.lib.jsonx
36import gws.lib.osx
38gws.ext.new.helper('upload')
41class Config(gws.Config):
42 """Upload helper."""
44 maxSize: int = 1000
45 """Maximum upload size in megabytes."""
48class ChunkRequest(gws.Request):
49 uploadUid: str = ''
50 fileName: str
51 totalSize: int
52 chunkNumber: int
53 chunkCount: int
54 content: bytes
57class ChunkResponse(gws.Response):
58 uploadUid: str
61class Upload(gws.Data):
62 uid: str
63 fileName: str
64 totalSize: int
65 chunkCount: int
66 path: str
69class Error(gws.Error):
70 pass
73class Object(gws.Node):
74 maxSize: int
75 maxChunkCount: int
77 def configure(self):
78 self.maxSize = self.cfg('maxSize', default=1000) * 1024 * 1024
79 self.maxChunkCount = max(1, self.maxSize // (500 * 1024)) # min. 500K chunks
81 def handle_chunk_request(self, req: gws.WebRequester, p: ChunkRequest) -> ChunkResponse:
82 try:
83 up = self._save_chunk(p)
84 return ChunkResponse(uploadUid=up.uid)
85 except Error as exc:
86 gws.log.exception()
87 raise gws.BadRequestError('upload_error') from exc
89 def get_upload(self, uid: str) -> Upload:
90 up = self._load_upload(uid)
91 out_path = _base_path(up.uid, 'out')
93 if not gws.u.is_file(out_path):
94 with gws.u.server_lock(f'upload_{up.uid}'):
95 self._finalize(up, out_path)
97 up.path = out_path
98 return up
100 ##
102 def _save_chunk(self, p: ChunkRequest) -> Upload:
103 up = self._load_upload(p.uploadUid) if p.uploadUid else self._create_upload(p)
105 if p.chunkNumber < 0 or p.chunkNumber >= up.chunkCount:
106 raise Error(f'upload: {up.uid!r} invalid chunk number')
108 if len(p.content) > up.totalSize:
109 raise Error(f'upload: {up.uid!r} invalid chunk size')
111 with gws.u.server_lock(f'upload_{up.uid}'):
112 gws.u.write_file_b(_base_path(up.uid, p.chunkNumber), p.content)
114 return up
116 def _finalize(self, up: Upload, out_path):
117 chunks = [_base_path(up.uid, n) for n in range(0, up.chunkCount)]
118 complete = all(gws.u.is_file(c) for c in chunks)
119 if not complete:
120 raise Error(f'upload: {up.uid!r}: incomplete')
122 tmp_path = out_path + '.tmp'
123 with open(tmp_path, 'wb') as fp_all:
124 for c in chunks:
125 try:
126 with open(c, 'rb') as fp:
127 shutil.copyfileobj(fp, fp_all)
128 except (OSError, IOError) as exc:
129 raise Error(f'upload: {up.uid!r}: IO error') from exc
131 if gws.lib.osx.file_size(tmp_path) != up.totalSize:
132 raise Error(f'upload: {up.uid!r}: invalid file size')
134 # @TODO check checksums as well?
136 try:
137 gws.lib.osx.rename(tmp_path, out_path)
138 except OSError:
139 raise Error(f'upload: {up.uid!r}: move error')
141 for c in chunks:
142 gws.lib.osx.unlink(c)
144 def _create_upload(self, p: ChunkRequest) -> Upload:
145 if p.totalSize <= 0 or p.totalSize > self.maxSize:
146 raise Error(f'upload: invalid total size {p.totalSize!r}')
147 if p.chunkCount <= 0 or p.chunkCount > self.maxChunkCount:
148 raise Error(f'upload: invalid chunk count {p.chunkCount!r}')
150 uid = gws.u.random_string(64)
151 up = Upload(
152 uid=uid,
153 fileName=p.fileName,
154 totalSize=p.totalSize,
155 chunkCount=p.chunkCount,
156 path='',
157 )
158 gws.lib.jsonx.to_path(_base_path(uid, 'state'), up)
159 return up
161 def _load_upload(self, uid) -> Upload:
162 if not uid.isalnum():
163 raise Error(f'upload: invalid uid {uid!r}')
164 try:
165 return Upload(gws.lib.jsonx.from_path(_base_path(uid, 'state')))
166 except gws.lib.jsonx.Error as exc:
167 raise Error(f'upload: not found {uid!r}') from exc
170def _base_path(uid, p):
171 return gws.u.ephemeral_dir(f'upload_{uid}') + f'/{p}'