Coverage for gws-app/gws/plugin/upload_helper/__init__.py: 0%
90 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-16 23:09 +0200
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-16 23:09 +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
7 @gws.ext.command.api('myUpload')
8 def do_upload(self, req, p: uh.ChunkRequest) -> uh.ChunkResponse:
9 # check permissions, etc...
10 helper = self.root.app.helper('upload')
11 return helper.handle_chunk_request(req, p)
12 ...
14The 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.
16Once 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"""
31import shutil
33import gws
34import gws.lib.jsonx
35import gws.lib.osx
37gws.ext.new.helper('upload')
40class ChunkRequest(gws.Request):
41 uploadUid: str = ''
42 fileName: str
43 totalSize: int
44 chunkNumber: int
45 chunkCount: int
46 content: bytes
49class ChunkResponse(gws.Response):
50 uploadUid: str
53class Upload(gws.Data):
54 uploadUid: str
55 fileName: str
56 totalSize: int
57 path: str
60class _Params(gws.Data):
61 uid: str
62 fileName: str
63 totalSize: int
64 chunkCount: int
67class Error(gws.Error):
68 pass
71class Object(gws.Node):
72 def handle_chunk_request(self, req: gws.WebRequester, p: ChunkRequest) -> ChunkResponse:
73 try:
74 ps = self._save_chunk(p)
75 return ChunkResponse(uploadUid=ps.uid)
76 except Error as exc:
77 gws.log.exception()
78 raise gws.BadRequestError('upload_error') from exc
80 def get_upload(self, uid: str) -> Upload:
81 ps = self._get_params(uid)
82 dd = self._base_dir(ps.uid)
83 out_path = f'{dd}/out'
84 if not gws.u.is_file(out_path):
85 with gws.u.server_lock(f'upload_{ps.uid}'):
86 self._finalize(ps, out_path)
87 return Upload(uploadUid=ps.uid, path=out_path, fileName=ps.fileName)
89 ##
91 def _save_chunk(self, p: ChunkRequest) -> _Params:
92 ps = self._get_params(p.uploadUid) if p.uploadUid else self._create_upload(p)
94 if p.chunkNumber < 0 or p.chunkNumber >= ps.chunkCount:
95 raise Error(f'upload: {ps.uid!r} invalid chunk number')
97 dd = self._base_dir(ps.uid)
99 with gws.u.server_lock(f'upload_{ps.uid}'):
100 gws.u.write_file_b(f'{dd}/{p.chunkNumber}', p.content)
102 return ps
104 def _get_chunks(self, ps: _Params):
105 dd = self._base_dir(ps.uid)
106 chunks = [f'{dd}/{n}' for n in range(0, ps.chunkCount)]
107 complete = all(gws.u.is_file(c) for c in chunks)
108 return chunks, complete
110 def _finalize(self, ps: _Params, out_path):
111 chunks, complete = self._get_chunks(ps)
112 if not complete:
113 raise Error(f'upload: {ps.uid!r} incomplete')
115 tmp_path = out_path + '.tmp'
116 with open(tmp_path, 'wb') as fp_all:
117 for c in chunks:
118 try:
119 with open(c, 'rb') as fp:
120 shutil.copyfileobj(fp, fp_all)
121 except (OSError, IOError) as exc:
122 raise Error(f'upload: {ps.uid!r}: IO error') from exc
124 if gws.lib.osx.file_size(tmp_path) != ps.totalSize:
125 raise Error(f'upload: {ps.uid!r}: invalid file size')
127 # @TODO check checksums as well?
129 try:
130 gws.lib.osx.rename(tmp_path, out_path)
131 except OSError:
132 raise Error(f'upload: {ps.uid!r}: move error')
134 for c in chunks:
135 gws.lib.osx.unlink(c)
137 def _create_upload(self, p: ChunkRequest) -> _Params:
138 uid = gws.u.random_string(64)
139 dd = self._base_dir(uid)
141 gws.lib.jsonx.to_path(f'{dd}/s.json', _Params(
142 uid=uid,
143 fileName=p.fileName,
144 totalSize=p.totalSize,
145 chunkCount=p.chunkCount,
146 ))
148 return self._get_params(uid)
150 def _get_params(self, uid):
151 if not uid.isalnum():
152 raise Error(f'upload: {uid!r} invalid')
154 dd = self._base_dir(uid)
156 try:
157 return _Params(gws.lib.jsonx.from_path(f'{dd}/s.json'))
158 except gws.lib.jsonx.Error as exc:
159 raise Error(f'upload: {uid!r} not found') from exc
161 def _base_dir(self, uid):
162 return gws.u.ephemeral_dir(f'upload_{uid}')