tuspyserver 4.2.1__tar.gz → 4.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: tuspyserver
3
- Version: 4.2.1
3
+ Version: 4.2.2
4
4
  Summary: A Python tus server implementation as a FastAPI router
5
5
  Author: Edi Hasaj
6
6
  Author-email: Edi Hasaj <edi.hasaj@applifyer.com>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tuspyserver"
3
- version = "4.2.1"
3
+ version = "4.2.2"
4
4
  description ="A Python tus server implementation as a FastAPI router"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -26,6 +26,31 @@ class TusUploadFile:
26
26
  params: Optional[TusUploadParams] = None,
27
27
  ):
28
28
  self._options = options
29
+
30
+ # Ensure files_dir is a valid directory path
31
+ # Normalize the path and ensure parent directories exist
32
+ files_dir = os.path.abspath(os.path.expanduser(self._options.files_dir))
33
+
34
+ # Check if path exists and is actually a directory
35
+ if os.path.exists(files_dir):
36
+ if not os.path.isdir(files_dir):
37
+ raise OSError(
38
+ f"Upload directory path '{files_dir}' exists but is not a directory. "
39
+ f"Please ensure the path points to a valid directory."
40
+ )
41
+ else:
42
+ # create the files dir if necessary (must be done before creating files)
43
+ try:
44
+ os.makedirs(files_dir, exist_ok=True)
45
+ except OSError as e:
46
+ raise OSError(
47
+ f"Failed to create upload directory '{files_dir}': {e}. "
48
+ f"Please ensure the path is valid and writable."
49
+ ) from e
50
+
51
+ # Update options with normalized path
52
+ self._options.files_dir = files_dir
53
+
29
54
  # init
30
55
  if uid is None:
31
56
  # creating new file
@@ -34,7 +59,9 @@ class TusUploadFile:
34
59
  else:
35
60
  # reading existing file
36
61
  self.uid = uid
37
- if not self.exists:
62
+ # Only create the file if we're explicitly creating a new upload with params
63
+ # This happens when creating a partial or final upload with a specific uid
64
+ if params is not None and not self.exists:
38
65
  self.create()
39
66
  # create the files dir if necessary
40
67
  if not os.path.exists(self._options.files_dir):
@@ -14,23 +14,30 @@ from tuspyserver.params import TusUploadParams
14
14
 
15
15
  class TusUploadInfo:
16
16
  _params: Optional[TusUploadParams]
17
+ _loaded: bool
17
18
  file: TusUploadFile
18
19
 
19
20
  def __init__(self, file: TusUploadFile, params: Optional[TusUploadParams] = None):
20
21
  self.file = file
21
22
  self._params = params
23
+ self._loaded = params is not None # If params provided, consider them loaded
22
24
  # create if doesn't exist
23
25
  if params and not self.exists:
24
26
  self.serialize()
25
27
 
26
28
  @property
27
29
  def params(self):
28
- self.deserialize()
30
+ # Only deserialize if we haven't loaded params yet
31
+ # This prevents overwriting in-memory params on every access
32
+ if not self._loaded:
33
+ self.deserialize()
34
+ self._loaded = True
29
35
  return self._params
30
36
 
31
37
  @params.setter
32
38
  def params(self, value):
33
39
  self._params = value
40
+ self._loaded = True # Mark as loaded since we're explicitly setting params
34
41
  self.serialize()
35
42
 
36
43
  @property
@@ -42,11 +49,34 @@ class TusUploadInfo:
42
49
  return os.path.exists(self.path)
43
50
 
44
51
  def serialize(self) -> None:
