Coverage for src/lexigram/web/uploads/pipeline.py: 45%
91 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
1"""File upload handling and validation.
3Provides FileUpload model and validation pipeline.
4"""
6from __future__ import annotations
8from abc import ABC, abstractmethod
9from dataclasses import dataclass
10from pathlib import Path
11from typing import TYPE_CHECKING
13import aiofiles
14import aiofiles.os
16from lexigram.result import Err, Ok, Result
18if TYPE_CHECKING:
19 from starlette.datastructures import UploadFile
22@dataclass(frozen=True)
23class FileUpload:
24 """Represents an uploaded file with metadata."""
26 filename: str
27 content_type: str
28 size: int
29 file: UploadFile
31 @property
32 def extension(self) -> str:
33 """Get file extension."""
34 return Path(self.filename).suffix.lower()
36 @property
37 def name_without_extension(self) -> str:
38 """Get filename without extension."""
39 return Path(self.filename).stem
41 async def read(self) -> bytes:
42 """Read the entire file content."""
43 return await self.file.read()
45 async def save_to(
46 self, path: str | Path, base_dir: Path | str | None = None
47 ) -> None:
48 """Save the file to a path.
50 Args:
51 path: Destination path for the file.
52 base_dir: Optional base directory. When provided, the resolved
53 destination path must be relative to ``base_dir``; if it would
54 escape that directory (e.g. via ``../../``), a
55 :exc:`ValueError` is raised.
57 Raises:
58 ValueError: If ``base_dir`` is provided and the resolved path
59 escapes it (path-traversal prevention).
60 """
61 path = Path(path)
62 if base_dir is not None:
63 resolved = path.resolve()
64 base = Path(base_dir).resolve()
65 if not resolved.is_relative_to(base):
66 raise ValueError(
67 f"Path {str(path)!r} escapes base directory {str(base_dir)!r}"
68 )
69 await aiofiles.os.makedirs(path.parent, exist_ok=True)
71 content = await self.read()
72 async with aiofiles.open(path, "wb") as f:
73 await f.write(content)
75 async def stream_to(
76 self,
77 path: str | Path,
78 chunk_size: int = 8192,
79 base_dir: Path | str | None = None,
80 ) -> None:
81 """Stream file to disk in chunks.
83 Args:
84 path: Destination path for the file.
85 chunk_size: Number of bytes per read/write iteration.
86 base_dir: Optional base directory. When provided, the resolved
87 destination path must be relative to ``base_dir``; if it would
88 escape that directory (e.g. via ``../../``), a
89 :exc:`ValueError` is raised.
91 Raises:
92 ValueError: If ``base_dir`` is provided and the resolved path
93 escapes it (path-traversal prevention).
94 """
95 path = Path(path)
96 if base_dir is not None:
97 resolved = path.resolve()
98 base = Path(base_dir).resolve()
99 if not resolved.is_relative_to(base):
100 raise ValueError(
101 f"Path {str(path)!r} escapes base directory {str(base_dir)!r}"
102 )
103 await aiofiles.os.makedirs(path.parent, exist_ok=True)
105 async with aiofiles.open(path, "wb") as f:
106 while True:
107 chunk = await self.file.read(chunk_size)
108 if not chunk:
109 break
110 await f.write(chunk)
113class AbstractFileValidator(ABC):
114 """Base class for file validation."""
116 @abstractmethod
117 async def validate(self, upload: FileUpload) -> Result[None, FileValidationError]:
118 """Validate the file.
120 Args:
121 upload: The file upload to validate.
123 Returns:
124 Ok(None) if validation passes, Err(FileValidationError) if it fails.
125 """
128@dataclass(frozen=True)
129class FileValidationError:
130 """Error type for file validation failures."""
132 message: str
135class FileSizeValidator(AbstractFileValidator):
136 """Validates file size."""
138 def __init__(self, max_size_bytes: int):
139 self.max_size = max_size_bytes
141 async def validate(self, upload: FileUpload) -> Result[None, FileValidationError]:
142 """Validate file size against the configured maximum.
144 Args:
145 upload: The file upload to validate.
147 Returns:
148 Ok(None) if size is within the limit, Err(FileValidationError) if exceeded.
149 """
150 if upload.size > self.max_size:
151 return Err(
152 FileValidationError(
153 message=f"File size {upload.size} exceeds maximum {self.max_size}",
154 )
155 )
156 return Ok(None)
159class FileTypeValidator(AbstractFileValidator):
160 """Validates file MIME type."""
162 def __init__(self, allowed_types: list[str]):
163 self.allowed_types = [t.lower() for t in allowed_types]
165 async def validate(self, upload: FileUpload) -> Result[None, FileValidationError]:
166 """Validate file MIME type against allowed types.
168 Args:
169 upload: The file upload to validate.
171 Returns:
172 Ok(None) if type is allowed, Err(FileValidationError) if disallowed.
173 """
174 if upload.content_type.lower() not in self.allowed_types:
175 return Err(
176 FileValidationError(
177 message=f"File type {upload.content_type} not allowed. "
178 f"Allowed: {self.allowed_types}",
179 )
180 )
181 return Ok(None)
184class FileExtensionValidator(AbstractFileValidator):
185 """Validates file extension."""
187 def __init__(self, allowed_extensions: list[str]):
188 self.allowed_extensions = [e.lower() for e in allowed_extensions]
190 async def validate(self, upload: FileUpload) -> Result[None, FileValidationError]:
191 """Validate file extension against allowed extensions.
193 Args:
194 upload: The file upload to validate.
196 Returns:
197 Ok(None) if extension is allowed, Err(FileValidationError) if disallowed.
198 """
199 if upload.extension not in self.allowed_extensions:
200 return Err(
201 FileValidationError(
202 message=f"File extension {upload.extension} not allowed. "
203 f"Allowed: {self.allowed_extensions}",
204 )
205 )
206 return Ok(None)
209class FileUploadPipeline:
210 """Pipeline for processing file uploads with validation."""
212 def __init__(self, validators: list[AbstractFileValidator] | None = None):
213 self.validators = validators or []
215 def add_validator(self, validator: AbstractFileValidator) -> FileUploadPipeline:
216 """Add a validator to the pipeline."""
217 self.validators.append(validator)
218 return self
220 async def process(
221 self, upload: FileUpload
222 ) -> Result[FileUpload, FileValidationError]:
223 """Process and validate a file upload.
225 Args:
226 upload: The file upload to process.
228 Returns:
229 Ok(upload) if all validators pass, Err(FileValidationError) on first failure.
230 """
231 for validator in self.validators:
232 result = await validator.validate(upload)
233 if result.is_err():
234 return Err(result.unwrap_err())
235 return Ok(upload)
237 async def process_multiple(
238 self,
239 uploads: list[FileUpload],
240 ) -> list[Result[FileUpload, FileValidationError]]:
241 """Process multiple file uploads."""
242 results = []
243 for upload in uploads:
244 results.append(await self.process(upload))
245 return results