Xqt has submitted this change. ( 
https://gerrit.wikimedia.org/r/c/pywikibot/core/+/1325981?usp=email )

Change subject: upload: Replace recursive orchestration
......................................................................

upload: Replace recursive orchestration

Model upload progress with explicit private states so warning
retries and stash resumptions no longer re-enter _upload().

Separate preparation, chunk transfer, polling, and publication
while preserving existing upload behavior.

Bug: T434936
Change-Id: I95eb1f88d6a957e619c286b2a67626cf43ac343a
---
M pywikibot/site/_upload.py
1 file changed, 492 insertions(+), 346 deletions(-)

Approvals:
  Xqt: Verified; Looks good to me, approved




diff --git a/pywikibot/site/_upload.py b/pywikibot/site/_upload.py
index 0e605d3..543b66e 100644
--- a/pywikibot/site/_upload.py
+++ b/pywikibot/site/_upload.py
@@ -9,6 +9,9 @@
 import mimetypes
 import os
 from collections.abc import Iterable
+from dataclasses import dataclass
+from enum import Enum, auto
+from typing import Any
 from warnings import warn

 import pywikibot
@@ -19,6 +22,49 @@
 __all__ = ('Uploader', )


+class _UploadStage(Enum):
+
+    """Stages of an upload attempt."""
+
+    PREPARE = auto()
+    RECOVER_STASH = auto()
+    TRANSFER = auto()
+    POLL_TRANSFER = auto()
+    FINALIZE = auto()
+    POLL_PUBLICATION = auto()
+
+
+class _ResumeMode(Enum):
+
+    """Ways to resume an upload from the server stash."""
+
+    FRESH = auto()
+    DISCOVER_OFFSET = auto()
+    AT_OFFSET = auto()
+    COMPLETE_STASH = auto()
+
+
+@dataclass
+class _UploadState:
+
+    """Mutable progress for one upload invocation."""
+
+    ignore_warnings: Any
+    ignore_all_warnings: bool
+    report_success: bool | None
+    stage: _UploadStage = _UploadStage.PREPARE
+    resume_mode: _ResumeMode = _ResumeMode.FRESH
+    file_key: str | None = None
+    offset: int = 0
+    file_size: int | None = None
+    token: str | None = None
+    file_page_title: str | None = None
+    request: Any = None
+    response: dict[str, Any] | None = None
+    data_result: str | None = None
+    throttle: bool = True
+
+
 class Uploader:

     """Uploader class to upload a file to the wiki.
@@ -150,20 +196,13 @@

     def _upload(self, ignore_warnings, report_success,
                 file_key=None, offset=0) -> bool:
-        """Recursive Upload method.
+        """Upload the file using explicit state transitions."""
+        state = self._new_state(ignore_warnings, report_success)
+        self._restart_upload(state, file_key, offset)
+        return self._run_upload(state)

-        :param file_key: Reuses an already uploaded file using the
-            filekey. If None (default) it will upload the file.
-        :param offset: When file_key is not None this can be an integer
-            to continue a previously canceled chunked upload. If False
-            it treats that as a finished upload. If True it requests the
-            stash info from the server to determine the offset. By
-            default starts at 0.
-        :return: Whether the upload was successful.
-        """
-        # An offset != 0 doesn't make sense without a file key
-        assert offset == 0 or file_key is not None
-
+    def _new_state(self, ignore_warnings, report_success) -> _UploadState:
+        """Normalize upload options and create its initial state."""
         if report_success is None:
             report_success = isinstance(ignore_warnings, bool)
         if report_success is True and not isinstance(ignore_warnings, bool):
@@ -175,358 +214,465 @@
             def ignore_warnings(warnings):
                 return all(w.code in ignored_warnings for w in warnings)

-        ignore_all_warnings = not callable(ignore_warnings) and ignore_warnings
+        ignore_all_warnings = (
+            not callable(ignore_warnings) and ignore_warnings)
+        return _UploadState(ignore_warnings, ignore_all_warnings,
+                            report_success)

-        token = self.site.tokens['csrf']
-        result = None
-        file_page_title = self.filepage.title(
+    def _run_upload(self, state: _UploadState) -> bool:
+        """Run upload stages until the upload completes or aborts."""
+        while True:
+            if state.stage is _UploadStage.PREPARE:
+                self._prepare_upload(state)
+            elif state.stage is _UploadStage.RECOVER_STASH:
+                self._recover_stash(state)
+            elif state.stage in {
+                    _UploadStage.TRANSFER,
+                    _UploadStage.POLL_TRANSFER,
+            }:
+                outcome = self._transfer_chunks(state)
+                if outcome is not None:
+                    return outcome
+            elif state.stage in {
+                    _UploadStage.FINALIZE,
+                    _UploadStage.POLL_PUBLICATION,
+            }:
+                outcome = self._finalize_upload(state)
+                if outcome is not None:
+                    return outcome
+            else:  # pragma: no cover - all stages are handled above
+                raise AssertionError(f'Unknown upload stage: {state.stage}')
+
+    def _prepare_upload(self, state: _UploadState) -> None:
+        """Prepare one fresh or resumed upload attempt."""
+        state.token = self.site.tokens['csrf']
+        state.file_page_title = self.filepage.title(
             with_ns=False,
             with_section=False,
         )
-        file_size = None
+        state.file_size = None
+        state.request = None
+        state.response = None
+        state.data_result = None
+        state.throttle = True

-        # make sure file actually exists
         if self.filename:
             if os.path.isfile(self.filename):
-                file_size = os.path.getsize(self.filename)
-            elif offset is not False:
+                state.file_size = os.path.getsize(self.filename)
+            elif state.resume_mode is not _ResumeMode.COMPLETE_STASH:
                 raise ValueError(f"File '{self.filename}' does not exist.")

-        # Verify the stash when a file key and offset is given:
-        # requests the SHA1 and file size uploaded and compares it to
-        # the local file. Also verify that offset is matching the
-        # file size if the offset is an int. If offset is False if
-        # verifies that the file size match with the local file.
-        verify_stash = False
-        if self.filename and file_key:
-            assert offset is False or file_size is not None
-            verify_stash = True
-            if (offset is not False and offset is not True
-                    and offset > file_size):
-                raise ValueError(
-                    f'For the file key "{file_key}" the offset was set to '
-                    f'{offset} while the file is only {file_size} bytes large.'
-                )
-
-        if verify_stash or offset is True:
-            if not file_key:
-                raise ValueError('Without a file key it cannot request the '
-                                 'stash information')
-            if not self.filename:
-                raise ValueError('Can request stash information only when '
-                                 'using a file name.')
-            props = ['size']
-            if verify_stash:
-                props.append('sha1')
-            stash_info = self.site.stash_info(file_key, props)
-            if offset is True:
-                offset = stash_info['size']
-            elif offset is False:
-                if file_size != stash_info['size']:
-                    raise ValueError(
-                        f'For the file key "{file_key}" the server reported '
-                        f'a size {stash_info["size"]} while the file size is '
-                        f'{file_size}')
-            elif offset is not False and offset != stash_info['size']:
-                raise ValueError(
-                    f'For the file key "{file_key}" the server reported '
-                    f'a size {stash_info["size"]} while the offset was '
-                    f'{offset}')
-
-            if verify_stash:
-                # The SHA1 was also requested so calculate and compare it
-                assert 'sha1' in stash_info, \
-                    f'sha1 not in stash info: {stash_info}'
-                sha1 = compute_file_hash(
-                    self.filename,
-                    bytes_to_read=None if offset is False else offset)
-                if sha1 != stash_info['sha1']:
-                    raise ValueError(
-                        f'The SHA1 of {offset} bytes of the stashed '
-                        f'"{file_key}" is {stash_info["sha1"]} '
-                        f'while the local file is {sha1}')
-
-        assert offset is not True
-        if file_key and file_size is None:
-            assert offset is False
-
-        data = {}
-        if file_key and offset is False or offset == file_size:
-            pywikibot.log(
-                f'Reused already upload file using filekey "{file_key}"')
-            # TODO: Use sessionkey instead of filekey if necessary
-            final_request = self.site._request(
-                parameters={
-                    'action': 'upload',
-                    'token': token,
-                    'filename': file_page_title,
-                    'comment': self.comment,
-                    'text': self.text,
-                    'async': self.asynchronous,
-                    'filekey': file_key
-                })
-
-        elif self.filename:
-            # TODO: Dummy value to allow also Unicode names, see bug T75661
-            mime_filename = 'FAKE-NAME'
-            # upload local file
-            throttle = True
-            filesize = os.path.getsize(self.filename)
-            chunked_upload = 0 < self.chunk_size < filesize
-            with open(self.filename, 'rb') as f:
-                final_request = self.site._request(
-                    throttle=throttle, parameters={
-                        'action': 'upload', 'token': token, 'text': self.text,
-                        'filename': file_page_title, 'comment': self.comment})
-                if chunked_upload:
-                    if offset > 0:
-                        pywikibot.log(f'Continuing upload from byte {offset}')
-                    poll = False
-                    while True:
-
-                        if poll:
-                            # run a poll; not possible in first iteration
-                            assert file_key
-                            req = self.site.simple_request(
-                                action='upload',
-                                token=token,
-                                filekey=file_key,
-                                checkstatus=True)
-                        else:
-                            f.seek(offset)
-                            chunk = f.read(self.chunk_size)
-                            # workaround (hack) for T132676
-                            # append another '\r' so that one is the payload
-                            # and the second is used for newline when mangled
-                            # by email package.
-                            if (len(chunk) < self.chunk_size
-                                    or (offset + len(chunk)) == filesize
-                                    and chunk[-1] == b'\r'[0]):
-                                chunk += b'\r'
-
-                            mime_params = {
-                                'chunk': (chunk,
-                                          ('application', 'octet-stream'),
-                                          {'filename': mime_filename})
-                            }
-                            req = self.site._request(
-                                throttle=throttle,
-                                mime=mime_params,
-                                parameters={
-                                    'action': 'upload',
-                                    'token': token,
-                                    'stash': True,
-                                    'filesize': filesize,
-                                    'offset': offset,
-                                    'filename': file_page_title,
-                                    'async': self.asynchronous,
-                                    'ignorewarnings': ignore_all_warnings})
-
-                            if file_key:
-                                req['filekey'] = file_key
-
-                        try:
-                            data = req.submit()['upload']
-                        except APIError as error:
-                            # TODO: catch and process foreseeable errors
-                            if error.code == 'stashfailed' \
-                                    and 'offset' in error.other:
-                                # TODO: Ask MediaWiki to change this
-                                # ambiguous error code.
-
-                                new_offset = int(error.other['offset'])
-                                # If the offset returned from the server
-                                # (the offset it expects now) is equal to
-                                # the offset we sent it, there must be
-                                # something else that prevented the upload,
-                                # instead of simple offset mismatch. This
-                                # also prevents infinite loops when we
-                                # upload the same chunk again and again,
-                                # every time ApiError.
-                                if offset != new_offset:
-                                    pywikibot.log(
-                                        f'Old offset: {offset}; Returned '
-                                        f'offset: {new_offset}; Chunk size: '
-                                        f'{len(chunk)}'
-                                    )
-                                    pywikibot.warning('Attempting to correct '
-                                                      'automatically from '
-                                                      'offset mismatch error.')
-                                    offset = new_offset
-                                    continue
-                            raise
-                        if 'nochange' in data:  # in simulation mode
-                            break
-
-                        # Polls may not contain file key in response
-                        file_key = data.get('filekey', file_key)
-                        if data['result'] == 'Warning':
-                            assert ('warnings' in data
-                                    and not ignore_all_warnings)
-                            if callable(ignore_warnings):
-                                restart = False
-                                if 'offset' not in data:
-                                    # This is a result of a warning in the
-                                    # first chunk. The chunk is not actually
-                                    # stashed so upload must be restarted if
-                                    # the warning is allowed.
-                                    # T112416 and T112405#1637544
-                                    restart = True
-                                    data['offset'] = True
-                                if ignore_warnings(self.create_warnings_list(
-                                        data, file_key)):
-                                    # Future warnings of this run
-                                    # can be ignored
-                                    if restart:
-                                        return self._upload(
-                                            ignore_warnings=True,
-                                            report_success=False
-                                        )
-
-                                    ignore_warnings = True
-                                    ignore_all_warnings = True
-                                    offset = data['offset']
-                                    continue
-                                return False
-                            result = data
-                            result.setdefault('offset', 0)
-                            break
-
-                        if data['result'] == 'Continue':
-                            throttle = False
-                            if 'offset' in data:
-                                new_offset = int(data['offset'])
-                                if offset + len(chunk) != new_offset:
-                                    pywikibot.log(
-                                        f'Old offset: {offset}; Returned '
-                                        f'offset: {new_offset}; Chunk size: '
-                                        f'{len(chunk)}'
-                                    )
-                                    pywikibot.warning('Unexpected offset.')
-                                offset = new_offset
-                            else:
-                                pywikibot.warning('Offset was not supplied.')
-                                offset += len(chunk)
-                        elif data['result'] == 'Poll':
-                            poll = True
-                            pywikibot.log('Waiting for server to '
-                                          'assemble chunks.')
-                        elif data['result'] == 'Success':  # finished
-                            pywikibot.log('Finished uploading last chunk.')
-                            final_request['filekey'] = file_key
-                            final_request['async'] = self.asynchronous
-                            break
-                        else:
-                            raise Error('Unrecognized result: {result}'
-                                        .format_map(data))
-
-                elif file_key:
-                    final_request['filekey'] = file_key
-                else:
-                    file_contents = f.read()
-                    filetype = (mimetypes.guess_type(self.filename)[0]
-                                or 'application/octet-stream')
-                    final_request.mime = {
-                        'file': (file_contents, filetype.split('/'),
-                                 {'filename': mime_filename})
-                    }
+        if self.filename and state.file_key:
+            self._validate_resume_offset(state)
+            state.stage = _UploadStage.RECOVER_STASH
         else:
-            # upload by URL
-            if not self.site.has_right('upload_by_url'):
-                raise Error(f"User '{self.site.user()}' is not authorized to "
-                            f'upload by URL on site {self}.')
-            final_request = self.site.simple_request(
-                action='upload', filename=file_page_title, url=self.url,
-                comment=self.comment, text=self.text, token=token)
+            self._prepare_request(state)

-        return self.submit(final_request, result, data.get('result'),
-                           ignore_warnings, ignore_all_warnings,
-                           report_success, file_key)
+    def _validate_resume_offset(self, state: _UploadState) -> None:
+        """Validate a client-provided resume offset."""
+        assert (state.resume_mode is _ResumeMode.COMPLETE_STASH
+                or state.file_size is not None)
+        file_size = state.file_size
+        if (state.resume_mode is _ResumeMode.AT_OFFSET
+                and file_size is not None
+                and state.offset > file_size):
+            raise ValueError(
+                f'For the file key "{state.file_key}" the offset was set to '
+                f'{state.offset} while the file is only '
+                f'{file_size} bytes large.'
+            )

-    def submit(self, request, result, data_result: str | None,
-               ignore_warnings, ignore_all_warnings, report_success,
-               file_key) -> bool:
-        """Submit request and return whether upload was successful."""
-        # some warning keys have been changed
+    def _recover_stash(self, state: _UploadState) -> None:
+        """Validate a server stash and select its next stage."""
+        if not state.file_key:
+            raise ValueError('Without a file key it cannot request the '
+                             'stash information')
+        if not self.filename:
+            raise ValueError('Can request stash information only when '
+                             'using a file name.')
+
+        stash_info = self.site.stash_info(
+            state.file_key, ['size', 'sha1'])
+        if state.resume_mode is _ResumeMode.DISCOVER_OFFSET:
+            state.offset = stash_info['size']
+        elif state.resume_mode is _ResumeMode.COMPLETE_STASH:
+            if state.file_size != stash_info['size']:
+                raise ValueError(
+                    f'For the file key "{state.file_key}" the server reported '
+                    f'a size {stash_info["size"]} while the file size is '
+                    f'{state.file_size}')
+            assert state.file_size is not None
+            state.offset = state.file_size
+        elif state.offset != stash_info['size']:
+            raise ValueError(
+                f'For the file key "{state.file_key}" the server reported '
+                f'a size {stash_info["size"]} while the offset was '
+                f'{state.offset}')
+
+        assert 'sha1' in stash_info, f'sha1 not in stash info: {stash_info}'
+        sha1 = compute_file_hash(
+            self.filename,
+            bytes_to_read=(
+                None if state.resume_mode is _ResumeMode.COMPLETE_STASH
+                else state.offset))
+        if sha1 != stash_info['sha1']:
+            raise ValueError(
+                f'The SHA1 of {state.offset} bytes of the stashed '
+                f'"{state.file_key}" is {stash_info["sha1"]} '
+                f'while the local file is {sha1}')
+
+        self._prepare_request(state)
+
+    def _prepare_request(self, state: _UploadState) -> None:
+        """Build an upload request or select chunk transfer."""
+        reuse_stash = (
+            bool(state.file_key)
+            and state.resume_mode is _ResumeMode.COMPLETE_STASH
+        ) or state.offset == state.file_size
+        if reuse_stash:
+            pywikibot.log(
+                'Reused already upload file using filekey '
+                f'"{state.file_key}"')
+            # TODO: Use sessionkey instead of filekey if necessary
+            state.request = self.site._request(parameters={
+                'action': 'upload',
+                'token': state.token,
+                'filename': state.file_page_title,
+                'comment': self.comment,
+                'text': self.text,
+                'async': self.asynchronous,
+                'filekey': state.file_key,
+            })
+            state.stage = _UploadStage.FINALIZE
+            return
+
+        if self.filename:
+            self._prepare_file_request(state)
+            return
+
+        if not self.site.has_right('upload_by_url'):
+            raise Error(f"User '{self.site.user()}' is not authorized to "
+                        f'upload by URL on site {self}.')
+        state.request = self.site.simple_request(
+            action='upload', filename=state.file_page_title, url=self.url,
+            comment=self.comment, text=self.text, token=state.token)
+        state.stage = _UploadStage.FINALIZE
+
+    def _prepare_file_request(self, state: _UploadState) -> None:
+        """Build a local-file request and select its transfer mode."""
+        assert self.filename is not None
+        state.file_size = os.path.getsize(self.filename)
+        state.request = self.site._request(
+            throttle=state.throttle,
+            parameters={
+                'action': 'upload',
+                'token': state.token,
+                'text': self.text,
+                'filename': state.file_page_title,
+                'comment': self.comment,
+            })
+        if 0 < self.chunk_size < state.file_size:
+            state.stage = _UploadStage.TRANSFER
+            return
+
+        if state.file_key:
+            state.request['filekey'] = state.file_key
+        else:
+            # TODO: Dummy value to allow Unicode names, see T75661
+            with open(self.filename, 'rb') as f:
+                file_contents = f.read()
+            filetype = (mimetypes.guess_type(self.filename)[0]
+                        or 'application/octet-stream')
+            state.request.mime = {
+                'file': (file_contents, filetype.split('/'),
+                         {'filename': 'FAKE-NAME'})
+            }
+        state.stage = _UploadStage.FINALIZE
+
+    def _transfer_chunks(self, state: _UploadState) -> bool | None:
+        """Transfer chunks until another upload stage is required."""
+        assert self.filename is not None
+        if state.offset > 0:
+            pywikibot.log(f'Continuing upload from byte {state.offset}')
+
+        chunk = b''
+        with open(self.filename, 'rb') as f:
+            while state.stage in {
+                    _UploadStage.TRANSFER,
+                    _UploadStage.POLL_TRANSFER,
+            }:
+                request, new_chunk = self._next_chunk_request(state, f)
+                if new_chunk is not None:
+                    chunk = new_chunk
+                try:
+                    data = request.submit()['upload']
+                except APIError as error:
+                    if self._correct_stash_offset(error, state, chunk):
+                        continue
+                    raise
+
+                outcome = self._handle_chunk_response(state, data, chunk)
+                if outcome is not None:
+                    return outcome
+        return None
+
+    def _next_chunk_request(self, state: _UploadState, file_obj):
+        """Create the next chunk-transfer or polling request."""
+        if state.stage is _UploadStage.POLL_TRANSFER:
+            assert state.file_key
+            return self.site.simple_request(
+                action='upload', token=state.token,
+                filekey=state.file_key, checkstatus=True), None
+
+        file_obj.seek(state.offset)
+        chunk = file_obj.read(self.chunk_size)
+        # Workaround for T132676: append another carriage return so that
+        # one remains in the payload if the email package mangles it.
+        if (len(chunk) < self.chunk_size
+                or (state.offset + len(chunk)) == state.file_size
+                and chunk[-1] == b'\r'[0]):
+            chunk += b'\r'
+        request = self.site._request(
+            throttle=state.throttle,
+            mime={
+                'chunk': (chunk, ('application', 'octet-stream'),
+                          {'filename': 'FAKE-NAME'}),
+            },
+            parameters={
+                'action': 'upload',
+                'token': state.token,
+                'stash': True,
+                'filesize': state.file_size,
+                'offset': state.offset,
+                'filename': state.file_page_title,
+                'async': self.asynchronous,
+                'ignorewarnings': state.ignore_all_warnings,
+            })
+        if state.file_key:
+            request['filekey'] = state.file_key
+        return request, chunk
+
+    @staticmethod
+    def _correct_stash_offset(error, state: _UploadState, chunk) -> bool:
+        """Correct a mismatched stash offset when the server supplies one."""
+        if error.code != 'stashfailed' or 'offset' not in error.other:
+            return False
+        new_offset = int(error.other['offset'])
+        if state.offset == new_offset:
+            return False
+        pywikibot.log(
+            f'Old offset: {state.offset}; Returned offset: {new_offset}; '
+            f'Chunk size: {len(chunk)}')
+        pywikibot.warning('Attempting to correct automatically from '
+                          'offset mismatch error.')
+        state.offset = new_offset
+        return True
+
+    def _handle_chunk_response(self, state: _UploadState, data,
+                               chunk) -> bool | None:
+        """Apply a chunk response and select the next stage."""
+        state.data_result = data.get('result')
+        if 'nochange' in data:  # in simulation mode
+            state.stage = _UploadStage.FINALIZE
+            return None
+
+        # Polls may not contain a file key in the response.
+        state.file_key = data.get('filekey', state.file_key)
+        if data['result'] == 'Warning':
+            return self._handle_chunk_warning(state, data)
+        if data['result'] == 'Continue':
+            self._continue_chunk(state, data, chunk)
+        elif data['result'] == 'Poll':
+            state.stage = _UploadStage.POLL_TRANSFER
+            pywikibot.log('Waiting for server to assemble chunks.')
+        elif data['result'] == 'Success':
+            pywikibot.log('Finished uploading last chunk.')
+            state.request['filekey'] = state.file_key
+            state.request['async'] = self.asynchronous
+            state.stage = _UploadStage.FINALIZE
+        else:
+            raise Error('Unrecognized result: {result}'.format_map(data))
+        return None
+
+    def _handle_chunk_warning(self, state: _UploadState,
+                              data) -> bool | None:
+        """Resolve a warning returned while transferring chunks."""
+        assert 'warnings' in data and not state.ignore_all_warnings
+        if not callable(state.ignore_warnings):
+            state.response = data
+            state.response.setdefault('offset', 0)
+            state.stage = _UploadStage.FINALIZE
+            return None
+
+        restart = 'offset' not in data
+        if restart:
+            # A first-chunk warning has no stashed data to continue.
+            # See T112416 and T112405#1637544.
+            data['offset'] = True
+        warnings = self.create_warnings_list(data, state.file_key)
+        if not state.ignore_warnings(warnings):
+            return False
+
+        self._ignore_future_warnings(state)
+        if restart:
+            self._restart_upload(state)
+        else:
+            state.offset = data['offset']
+        return None
+
+    @staticmethod
+    def _continue_chunk(state: _UploadState, data, chunk) -> None:
+        """Advance state after a successful chunk transfer."""
+        state.throttle = False
+        if 'offset' in data:
+            new_offset = int(data['offset'])
+            if state.offset + len(chunk) != new_offset:
+                pywikibot.log(
+                    f'Old offset: {state.offset}; Returned offset: '
+                    f'{new_offset}; Chunk size: {len(chunk)}')
+                pywikibot.warning('Unexpected offset.')
+            state.offset = new_offset
+        else:
+            pywikibot.warning('Offset was not supplied.')
+            state.offset += len(chunk)
+
+    @staticmethod
+    def _ignore_future_warnings(state: _UploadState) -> None:
+        """Ignore any warnings after the callback accepts them."""
+        state.ignore_warnings = True
+        state.ignore_all_warnings = True
+        state.report_success = False
+
+    def _restart_upload(self, state: _UploadState, file_key=None,
+                        offset=0) -> None:
+        """Start a new attempt with explicit resume information."""
+        assert offset == 0 or file_key is not None
+        state.stage = _UploadStage.PREPARE
+        state.file_key = file_key
+        state.offset = 0
+        if not self.filename:
+            state.resume_mode = _ResumeMode.FRESH
+        elif offset is True:
+            state.resume_mode = _ResumeMode.DISCOVER_OFFSET
+        elif offset is False:
+            state.resume_mode = _ResumeMode.COMPLETE_STASH
+        elif file_key:
+            state.resume_mode = _ResumeMode.AT_OFFSET
+            state.offset = offset
+        else:
+            state.resume_mode = _ResumeMode.FRESH
+        state.request = None
+        state.response = None
+        state.data_result = None
+
+    def _finalize_upload(self, state: _UploadState) -> bool | None:
+        """Submit or poll the final publication request."""
+        assert state.request is not None
+        if not state.response:
+            state.request['watch'] = self.watch
+            state.request['ignorewarnings'] = state.ignore_all_warnings
+            state.response = state.request.submit()['upload']
+            pywikibot.debug(state.response)
+
+        if 'result' not in state.response:
+            raise Error(
+                f'Upload: unrecognized response: {state.response}')
+        if state.response['result'] == 'Warning':
+            return self._handle_final_warning(state)
+        if state.response['result'] == 'Poll':
+            self._poll_publication(state)
+            return None
+        if state.response['result'] == 'Success':
+            return self._complete_upload(state)
+        raise Error(
+            'Unrecognized result: '
+            f"{state.data_result or state.response['result']}")
+
+    def _handle_final_warning(self, state: _UploadState) -> bool | None:
+        """Resolve a warning returned during final publication."""
+        result = state.response
+        assert result is not None
+        assert 'warnings' in result and not state.ignore_all_warnings
+        warnings = result['warnings']
+        self._update_warning_file_key(state, result)
+
+        if not state.report_success:
+            result.setdefault('offset', bool(self.filename))
+            offset = result['offset'] if self.filename else False
+            warning_list = self.create_warnings_list(
+                result, state.file_key)
+            if state.ignore_warnings(warning_list):
+                self._ignore_future_warnings(state)
+                self._restart_upload(state, state.file_key, offset)
+                return None
+            return False
+
+        if len(warnings) > 1:
+            warn(f'The upload returned {len(warnings)} warnings: '
+                 f'{", ".join(warnings)}', UserWarning, 3)
+        warning, message = next(iter(warnings.items()))
         warning_keys = {
             'nochange': 'no-change',
             'duplicateversions': 'duplicate-version',
             'emptyfile': 'empty-file',
         }
+        warning = warning_keys.get(warning, warning)
+        raise UploadError(warning,
+                          self.upload_warnings[warning].format(msg=message),
+                          file_key=state.file_key,
+                          offset=result.get('offset', False))

-        token = request['token']
-        while True:
-            if not result:
-                request['watch'] = self.watch
-                request['ignorewarnings'] = ignore_all_warnings
-                result = request.submit()['upload']
-                pywikibot.debug(result)
+    def _update_warning_file_key(self, state: _UploadState, result) -> None:
+        """Update the file key supplied with a final warning."""
+        if not self.filename:
+            state.file_key = None
+        elif 'filekey' in result:
+            state.file_key = result['filekey']
+        elif 'sessionkey' in result:
+            # TODO: Reflect sessionkey in the API call above if necessary.
+            state.file_key = result['sessionkey']
+            pywikibot.warning('Using sessionkey instead of filekey.')
+        else:
+            state.file_key = None
+            pywikibot.warning('No filekey defined.')

-            if 'result' not in result:
-                raise Error(f'Upload: unrecognized response: {result}')
+    def _poll_publication(self, state: _UploadState) -> None:
+        """Prepare a status request for asynchronous publication."""
+        assert state.file_key
+        pywikibot.log('Waiting for upload to be published.')
+        state.response = None
+        state.request = self.site.simple_request(
+            action='upload', token=state.token,
+            filekey=state.file_key, checkstatus=True)
+        state.stage = _UploadStage.POLL_PUBLICATION

-            if result['result'] == 'Warning':
-                assert 'warnings' in result and not ignore_all_warnings
-                warnings = result['warnings']
+    def _complete_upload(self, state: _UploadState) -> bool:
+        """Record a successful upload and return its result."""
+        result = state.response
+        assert result is not None
+        if state.report_success:
+            pywikibot.info('Upload successful.')
+        if 'nochange' not in result:
+            self.filepage._load_file_revisions([result['imageinfo']])
+        return True

-                if self.filename:
-                    if 'filekey' in result:
-                        file_key = result['filekey']
-                    elif 'sessionkey' in result:
-                        # TODO: Probably needs to be reflected in the API call
-                        # above
-                        file_key = result['sessionkey']
-                        pywikibot.warning(
-                            'Using sessionkey instead of filekey.')
-                    else:
-                        file_key = None
-                        pywikibot.warning('No filekey defined.')
-                else:
-                    file_key = None
+    def submit(self, request, result, data_result: str | None,
+               ignore_warnings, ignore_all_warnings, report_success,
+               file_key) -> bool:
+        """Submit request and return whether upload was successful.

-                if not report_success:
-                    result.setdefault('offset', bool(self.filename))
-                    offset = result['offset'] if self.filename else False
-                    if ignore_warnings(self.create_warnings_list(result,
-                                                                 file_key)):
-                        return self._upload(ignore_warnings=True,
-                                            report_success=False,
-                                            file_key=file_key,
-                                            offset=offset)
-                    return False
-
-                if len(warnings) > 1:
-                    warn(f'The upload returned {len(warnings)} warnings: '
-                         f'{", ".join(warnings)}',
-                         UserWarning, 3)
-                warning, message = next(iter(warnings.items()))
-                warning = warning_keys.get(warning, warning)
-                raise UploadError(warning,
-                                  self.upload_warnings[warning]
-                                  .format(msg=message),
-                                  file_key=file_key,
-                                  offset=result.get('offset', False))
-
-            if result['result'] == 'Poll':
-                # Polling is meaningless without a file key
-                assert file_key
-                pywikibot.log('Waiting for upload to be published.')
-                result = None
-                request = self.site.simple_request(
-                    action='upload',
-                    token=token,
-                    filekey=file_key,
-                    checkstatus=True)
-                continue
-
-            if result['result'] == 'Success':
-                if report_success:
-                    pywikibot.info('Upload successful.')
-                # If we receive a nochange, that would mean we're in simulation
-                # mode, don't attempt to access imageinfo
-                if 'nochange' not in result:
-                    self.filepage._load_file_revisions([result['imageinfo']])
-                return True
-
-            raise Error(
-                f"Unrecognized result: {data_result or result['result']}")
+        This compatibility entry point starts the state machine at final
+        publication. New upload orchestration uses private stage helpers.
+        """
+        state = _UploadState(
+            ignore_warnings,
+            ignore_all_warnings,
+            report_success,
+            stage=_UploadStage.FINALIZE,
+            file_key=file_key,
+            token=request['token'],
+            request=request,
+            response=result,
+            data_result=data_result,
+        )
+        return self._run_upload(state)

--
To view, visit 
https://gerrit.wikimedia.org/r/c/pywikibot/core/+/1325981?usp=email
To unsubscribe, or for help writing mail filters, visit 
https://gerrit.wikimedia.org/r/settings?usp=email

Gerrit-MessageType: merged
Gerrit-Project: pywikibot/core
Gerrit-Branch: master
Gerrit-Change-Id: I95eb1f88d6a957e619c286b2a67626cf43ac343a
Gerrit-Change-Number: 1325981
Gerrit-PatchSet: 7
Gerrit-Owner: Mahveotm <[email protected]>
Gerrit-Reviewer: Multichill <[email protected]>
Gerrit-Reviewer: Xqt <[email protected]>
Gerrit-Reviewer: jenkins-bot
_______________________________________________
Pywikibot-commits mailing list -- [email protected]
To unsubscribe send an email to [email protected]

Reply via email to