45
- with open(self.path, "w") as f:
46
- json_string = json.dumps(
47
- self._params, indent=4, default=lambda k: k.__dict__
48
- )
49
- f.write(json_string)
52
+ """
53
+ Atomically serialize params to info file.
54
+
55
+ Uses a temporary file and atomic rename to prevent corruption
56
+ from concurrent writes.
57
+ """
58
+ # Write to temporary file first
59
+ temp_path = f"{self.path}.tmp"
60
+ try:
61
+ with open(temp_path, "w") as f:
62
+ json_string = json.dumps(
63
+ self._params, indent=4, default=lambda k: k.__dict__
64
+ )
65
+ f.write(json_string)
66
+ f.flush()
67
+ # Ensure data is written to disk
68
+ os.fsync(f.fileno())
69
+
70
+ # Atomic rename - this is atomic on Unix systems
71
+ os.rename(temp_path, self.path)
72
+ except Exception:
73
+ # Clean up temp file if rename fails
74
+ try:
75
+ if os.path.exists(temp_path):
76
+ os.remove(temp_path)
77
+ except Exception:
78
+ pass
79
+ raise
50
80
 
51
81
  def deserialize(self) -> Optional[TusUploadParams]:
52
82
  if self.exists:
@@ -0,0 +1,185 @@
1
+ """
2
+ File locking utilities for tus uploads.
3
+
4
+ Provides advisory file locking using fcntl (Unix) to prevent race conditions
5
+ during concurrent upload operations. This implementation matches tusd's approach
6
+ by using separate lock files stored in a .locks directory, where each lock file
7
+ contains the PID of the process holding the lock.
8
+ """
9
+ import fcntl
10
+ import logging
11
+ import os
12
+ from contextlib import contextmanager
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class FileLock:
20
+ """
21
+ Advisory file lock using fcntl (Unix) with separate lock files.
22
+
23
+ Matches tusd's filelocker approach by creating separate .lock files
24
+ in a .locks directory. Each lock file contains the PID of the process
25
+ holding the lock, allowing for lock cleanup if a process crashes.
26
+ """
27
+
28
+ def __init__(self, file_path: str, locks_dir: Optional[str] = None):
29
+ """
30
+ Initialize a file lock.
31
+
32
+ Args:
33
+ file_path: Path to the upload file to lock
34
+ locks_dir: Directory to store lock files (defaults to {files_dir}/.locks)
35
+ """
36
+ self.file_path = file_path
37
+ self.locks_dir = locks_dir
38
+
39
+ # Derive lock file path from upload file path
40
+ if locks_dir:
41
+ # Ensure locks directory exists
42
+ os.makedirs(locks_dir, exist_ok=True)
43
+ lock_filename = os.path.basename(file_path) + ".lock"
44
+ self.lock_file_path = os.path.join(locks_dir, lock_filename)
45
+ else:
46
+ # Default: create .locks directory next to upload file
47
+ upload_dir = os.path.dirname(file_path)
48
+ locks_dir = os.path.join(upload_dir, ".locks")
49
+ os.makedirs(locks_dir, exist_ok=True)
50
+ lock_filename = os.path.basename(file_path) + ".lock"
51
+ self.lock_file_path = os.path.join(locks_dir, lock_filename)
52
+
53
+ self._lock_fd: Optional[int] = None
54
+
55
+ def acquire(self, blocking: bool = True) -> bool:
56
+ """
57
+ Acquire an exclusive lock on the upload file.
58
+
59
+ Creates a separate lock file and applies an exclusive lock on it.
60
+ The lock file contains the PID of the process holding the lock.
61
+ This matches tusd's filelocker approach.
62
+
63
+ Args:
64
+ blocking: If True, block until lock is acquired. If False, return immediately.
65
+
66
+ Returns:
67
+ True if lock was acquired, False otherwise (only when blocking=False)
68
+ """
69
+ try:
70
+ # Ensure locks directory exists
71
+ os.makedirs(os.path.dirname(self.lock_file_path), exist_ok=True)
72
+
73
+ # Open the lock file for read-write, create if it doesn't exist
74
+ # Use O_RDWR to allow both reading and writing
75
+ # Use O_CREAT to create the file if it doesn't exist
76
+ self._lock_fd = os.open(self.lock_file_path, os.O_RDWR | os.O_CREAT, 0o644)
77
+
78
+ # Ensure the lock file contains only the current PID before writing
79
+ try:
80
+ os.ftruncate(self._lock_fd, 0)
81
+ os.lseek(self._lock_fd, 0, os.SEEK_SET)
82
+ except OSError as e:
83
+ logger.warning(f"Failed to truncate lock file {self.lock_file_path}: {e}")
84
+
85
+ # Write PID to lock file (like tusd does)
86
+ try:
87
+ pid_str = str(os.getpid()).encode('utf-8')
88
+ os.write(self._lock_fd, pid_str)
89
+ os.fsync(self._lock_fd) # Ensure PID is written to disk
90
+ # Seek back to beginning for reading
91
+ os.lseek(self._lock_fd, 0, os.SEEK_SET)
92
+ except (IOError, OSError) as e:
93
+ # If writing PID fails, continue anyway - lock is still valid
94
+ logger.warning(f"Failed to write PID to lock file {self.lock_file_path}: {e}")
95
+
96
+ # Try to acquire exclusive lock (LOCK_EX) on the lock file
97
+ # If blocking=False, use LOCK_NB (non-blocking)
98
+ flags = fcntl.LOCK_EX
99
+ if not blocking:
100
+ flags |= fcntl.LOCK_NB
101
+
102
+ fcntl.flock(self._lock_fd, flags)
103
+ return True
104
+ except (IOError, OSError) as e:
105
+ # Lock acquisition failed
106
+ if self._lock_fd is not None:
107
+ try:
108
+ os.close(self._lock_fd)
109
+ except Exception:
110
+ pass
111
+ self._lock_fd = None
112
+
113
+ if not blocking and e.errno in (11, 35): # EAGAIN/EWOULDBLOCK
114
+ return False
115
+ raise
116
+
117
+ def release(self) -> None:
118
+ """Release the lock and close the file descriptor."""
119
+ if self._lock_fd is not None:
120
+ try:
121
+ fcntl.flock(self._lock_fd, fcntl.LOCK_UN)
122
+ os.close(self._lock_fd)
123
+ except Exception as e:
124
+ logger.warning(f"Error releasing lock file descriptor: {e}")
125
+ finally:
126
+ self._lock_fd = None
127
+
128
+ # Remove the lock file (tusd does this)
129
+ # Note: We remove the lock file but keep the .locks directory
130
+ try:
131
+ if os.path.exists(self.lock_file_path):
132
+ os.remove(self.lock_file_path)
133
+ except Exception as e:
134
+ logger.warning(f"Error removing lock file {self.lock_file_path}: {e}")
135
+
136
+ def get_fd(self) -> Optional[int]:
137
+ """
138
+ Get the file descriptor for the lock file.
139
+
140
+ Returns:
141
+ File descriptor if lock is acquired, None otherwise
142
+ """
143
+ return self._lock_fd
144
+
145
+ def __enter__(self):
146
+ """Context manager entry."""
147
+ self.acquire(blocking=True)
148
+ return self
149
+
150
+ def __exit__(self, exc_type, exc_val, exc_tb):
151
+ """Context manager exit."""
152
+ self.release()
153
+
154
+
155
+ @contextmanager
156
+ def acquire_upload_lock(upload_path: str, locks_dir: Optional[str] = None, blocking: bool = True):
157
+ """
158
+ Context manager for acquiring an upload lock.
159
+
160
+ This matches tusd's filelocker pattern by creating separate lock files
161
+ in a .locks directory. Each lock file contains the PID of the process
162
+ holding the lock.
163
+
164
+ Args:
165
+ upload_path: Path to the upload file to lock
166
+ locks_dir: Directory to store lock files (defaults to {upload_dir}/.locks)
167
+ blocking: If True, block until lock is acquired
168
+
169
+ Yields:
170
+ FileLock instance with the locked file descriptor
171
+
172
+ Example:
173
+ with acquire_upload_lock("/path/to/upload") as lock:
174
+ # Lock is held via lock file in .locks directory
175
+ # Lock file contains PID of this process
176
+ pass
177
+ """
178
+ lock = FileLock(upload_path, locks_dir=locks_dir)
179
+ try:
180
+ acquired = lock.acquire(blocking=blocking)
181
+ if not acquired:
182
+ raise IOError("Could not acquire lock")
183
+ yield lock
184
+ finally:
185
+ lock.release()
@@ -0,0 +1,259 @@
1
+ from __future__ import annotations
2
+
3
+ from copy import deepcopy
4
+ import datetime
5
+ import os
6
+ import typing
7
+ from typing import Callable, Optional
8
+
9
+ import inspect
10
+
11
+ if typing.TYPE_CHECKING:
12
+ from tuspyserver.router import TusRouterOptions
13
+
14
+ from fastapi import Depends, HTTPException, Path, Request
15
+ from starlette.requests import ClientDisconnect
16
+
17
+ from tuspyserver.file import TusUploadFile
18
+ from tuspyserver.lock import acquire_upload_lock
19
+
20
+
21
+ def make_request_chunks_dep(options: TusRouterOptions):
22
+ async def request_chunks_dep(
23
+ request: Request,
24
+ uuid: str = Path(...),
25
+ post_request: bool = False,
26
+ file_dep: Callable[[dict], None] = Depends(options.file_dep),
27
+ ) -> Optional[bool]:
28
+ # Create a copy of options to avoid mutating the original
29
+ file_options = deepcopy(options)
30
+ # call file_dep to possibly update the files_dir
31
+ result = file_dep({})
32
+ # if the callback returned a coroutine, await it
33
+ if inspect.isawaitable(result):
34
+ result = await result
35
+ if isinstance(result, dict):
36
+ file_options.files_dir = result.get("files_dir", file_options.files_dir)
37
+
38
+ # init file handle
39
+ file = TusUploadFile(uid=uuid, options=file_options)
40
+ # check if valid file exists BEFORE acquiring lock
41
+ if not file.exists or not file.info:
42
+ raise HTTPException(status_code=404, detail="Upload not found")
43
+
44
+ # Ensure uploads directory exists before locking
45
+ uploads_dir = file_options.files_dir
46
+ os.makedirs(uploads_dir, exist_ok=True)
47
+
48
+ # Create locks directory path (matches tusd's .locks directory)
49
+ locks_dir = os.path.join(uploads_dir, ".locks")
50
+
51
+ # Acquire lock BEFORE any validation or reading to prevent race conditions
52
+ # This ensures we have exclusive access during the entire write operation
53
+ upload_path = file.path
54
+ try:
55
+ with acquire_upload_lock(upload_path, locks_dir=locks_dir, blocking=True):
56
+ # Re-read info file while holding lock to get latest offset
57
+ # This prevents TOCTOU race condition
58
+ file._info._loaded = False # Force reload
59
+ current_info = file.info
60
+
61
+ if current_info is None:
62
+ raise HTTPException(status_code=404, detail="Upload not found")
63
+
64
+ # Check if upload has expired (distinguish from non-existent uploads)
65
+ if current_info.expires:
66
+ expires_dt = None
67
+ if isinstance(current_info.expires, str):
68
+ try:
69
+ # Try RFC 7231 format first (e.g., "Mon, 02 Jan 2006 15:04:05 GMT")
70
+ from email.utils import parsedate_to_datetime
71
+ expires_dt = parsedate_to_datetime(current_info.expires)
72
+ except (ValueError, TypeError):
73
+ # Fallback to ISO format
74
+ try:
75
+ expires_dt = datetime.datetime.fromisoformat(current_info.expires.replace('Z', '+00:00'))
76
+ except (ValueError, AttributeError):
77
+ pass
78
+ elif isinstance(current_info.expires, (int, float)):
79
+ expires_dt = datetime.datetime.fromtimestamp(current_info.expires)
80
+
81
+ if expires_dt:
82
+ now = datetime.datetime.now(expires_dt.tzinfo) if expires_dt.tzinfo else datetime.datetime.now()
83
+ if expires_dt < now:
84
+ raise HTTPException(status_code=410, detail="Upload expired")
85
+
86
+ # Validate Upload-Offset header matches current file offset (required for PATCH)
87
+ # Use the offset we just read while holding the lock
88
+ upload_offset = request.headers.get("upload-offset")
89
+ if upload_offset is not None:
90
+ try:
91
+ upload_offset = int(upload_offset)
92
+ if current_info.offset != upload_offset:
93
+ raise HTTPException(status_code=409, detail="Offset mismatch")
94
+ except (ValueError, TypeError):
95
+ raise HTTPException(status_code=400, detail="Invalid Upload-Offset header")
96
+ else:
97
+ # Upload-Offset is required for PATCH
98
+ raise HTTPException(status_code=400, detail="Upload-Offset header is required")
99
+
100
+ # Use the validated offset directly - don't re-read from file.info
101
+ # This prevents stale offset issues
102
+ new_params = current_info
103
+ validated_offset = current_info.offset
104
+
105
+ # init variables
106
+ has_chunks = False
107
+ total_bytes_written = 0
108
+
109
+ # process chunk stream - lock is held during entire operation
110
+ with open(upload_path, "ab") as f:
111
+ try:
112
+ async for chunk in request.stream():
113
+ has_chunks = True
114
+ # skip empty chunks but continue processing
115
+ if len(chunk) == 0:
116
+ continue
117
+
118
+ # Calculate new offset based on validated offset + bytes written so far
119
+ new_offset = validated_offset + total_bytes_written
120
+
121
+ # Check if upload would exceed declared size
122
+ if (
123
+ new_params.size is not None
124
+ and new_offset + len(chunk) > new_params.size
125
+ ):
126
+ raise HTTPException(
127
+ status_code=400,
128
+ detail="Upload would exceed declared Upload-Length",
129
+ )
130
+ # throw if max size exceeded
131
+ if new_offset + len(chunk) > options.max_size:
132
+ raise HTTPException(
133
+ status_code=413,
134
+ detail="Upload exceeds maximum allowed size",
135
+ )
136
+ # write chunk otherwise
137
+ f.write(chunk)
138
+ f.flush() # Ensure data is written to disk immediately
139
+ total_bytes_written += len(chunk)
140
+
141
+ # After all chunks are written, update params atomically
142
+ # Lock is still held here, so this is safe
143
+ new_params.offset = validated_offset + total_bytes_written
144
+ new_params.upload_chunk_size = total_bytes_written if total_bytes_written > 0 else 0
145
+ new_params.upload_part += 1
146
+ # Save updated params (atomic write while lock is held)
147
+ file.info = new_params
148
+
149
+ except HTTPException:
150
+ # HTTPExceptions should propagate - don't catch them here
151
+ # The lock will be released by the context manager
152
+ raise
153
+ except ClientDisconnect:
154
+ # Save the current offset before returning, so resume works correctly
155
+ # Lock is still held, so this is safe
156
+ new_params.offset = validated_offset + total_bytes_written
157
+ new_params.upload_chunk_size = total_bytes_written if total_bytes_written > 0 else 0
158
+ new_params.upload_part += 1
159
+ file.info = new_params
160
+ return False
161
+ except Exception as e:
162
+ # save the error
163
+ new_params.error = str(e)
164
+ new_params.offset = validated_offset + total_bytes_written
165
+ # save updated params
166
+ file.info = new_params
167
+ return False
168
+ finally:
169
+ f.close()
170
+
171
+ # For empty files in a POST request, we still want to return True
172
+ # to ensure the file gets created properly
173
+ # Lock is still held here
174
+ if post_request and not has_chunks:
175
+ # update params for empty file
176
+ new_params.offset = validated_offset
177
+ new_params.upload_chunk_size = 0
178
+ new_params.upload_part += 1
179
+ # save updated params
180
+ file.info = new_params
181
+ except (IOError, OSError) as e:
182
+ # Lock acquisition or file operation failed
183
+ raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
184
+
185
+ return True
186
+
187
+ return request_chunks_dep
188
+
189
+
190
+ def get_request_headers(request: Request, uuid: str, prefix: str = "files") -> dict:
191
+ proto = "http"
192
+ host = request.headers.get("host")
193
+
194
+ # Check for forwarded headers first (for proxy setups)
195
+ if request.headers.get("X-Forwarded-Proto") is not None:
196
+ proto = request.headers.get("X-Forwarded-Proto")
197
+ if request.headers.get("X-Forwarded-Host") is not None:
198
+ host = request.headers.get("X-Forwarded-Host")
199
+
200
+ # If no forwarded protocol, try to detect scheme from request URL
201
+ # This handles direct HTTPS connections (e.g., Uvicorn with SSL)
202
+ if proto == "http" and not request.headers.get("X-Forwarded-Proto"):
203
+ try:
204
+ # Use request.url.scheme to detect the actual protocol
205
+ if hasattr(request, 'url') and request.url.scheme:
206
+ proto = request.url.scheme
207
+ except Exception:
208
+ # Fallback to default if URL parsing fails
209
+ pass
210
+
211
+ # Ensure we have a host
212
+ if not host:
213
+ host = "localhost:8000" # fallback host
214
+
215
+ # Build the full path including root_path and router prefixes
216
+ # FastAPI includes the full mount path in request.url.path
217
+ # We need to construct the base path from the current request path
218
+ current_path = request.url.path.rstrip("/")
219
+
220
+ # The current path should end with our prefix (e.g., "/api/files")
221
+ # We want to extract everything before our prefix to build the location header
222
+ clean_prefix = prefix.lstrip("/").rstrip("/")
223
+
224
+ # Find where our prefix appears in the current path
225
+ if current_path == f"/{clean_prefix}":
226
+ # Handle case where current_path is exactly our prefix (e.g., "/files")
227
+ # This means there's no parent path in the URL, check for root_path
228
+ root_path = request.scope.get("root_path", "")
229
+ base_path = root_path.rstrip("/") if root_path else ""
230
+ elif current_path.endswith(f"/{clean_prefix}"):
231
+ # Extract the base path (everything before our prefix)
232
+ base_path = current_path[:-len(f"/{clean_prefix}")]
233
+ elif current_path.endswith(clean_prefix) and current_path != clean_prefix:
234
+ # Handle case where current_path doesn't have leading slash
235
+ base_path = current_path[:-len(clean_prefix)].rstrip("/")
236
+ else:
237
+ # Fallback: check for root_path in request scope
238
+ # This handles FastAPI root_path that might not be included in request.url.path
239
+ root_path = request.scope.get("root_path", "")
240
+ if root_path:
241
+ base_path = root_path.rstrip("/")
242
+ else:
243
+ base_path = ""
244
+
245
+ # Construct the full path
246
+ if base_path:
247
+ full_path = f"{base_path.rstrip('/')}/{clean_prefix}"
248
+ else:
249
+ full_path = clean_prefix
250
+
251
+ # Ensure path starts with /
252
+ if not full_path.startswith("/"):
253
+ full_path = "/" + full_path
254
+
255
+ return {
256
+ "location": f"{proto}://{host}{full_path}/{uuid}",
257
+ "proto": proto,
258
+ "host": host,
259
+ }