Repository: mwclient/mwclient

Issue:
site.upload() should raise exception for warnings
If you call `wiki.upload()` with an existing filename, MediaWiki returns a warning and does not update the file. Since mwclient swallows these warnings, I just spent a lot of time debugging my program before I figured out what was going on. These warnings should probably be raised as errors instead of ignored.

Discussion:
coveralls: 
[![Coverage Status](https://coveralls.io/builds/65761205/badge)](https://coveralls.io/builds/65761205)

coverage: 75.022% (+0.2%) from 74.805%
when pulling **fb44e3b1208ee63182df9e455d8386552c1f21d3 on feature/handle-upload-exists**
into **390fd4d54e12b2328a18e4613bcb0bc923daa985 on master**.


Patch:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..7915bc0
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,80 @@
+name: Build and publish Python distribution to PyPI and TestPyPI
+
+on: push
+
+jobs:
+  build:
+    name: Build distribution
+    if: startsWith(github.ref, 'refs/tags/v') # only publish to PyPI on version tag pushes
+    runs-on: ubuntu-latest
+
+    steps:
+    - uses: actions/checkout@v4
+    - name: Set up Python
+      uses: actions/setup-python@v5.1.1
+      with:
+        python-version: "3.x"
+
+    - name: Install pypa/build
+      run: python3 -m pip install build --user
+    - name: Build a binary wheel and a source tarball
+      run: python3 -m build
+    - name: Store the distribution packages
+      uses: actions/upload-artifact@v4
+      with:
+        name: python-package-distributions
+        path: dist/
+
+  publish-to-pypi:
+    name: Publish Python distribution to PyPI
+    if: startsWith(github.ref, 'refs/tags/v') # only publish to PyPI on version tag pushes
+    needs:
+    - build
+    runs-on: ubuntu-latest
+    environment:
+      name: pypi
+      url: https://pypi.org/p/mwclient
+    permissions:
+      id-token: write  # IMPORTANT: mandatory for trusted publishing
+
+    steps:
+    - name: Download all the dists
+      uses: actions/download-artifact@v4
+      with:
+        name: python-package-distributions
+        path: dist/
+    - name: Publish distribution to PyPI
+      uses: pypa/gh-action-pypi-publish@release/v1
+
+  github-release:
+    name: Sign the distribution with Sigstore and upload as a GitHub Release
+    if: startsWith(github.ref, 'refs/tags/v') # only publish to GitHub on version tag pushes
+    needs:
+    - publish-to-pypi
+    runs-on: ubuntu-latest
+
+    permissions:
+      contents: write  # IMPORTANT: mandatory for making GitHub Releases
+      id-token: write  # IMPORTANT: mandatory for sigstore
+
+    steps:
+    - name: Download all the dists
+      uses: actions/download-artifact@v4
+      with:
+        name: python-package-distributions
+        path: dist/
+    - name: Sign the dists with Sigstore
+      uses: sigstore/gh-action-sigstore-python@v3.0.0
+      with:
+        inputs: ./dist/*.tar.gz ./dist/*.whl
+    - name: Create GitHub Release
+      env:
+        GITHUB_TOKEN: ${{ github.token }}
+      run: gh release create '${{ github.ref_name }}' --repo '${{ github.repository }}' --notes ""
+    - name: Upload artifact signatures to GitHub Release
+      env:
+        GITHUB_TOKEN: ${{ github.token }}
+      # Upload to GitHub Release using the `gh` CLI.
+      # `dist/` contains the built packages, and the
+      # sigstore-produced signatures and certificates.
+      run: gh release upload '${{ github.ref_name }}' dist/** --repo '${{ github.repository }}'
diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml
index 26858cc..856697b 100644
--- a/.github/workflows/tox.yml
+++ b/.github/workflows/tox.yml
@@ -18,6 +18,7 @@ jobs:
           - "3.10"
           - "3.11"
           - "3.12"
+          - "3.13.0-alpha - 3.13"
         include:
           - os: "ubuntu-latest"
           - os: "ubuntu-20.04"
@@ -28,7 +29,7 @@ jobs:
         with:
           fetch-depth: 0
       - name: Set up Python ${{ matrix.python-version }}
-        uses: actions/setup-python@v5.0.0
+        uses: actions/setup-python@v5.1.1
         with:
           python-version: ${{ matrix.python-version }}
       - name: Install dependencies
diff --git a/.gitignore b/.gitignore
index f8edddd..207ab43 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,5 @@ dist
 .eggs
 .tox
 .idea/*
+.coverage
+coverage.lcov
diff --git a/MANIFEST.in b/MANIFEST.in
index c28ab72..06cfd02 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,2 +1,11 @@
+include CHANGELOG.md
+include CONTRIBUTING.md
+include CREDITS.md
 include README.md
 include LICENSE.md
+# prospector config - https://github.com/landscapeio/prospector
+include .landscape.yaml
+include .editorconfig
+include tox.ini
+graft docs
+graft examples
diff --git a/docs/source/development/index.rst b/docs/source/development/index.rst
index 9894b88..9a9a84c 100644
--- a/docs/source/development/index.rst
+++ b/docs/source/development/index.rst
@@ -12,12 +12,14 @@ Development environment
 
 If you plan to submit a pull request, you should first
 `fork <https://github.com/mwclient/mwclient#fork-destination-box>`_
-the mwclient repo on GitHub, then clone your own fork:
+the mwclient repo on GitHub, then check out the original repository
+and configure your fork as a remote:
 
 .. code:: bash
 
-    $ git clone git@github.com:MYUSERNAME/mwclient.git
+    $ git clone https://github.com/mwclient/mwclient.git
     $ cd mwclient
+    $ git remote add fork git@github.com:MYUSERNAME/mwclient.git
 
 You can then use pip to do an "editable" install so that your
 edits will be immediately available for (both interactive
@@ -27,6 +29,12 @@ and automated) testing:
 
     $ pip install -e .
 
+Create a new branch for your changes:
+
+.. code:: bash
+
+    $ git checkout -b my-branch
+
 Test suite
 ----------
 
@@ -91,7 +99,28 @@ on the main master branch to ease merging:
 
 .. code:: bash
 
-    $ git remote add upstream https://github.com/mwclient/mwclient.git
-    $ git rebase upstream master
+    $ git rebase master
+
+When it is ready, push your branch to your remote:
+
+.. code:: bash
+
+    $ git push -u fork my-branch
+
+Then you can open a pull request on GitHub. You should see a URL to do this
+when you push your branch.
+
+Making a release
+----------------
+
+These instructions are for maintainers of the project.
+To cut a release, ensure ``CHANGELOG.md`` is updated, then use
+`bump-my-version <https://callowayproject.github.io/bump-my-version/>`_:
+
+.. code:: bash
+
+    $ pip install bump-my-version
+    $ bump-my-version bump major|minor|patch
 
-Then push your code and open a pull request on GitHub.
+Then check the commit looks correct and is tagged vX.Y.Z, and push. The
+``.github/workflows/release.yml`` action will publish to PyPI.
diff --git a/docs/source/user/connecting.rst b/docs/source/user/connecting.rst
index 9819729..f369ef0 100644
--- a/docs/source/user/connecting.rst
+++ b/docs/source/user/connecting.rst
@@ -74,7 +74,7 @@ Using a proxy
 -------------
 
 If you need to use a proxy, you can configure the :class:`requests.Session`
-using the `reqs` parameter of the :class:`~mwclient.client.Site`.
+using the `connection_options` parameter of the :class:`~mwclient.client.Site`.
 
 .. code-block:: python
 
@@ -84,7 +84,7 @@ using the `reqs` parameter of the :class:`~mwclient.client.Site`.
       'http': 'http://10.10.1.10:3128',
       'https': 'http://10.10.1.10:1080',
     }
-    site = mwclient.Site('en.wikipedia.org', reqs={"proxy": proxies})
+    site = mwclient.Site('en.wikipedia.org', connection_options={"proxy": proxies})
 
 Errors and warnings
 -------------------
@@ -115,8 +115,8 @@ unauthenticated, just set
 
 .. _oauth:
 
-OAuth
-^^^^^
+OAuth Authentication
+^^^^^^^^^^^^^^^^^^^^
 
 On Wikimedia wikis, the recommended authentication method is to authenticate as
 a `owner-only consumer`_. Once you have obtained the *consumer token* (also
@@ -132,33 +132,73 @@ called *consumer key*), the *consumer secret*, the *access token* and the
 
 .. _owner-only consumer: https://www.mediawiki.org/wiki/OAuth/Owner-only_consumers
 
-.. _old-login:
+.. _clientlogin:
 
-Old-school login
-^^^^^^^^^^^^^^^^
+Clientlogin authentication
+^^^^^^^^^^^^^^^^^^^^^^^^^^
 
-To use old-school login, call the login method:
+The :meth:`~mwclient.client.Site.clientlogin` method supports authentication
+using the ``clientlogin`` API, currently recommended by upstream for non-oauth
+authentication.
+
+For simple username-password authentication, you can do:
+
+    >>> site.clientlogin(username='myusername', password='secret')
+
+However, ``clientlogin`` can be called with arbitrary kwargs which are passed
+through, potentially enabling many different authentication processes,
+depending on server configuration.
+
+``clientlogin`` will retrieve and add the ``logintoken`` kwarg automatically,
+and add a ``loginreturnurl`` kwarg if neither it nor ``logincontinue`` is set.
+
+It returns ``True`` if login immediately succeeds, and raises an error if it
+fails. Otherwise it returns the response from the server for your application
+to parse. You will need to do something appropriate with the response and then
+call ``clientlogin`` again with updated arguments. Please see the
+`upstream documentation`_ for more details.
+
+.. _upstream documentation: https://www.mediawiki.org/wiki/API:Login#Method_2._action=clientlogin
+
+.. _username-password:
+
+Legacy Username-Password Authentication
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+.. warning::
+    Username-Password authentication is not recommended for Wikimedia wikis.
+    See :ref:`oauth` for the recommended authentication method.
+
+To use the legacy ``login`` interface, call :meth:`~mwclient.client.Site.login`
+with your username and password. If login fails, a
+:class:`mwclient.errors.LoginError` will be raised.
 
     >>> site.login('my_username', 'my_password')
 
-If login fails, a :class:`mwclient.errors.LoginError` will be thrown.
-See :meth:`mwclient.client.Site.login` for all options.
+For sites that use "bot passwords", you can use this method to login with a
+bot password. From mediawiki 1.27 onwards, logging in this way with an
+account's main password is deprecated, and may stop working at some point.
+It is recommended to use :ref:`oauth`, :ref:`clientlogin`, or a bot password
+instead.
 
 .. _http-auth:
 
 HTTP authentication
 ^^^^^^^^^^^^^^^^^^^
 
-If your server is configured to use HTTP authentication, you can
-authenticate using the ``httpauth`` parameter. For Basic HTTP authentication:
+.. warning::
+    HTTP authentication does not replace MediaWiki's built-in authentication
+    system. It is used to protect access to the API, not to authenticate users.
 
-    >>> site = Site('awesome.site', httpauth=('my_username', 'my_password'))
+If your server is configured to use HTTP authentication, you can authenticate
+using the ``httpauth`` parameter. This parameter is a proxy to the
+``auth`` parameter of :class:`requests.Session` and can be set to any class
+that extends :class:`requests.auth.AuthBase`. For example, to use basic
+authentication:
 
-You can also pass in any other :ref:`authentication mechanism <requests:authentication>`
-based on the :class:`requests.auth.AuthBase`, such as Digest authentication:
+    >>> from requests.auth import HTTPBasicAuth
+    >>> site = Site('awesome.site', httpauth=HTTPBasicAuth('my_username', 'my_password'))
 
-    >>> from requests.auth import HTTPDigestAuth
-    >>> site = Site('awesome.site', httpauth=HTTPDigestAuth('my_username', 'my_password'))
 
 .. _ssl-auth:
 
diff --git a/mwclient/client.py b/mwclient/client.py
index bd1f715..ecfa534 100644
--- a/mwclient/client.py
+++ b/mwclient/client.py
@@ -65,7 +65,7 @@ class Site:
             text strings are encoded as UTF-8. If dealing with a server that cannot
             handle UTF-8, please provide the username and password already encoded with
             the appropriate encoding.
-        reqs (Dict[str, Any]): Additional arguments to be passed to the
+        connection_options (Dict[str, Any]): Additional arguments to be passed to the
             :py:meth:`requests.Session.request` method when performing API calls. If the
             `timeout` key is empty, a default timeout of 30 seconds is added.
         consumer_token (str): OAuth1 consumer key for owner-only consumers.
@@ -92,9 +92,9 @@ class Site:
     def __init__(self, host, path='/w/', ext='.php', pool=None, retry_timeout=30,
                  max_retries=25, wait_callback=lambda *x: None, clients_useragent=None,
                  max_lag=3, compress=True, force_login=True, do_init=True, httpauth=None,
-                 reqs=None, consumer_token=None, consumer_secret=None, access_token=None,
-                 access_secret=None, client_certificate=None, custom_headers=None,
-                 scheme='https'):
+                 connection_options=None, consumer_token=None, consumer_secret=None,
+                 access_token=None, access_secret=None, client_certificate=None,
+                 custom_headers=None, scheme='https', reqs=None):
         # Setup member variables
         self.host = host
         self.path = path
@@ -103,7 +103,17 @@ class Site:
         self.compress = compress
         self.max_lag = str(max_lag)
         self.force_login = force_login
-        self.requests = reqs or {}
+        if reqs and connection_options:
+            raise ValueError(
+                "reqs is a deprecated alias of connection_options. Do not specify both."
+            )
+        if reqs:
+            warnings.warn(
+                "reqs is deprecated in mwclient 1.0.0. Use connection_options instead",
+                DeprecationWarning
+            )
+            connection_options = reqs
+        self.requests = connection_options or {}
         self.scheme = scheme
         if 'timeout' not in self.requests:
             self.requests['timeout'] = 30  # seconds
@@ -904,6 +914,7 @@ class Site:
         Raises:
             errors.InsufficientPermission
             requests.exceptions.HTTPError
+            errors.FileExists: The file already exists and `ignore` is `False`.
         """
 
         if file_size is not None:
@@ -982,7 +993,16 @@ class Site:
                 info = {}
             if self.handle_api_result(info, kwargs=predata, sleeper=sleeper):
                 response = info.get('upload', {})
+                # Workaround for https://github.com/mwclient/mwclient/issues/211
+                # ----------------------------------------------------------------
+                # Raise an error if the file already exists. This is necessary because
+                # MediaWiki returns a warning, not an error, leading to silent failure.
+                # The user must explicitly set ignore=True (ignorewarnings=True) to
+                # overwrite an existing file.
+                if ignore is False and 'exists' in response.get('warnings', {}):
+                    raise errors.FileExists(filename)
                 break
+
         if file is not None:
             file.close()
         return response
diff --git a/mwclient/errors.py b/mwclient/errors.py
index 845cf31..5c87cca 100644
--- a/mwclient/errors.py
+++ b/mwclient/errors.py
@@ -48,7 +48,18 @@ class ProtectedPageError(EditError, InsufficientPermission):
 
 
 class FileExists(EditError):
-    pass
+    """
+    Raised when trying to upload a file that already exists.
+
+    See also: https://www.mediawiki.org/wiki/API:Upload#Upload_warnings
+    """
+
+    def __init__(self, file_name):
+        self.file_name = file_name
+
+    def __str__(self):
+        return ('The file "{0}" already exists. Set ignore=True to overwrite it.'
+                .format(self.file_name))
 
 
 class LoginError(MwClientError):
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..d11700d
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,22 @@
+[build-system]
+requires = ["setuptools>=40.6.0", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[tool.pytest.ini_options]
+addopts = "--cov mwclient test"
+
+[tool.bumpversion]
+current_version = "0.10.1"
+commit = true
+tag = true
+
+[[tool.bumpversion.files]]
+filename = "setup.py"
+search = "version='{current_version}'"
+replace = "version='{new_version}'"
+
+[[tool.bumpversion.files]]
+filename = "mwclient/client.py"
+
+[[tool.bumpversion.files]]
+filename = "README.md"
diff --git a/setup.cfg b/setup.cfg
index 21addee..ea59aeb 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,25 +1,6 @@
-[bumpversion]
-current_version = 0.10.1
-commit = True
-tag = True
-
 [aliases]
 test = pytest
 
-[bumpversion:file:setup.py]
-search = version='{current_version}'
-replace = version='{new_version}'
-
-[bumpversion:file:mwclient/client.py]
-
-[bumpversion:file:README.md]
-
-[bdist_wheel]
-universal = 1
-
-[tool:pytest]
-addopts = --cov mwclient test
-
 [flake8]
 max-line-length = 90
 ignore = 
diff --git a/setup.py b/setup.py
index a87ba1d..72a53be 100644
--- a/setup.py
+++ b/setup.py
@@ -10,7 +10,9 @@ needs_pytest = set(['pytest', 'test', 'ptr']).intersection(sys.argv)
 pytest_runner = ['pytest-runner'] if needs_pytest else []
 
 setup(name='mwclient',
-      version='0.10.1',  # Use bumpversion to update
+      # See https://mwclient.readthedocs.io/en/latest/development/#making-a-release
+      # for how to update this field and release a new version.
+      version='0.10.1',
       description='MediaWiki API client',
       long_description=README,
       long_description_content_type='text/markdown',
diff --git a/tox.ini b/tox.ini
index deb01f4..3066a0b 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,5 +1,5 @@
 [tox]
-envlist = py35,py36,py37,py38,py39,py310,py311,py312,flake
+envlist = py35,py36,py37,py38,py39,py310,py311,py312,py313,flake
 
 [gh-actions]
 python =
@@ -10,6 +10,7 @@ python =
     3.10: py310
     3.11: py311, flake
     3.12: py312
+    3.13: py313
 
 [testenv]
 deps =


Test patch:
diff --git a/test/test_client.py b/test/test_client.py
index c86077f..71016ac 100644
--- a/test/test_client.py
+++ b/test/test_client.py
@@ -12,7 +12,6 @@ from requests_oauthlib import OAuth1
 
 import unittest.mock as mock
 
-
 if __name__ == "__main__":
     print()
     print("Note: Running in stand-alone mode. Consult the README")
@@ -467,6 +466,18 @@ class TestClient(TestCase):
             site.raw_api("query", "GET", retry_on_error=False)
         assert timesleep.call_count == 25
 
+    @responses.activate
+    def test_connection_options(self):
+        self.httpShouldReturn(self.metaResponseAsJson())
+        args = {"timeout": 60, "stream": False}
+        site = mwclient.Site('test.wikipedia.org', connection_options=args)
+        assert site.requests == args
+        with pytest.warns(DeprecationWarning):
+            site = mwclient.Site('test.wikipedia.org', reqs=args)
+        assert site.requests == args
+        with pytest.raises(ValueError):
+            site = mwclient.Site('test.wikipedia.org', reqs=args, connection_options=args)
+
 class TestLogin(TestCase):
 
     @mock.patch('mwclient.client.Site.site_init')
@@ -762,6 +773,33 @@ class TestClientUploadArgs(TestCase):
         with pytest.raises(mwclient.errors.InsufficientPermission):
             self.site.upload(filename='Test', file=StringIO('test'))
 
+    def test_upload_file_exists(self):
+        self.configure()
+        self.raw_call.side_effect = [
+            self.makePageResponse(title='File:Test.jpg', imagerepository='local',
+                                  imageinfo=[{
+                                      "comment": "",
+                                      "height": 1440,
+                                      "metadata": [],
+                                      "sha1": "69a764a9cf8307ea4130831a0aa0b9b7f9585726",
+                                      "size": 123,
+                                      "timestamp": "2013-12-22T07:11:07Z",
+                                      "user": "TestUser",
+                                      "width": 2160
+                                  }]),
+            json.dumps({'query': {'tokens': {'csrftoken': self.vars['token']}}}),
+            json.dumps({
+                'upload': {'result': 'Warning',
+                           'warnings': {'duplicate': ['Test.jpg'],
+                                        'exists': 'Test.jpg'},
+                           'filekey': '1apyzwruya84.da2cdk.1.jpg',
+                           'sessionkey': '1apyzwruya84.da2cdk.1.jpg'}
+            })
+        ]
+
+        with pytest.raises(mwclient.errors.FileExists):
+            self.site.upload(file=StringIO('test'), filename='Test.jpg', ignore=False)
+
 
 class TestClientGetTokens(TestCase):