|
3 | 3 | import os |
4 | 4 | from typing import IO, Any, Dict, List, Optional |
5 | 5 |
|
| 6 | +import mwclient.errors |
| 7 | + |
6 | 8 | from osw.controller.file.base import FileController |
7 | 9 | from osw.controller.file.remote import RemoteFileController |
8 | 10 | from osw.core import OSW, model |
|
12 | 14 | _logger = logging.getLogger(__name__) |
13 | 15 |
|
14 | 16 |
|
| 17 | +def format_allowed_extensions(allowed: WtSite.AllowedFileExtensionsResult) -> str: |
| 18 | + """Renders the allowed-extensions hint for a rejected-upload error message""" |
| 19 | + if not allowed.extensions or allowed.fetched_at is None: |
| 20 | + return "" |
| 21 | + ttl_hours = int(WtSite.ALLOWED_FILE_EXTENSIONS_TTL.total_seconds() // 3600) |
| 22 | + return ( |
| 23 | + f" Extensions allowed there, read {allowed.fetched_at:%Y-%m-%d %H:%M:%S} and " |
| 24 | + f"cached for {ttl_hours} h: {', '.join(sorted(allowed.extensions))}. " |
| 25 | + "Call osw.site.clear_allowed_file_extensions_cache() to re-read the list " |
| 26 | + "if the wiki configuration changed since." |
| 27 | + ) |
| 28 | + |
| 29 | + |
| 30 | +def reraise_upload_error( |
| 31 | + error: mwclient.errors.APIError, site: WtSite, title: str, suffix: Optional[str] |
| 32 | +) -> None: |
| 33 | + """Turns a rejected file extension into a readable error, re-raises the rest |
| 34 | +
|
| 35 | + MediaWiki reports a rejected extension as filetype-banned, |
| 36 | + filetype-banned-type or filetype-badtype, depending on the version. |
| 37 | + """ |
| 38 | + if "filetype" not in str(getattr(error, "code", "")): |
| 39 | + raise error |
| 40 | + allowed = site.get_allowed_file_extensions() |
| 41 | + hint = format_allowed_extensions(allowed) |
| 42 | + raise ValueError( |
| 43 | + f"Upload of '{title}' was rejected because the file extension " |
| 44 | + f"'{suffix}' is not allowed on {site.mw_site.host}.{hint}" |
| 45 | + ) from error |
| 46 | + |
| 47 | + |
| 48 | +def assert_upload_success(result: Any, title: str, host: str) -> None: |
| 49 | + """Raises unless the upload API reports that it stored the file |
| 50 | +
|
| 51 | + mwclient raises only when the response carries an 'error' key. A file that |
| 52 | + MediaWiki declined for any other reason comes back as a normal return value |
| 53 | + with a result other than 'Success', so without this check the upload would |
| 54 | + fail silently. |
| 55 | + """ |
| 56 | + status = result.get("result") if isinstance(result, dict) else None |
| 57 | + if status == "Success": |
| 58 | + return |
| 59 | + warnings = result.get("warnings") if isinstance(result, dict) else None |
| 60 | + detail = f" Warnings: {warnings}." if warnings else "" |
| 61 | + raise ValueError( |
| 62 | + f"Upload of '{title}' to {host} did not succeed (result: {status}).{detail}" |
| 63 | + ) |
| 64 | + |
| 65 | + |
| 66 | +def store_params_for_upload( |
| 67 | + se_params: Dict[str, Any], page_existed: bool |
| 68 | +) -> Dict[str, Any]: |
| 69 | + """Picks the overwrite policy for the entity that accompanies an upload |
| 70 | +
|
| 71 | + An upload creates the file page when it was not there yet. store_entity |
| 72 | + would then see an existing page and, under the default 'keep existing', |
| 73 | + leave the metadata unwritten. So the entity has to replace what the upload |
| 74 | + put there. A page that was already there keeps whatever the caller asked |
| 75 | + for. |
| 76 | + """ |
| 77 | + if page_existed: |
| 78 | + return se_params |
| 79 | + return {**se_params, "overwrite": "replace remote"} |
| 80 | + |
| 81 | + |
15 | 82 | class WikiFileController(model.WikiFile, RemoteFileController): |
16 | 83 | """File controller for wiki files""" |
17 | 84 |
|
@@ -133,20 +200,29 @@ def put(self, file: IO, **kwargs: Dict[str, Any]): |
133 | 200 | } |
134 | 201 | for key in ["entities", "namespace"]: |
135 | 202 | se_params.pop(key, None) # avoid duplicated kwargs |
| 203 | + # Upload before storing the entity: MediaWiki offers no transaction |
| 204 | + # across the two writes, so one of them can be left standing. A file |
| 205 | + # page without metadata is visibly incomplete, while metadata without a |
| 206 | + # file looks like a valid entity until someone tries to download it. |
| 207 | + page_existed = self.osw.mw_site.pages[f"{self.namespace}:{self.title}"].exists |
| 208 | + try: |
| 209 | + result = self.osw.mw_site.upload( |
| 210 | + file=file, |
| 211 | + filename=self.title, |
| 212 | + # comment="", |
| 213 | + # description="", |
| 214 | + ignore=True, |
| 215 | + ) |
| 216 | + except mwclient.errors.APIError as e: |
| 217 | + reraise_upload_error(e, self.osw.site, self.title, self.suffix) |
| 218 | + assert_upload_success(result, self.title, self.osw.mw_site.host) |
136 | 219 | self.osw.store_entity( |
137 | 220 | OSW.StoreEntityParam( |
138 | 221 | entities=[self.cast(model.WikiFile, **wf_params)], |
139 | 222 | namespace=self.namespace, |
140 | | - **se_params, |
| 223 | + **store_params_for_upload(se_params, page_existed), |
141 | 224 | ) |
142 | 225 | ) |
143 | | - self.osw.mw_site.upload( |
144 | | - file=file, |
145 | | - filename=self.title, |
146 | | - # comment="", |
147 | | - # description="", |
148 | | - ignore=True, |
149 | | - ) |
150 | 226 |
|
151 | 227 | def put_from(self, other: FileController, **kwargs: Dict[str, Any]): |
152 | 228 | # if isinstance(file, LocalFileController) and self.suffix is None: |
|
0 commit comments