././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1723405414.3553734 mwclient-0.11.0/0000755000175100001770000000000014656212146013027 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/.editorconfig0000644000175100001770000000046514656212142015505 0ustar00runnerdocker# top-most .editorconfig file root = true [*] charset = utf-8 end_of_line = lf indent_size = 4 indent_style = space insert_final_newline = true trim_trailing_whitespace = true [*.cfg] indent_size = tab [{make.bat, Makefile}] indent_style = tab [*.py] max_line_length = 90 [*.{yaml, yml}] indent_size = 2 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/.landscape.yaml0000644000175100001770000000013714656212142015720 0ustar00runnerdockerpython-targets: - 2 - 3 pylint: disable: - redefined-builtin - too-many-arguments././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/CHANGELOG.md0000644000175100001770000002262214656212142014640 0ustar00runnerdocker# Release Notes for mwclient See [GitHub releases](https://github.com/mwclient/mwclient/releases/) for release notes for mwclient 0.7.1+. ## Changes in version 0.7.0 Mwclient 0.7.0 was released on 27 September 2014. Upgrade notices: - This version requires minimum Python 2.6 and MediaWiki 1.16. Support for Python 2.4–2.5 and MediaWiki 1.11–1.15 has been dropped. - The `Page.edit()` method has been renamed to `Page.text()`. While `Page.edit()` is deprecated, it will be available for a long time. The old `Page.text` attribute, that used to store a copy of the wikitext from the last `Page.edit()` call, has been removed entirely. The `readonly` argument has also been removed (it was never really implemented, so it acted only as a dummy argument before the removal). - The `Page.get_expanded()` method has been deprecated in favour of calling `Page.text(expandtemplates=True)`. Detailed changelog: * [2012-08-30] [@btongminh](https://github.com/btongminh): Allow setting both the upload description and the page content separately. [0aa748f](https://github.com/mwclient/mwclient/commit/0aa748f). * [2012-08-30] [@tommorris](https://github.com/tommorris): Improve documentation. [a2723e7](https://github.com/mwclient/mwclient/commit/a2723e7). * [2013-02-15] [@waldyrious](https://github.com/waldyrious): Converted the repository to git and moved from sourceforge to github. [#1](https://github.com/mwclient/mwclient/issues/1) (also [#11](https://github.com/mwclient/mwclient/issues/11), [#13](https://github.com/mwclient/mwclient/issues/13) and [#15](https://github.com/mwclient/mwclient/issues/15)). * [2013-03-20] [@eug48](https://github.com/eug48): Support for customising the useragent. [773adf9](https://github.com/mwclient/mwclient/commit/773adf9), [#16](https://github.com/mwclient/mwclient/pull/16). * [2013-03-20] [@eug48](https://github.com/eug48): Removed unused `Request` class. [99e786d](https://github.com/mwclient/mwclient/commit/99e786d), [#16](https://github.com/mwclient/mwclient/pull/16). * [2013-05-13] [@danmichaelo](https://github.com/danmichaelo): Support for requesting pages by their page id (`site.pages[page_id]`). [a1a2ced](https://github.com/danmichaelo/mwclient/commit/a1a2ced), [#19](https://github.com/mwclient/mwclient/pull/19). * [2013-05-13] [@danmichaelo](https://github.com/danmichaelo): Support for editing sections. [546f77d](https://github.com/danmichaelo/mwclient/commit/546f77d), [#19](https://github.com/mwclient/mwclient/pull/19). * [2013-05-13] [@danmichaelo](https://github.com/danmichaelo): New method `Page.redirects_to()` and helper method `Page.resolve_redirect()`. [3b851cb](https://github.com/danmichaelo/mwclient/commit/3b851cb), [36e8dcc](https://github.com/danmichaelo/mwclient/commit/36e8dcc), [#19](https://github.com/mwclient/mwclient/pull/19). * [2013-05-13] [@danmichaelo](https://github.com/danmichaelo): Support argument `action` with `logevents()`. [241ed37](https://github.com/danmichaelo/mwclient/commit/241ed37), [#19](https://github.com/mwclient/mwclient/pull/19). * [2013-05-13] [@danmichaelo](https://github.com/danmichaelo): Support argument `page` with `parse()`. [223aa0](https://github.com/danmichaelo/mwclient/commit/223aa0), [#19](https://github.com/mwclient/mwclient/pull/19). * [2013-11-14] [@kyv](https://github.com/kyv): Allow setting HTTP `Authorization` header. [HTTP headers](http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.8). [72fc49a](https://github.com/kyv/mwclient/commit/72fc49a). * [2013-11-15] [@kyv](https://github.com/kyv): Add support for the `ask` API action [provided by Semantic MediaWiki](http://semantic-mediawiki.org/wiki/Ask_API). [0a16afc](https://github.com/kyv/mwclient/commit/0a16afc). * [2014-05-02] [@danmichaelo](https://github.com/danmichaelo): Quickfix for [#38](https://github.com/mwclient/mwclient/issues/38). [98b850b](https://github.com/mwclient/mwclient/commit/98b850b). * [2014-06-13] [@tuffnatty](https://github.com/tuffnatty): Fix updating of Page.last_rev_time upon save(). [d0cc7db](https://github.com/mwclient/mwclient/commit/d0cc7db), [#41](https://github.com/mwclient/mwclient/issues/41). * [2014-06-13] [@jimt](https://github.com/jimt), [@danmichaelo](https://github.com/danmichaelo): Support more arguments to `list=allusers`. [7cb4383](https://github.com/mwclient/mwclient/commit/7cb4383), [#8](https://github.com/mwclient/mwclient/issues/8). * [2014-08-18] [@danmichaelo](https://github.com/danmichaelo): Replace http.py with the Requests library. [593cb44](https://github.com/mwclient/mwclient/commit/593cb44), [#45](https://github.com/mwclient/mwclient/issues/45). * [2014-08-18] [@jaloren](https://github.com/jaloren), [@danmichaelo](https://github.com/danmichaelo): Don't crash if edit response does not contain timestamp. [bd7bc3b](https://github.com/mwclient/mwclient/commit/bd7bc3b), [0ef9a17](https://github.com/mwclient/mwclient/commit/0ef9a17), [#57](https://github.com/mwclient/mwclient/issues/57). * [2014-08-31] [@danmichaelo](https://github.com/danmichaelo): Retry on internal_api_error_DBQueryError. [d0ce831](https://github.com/mwclient/mwclient/commit/d0ce831). * [2014-09-22] [@danmichaelo](https://github.com/danmichaelo): Rename `Page.edit()` to `Page.text()`. Note that `text` is now a required parameter to `Page.save()`. [61155f1](https://github.com/mwclient/mwclient/commit/61155f1), [#51](https://github.com/mwclient/mwclient/issues/51). * [2014-09-27] [@danmichaelo](https://github.com/danmichaelo): Add `expandtemplates` argument to `Page.text()` and deprecate `Page.get_expanded()` [57df5f4](https://github.com/mwclient/mwclient/commit/57df5f4). ## Changes in version 0.6.5 Mwclient 0.6.5 was released on 6 May 2011. * [2011-02-16] Fix for upload by URL. [7ceb14b](https://github.com/mwclient/mwclient/commit/7ceb14b). * [2011-05-06] Explicitly convert the `Content-Length` header to `str`, avoiding a `TypeError` on some versions of Python. [4a829bc](https://github.com/mwclient/mwclient/commit/4a829bc), [2ca1fbd](https://github.com/mwclient/mwclient/commit/2ca1fbd). * [2011-05-06] Handle `readapidenied` error in site init. [c513396](https://github.com/mwclient/mwclient/commit/c513396). * [2011-05-06] Fix version parsing for almost any sane version string. [9f5339f](https://github.com/mwclient/mwclient/commit/9f5339f). ## Changes in version 0.6.4 Mwclient 0.6.3 was released on 7 April 2010. * [2009-08-27] Added support for upload API. [56eeb9b](https://github.com/mwclient/mwclient/commit/56eeb9b), [0d7caab](https://github.com/mwclient/mwclient/commit/0d7caab) (see also [610411a](https://github.com/mwclient/mwclient/commit/610411a)). * [2009-11-02] Added `prop=duplicatefiles`. [241e5d6](https://github.com/mwclient/mwclient/commit/241e5d6). * [2009-11-02] Properly fix detection of alpha versions. [241e5d6](https://github.com/mwclient/mwclient/commit/241e5d6). * [2009-11-14] Added support for built-in JSON library. [73e9cd6](https://github.com/mwclient/mwclient/commit/73e9cd6). * [2009-11-15] Handle badtoken once. [4b384e1](https://github.com/mwclient/mwclient/commit/4b384e1). * [2010-02-23] Fix module conflict with simplejson-1.x by inserting mwclient path at the beginning of `sys.path` instead of the end. [cd37ef0](https://github.com/mwclient/mwclient/commit/cd37ef0). * [2010-02-23] Fix revision iteration. [09b68e9](https://github.com/mwclient/mwclient/commit/09b68e9), [2ad32f1](https://github.com/mwclient/mwclient/commit/2ad32f1), [afdd5e8](https://github.com/mwclient/mwclient/commit/afdd5e8), [993b346](https://github.com/mwclient/mwclient/commit/993b346), [#3](https://github.com/mwclient/mwclient/issues/3). * [2010-04-07] Supply token on login if necessary. [3731de5](https://github.com/mwclient/mwclient/commit/3731de5). ## Changes in version 0.6.3 Mwclient 0.6.3 was released on 16 July 2009. * Added domain parameter to login * Applied edit fix to `page_nowriteapi` * Allow arbitrary data to be passed to `page.save` * Fix mwclient on WMF wikis ## Changes in version 0.6.2 Mwclient 0.6.2 was released on 2 May 2009. * Compatibility fixes for MediaWiki 1.13 * Download fix for images * Full support for editing pages via write API and split of compatibility to another file. * Added `expandtemplates` API call * Added and fixed moving via API * Raise an `ApiDisabledError` if the API is disabled * Added support for HTTPS * Fixed email code * Mark edits as bots by default. * Added `action=parse`. Modified patch by Brian Mingus. * Improved general HTTP and upload handling. ## Changes in version 0.6.1 Mwclient 0.6.1 was released in May 2008. No release notes were kept for this version. ## Changes in version 0.6.0 Mwclient 0.6.0 was released in February 2008. This was the first official release via Sourceforge. This version removed some Pywikipedia influences added in 0.4. ## Changes in versions 0.5 Mwclient 0.5 was an architectural redesign which accomplished easy extendability and added proper support for continuations. ## Changes in version 0.4 Mwclient 0.4 was somewhat the basis for future releases and shows the current module architecture. It was influenced by Pywikipedia, which was discovered by the author at the time. ## Changes in versions 0.2 and 0.3 Mwclient 0.2 and 0.3 were probably a bit of a generalization, and maybe already used the API for some part, but details are unknown. ## Mwclient 0.1 Mwclient 0.1 was a non-API module for accessing Wikipedia using an XML parser. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/CONTRIBUTING.md0000644000175100001770000000044314656212142015255 0ustar00runnerdocker# Contributing to the project Thank you for your interest in contributing to the project! For information on mwclient development, please see https://mwclient.readthedocs.io/en/latest/development/ (or the local file `docs/source/development/index.rst` from which the webpage is generated) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/CREDITS.md0000644000175100001770000000436514656212142014452 0ustar00runnerdockerThe **mwclient** framework was originally written by Bryan Tong Minh ([@btongminh](https://github.com/btongminh)) and released in 2008 [on Sourceforge](http://sourceforge.net/projects/mwclient/). Bryan maintained the project until version 0.6.5, released on 6 May 2011. In 2013, Waldir Pimenta ([@waldyrious](https://github.com/waldyrious)) contacted Bryan and proposed helping out with a conversion from SVN to git and moving the project to Github. After getting the appropriate permissions, he performed the repository conversion using [sf2github](http://github.com/ttencate/sf2github) ([#1](https://github.com/mwclient/mwclient/issues/1)), converted the wiki previously hosted on sourceforge ([#12](https://github.com/mwclient/mwclient/issues/12)), updated the sourceforge project page ([#15](https://github.com/mwclient/mwclient/issues/15)), identified the users who had created bug reports ([#1, comment](https://github.com/mwclient/mwclient/issues/1#issuecomment-13972022)), contacted the authors of forks of the project suggesting them to provide their changes as PRs ([#14](https://github.com/mwclient/mwclient/issues/14)), and handed the repository to Bryan ([#11](https://github.com/mwclient/mwclient/issues/11)). Dan Michael O. Heggø ([@danmichaelo](https://github.com/danmichaelo)) was the author of one of those forks, and the most prolific submitter of PRs in the early history of mwclient as a git repository. Not long after the git transition, the repository was moved to an organization ([#12, comment](https://github.com/mwclient/mwclient/issues/12#issuecomment-20447515)), and Dan became the main force behind the 2014 release of version 0.7.0 (the first after a 3-year hiatus). From then until the 0.10.1 release in 2020, he was the lead maintainer of the project, which has attracted contributions from [several other people](../../graphs/contributors). From 2023, the project is maintained by Marc Trölitzsch ([@marcfrederick](https://github.com/marcfrederick)), Adam Williamson ([@adamwill](https://github.com/adamwill)), and Megan Cutrofello ([@RheingoldRiver](https://github.com/RheingoldRiver)). For more details on the technical history of the project, see the [CHANGELOG.md](CHANGELOG.md) document. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/LICENSE.md0000644000175100001770000000212614656212142014430 0ustar00runnerdocker## MIT License Copyright (c) Bryan Tong Minh, Dan Michael O. Heggø and contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/MANIFEST.in0000644000175100001770000000037514656212142014566 0ustar00runnerdockerinclude 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 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1723405414.3553734 mwclient-0.11.0/PKG-INFO0000644000175100001770000000720414656212146014127 0ustar00runnerdockerMetadata-Version: 2.1 Name: mwclient Version: 0.11.0 Summary: MediaWiki API client Home-page: https://github.com/mwclient/mwclient Author: Bryan Tong Minh Author-email: bryan.tongminh@gmail.com License: MIT Keywords: mediawiki wikipedia Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3.5 Classifier: Programming Language :: Python :: 3.6 Classifier: Programming Language :: Python :: 3.7 Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Description-Content-Type: text/markdown License-File: LICENSE.md Requires-Dist: requests-oauthlib
mwclient logo

mwclient

[![Build status][build-status-img]](https://github.com/mwclient/mwclient) [![Test coverage][test-coverage-img]](https://coveralls.io/r/mwclient/mwclient) [![Latest version][latest-version-img]](https://pypi.python.org/pypi/mwclient) [![MIT license][mit-license-img]](http://opensource.org/licenses/MIT) [![Documentation status][documentation-status-img]](http://mwclient.readthedocs.io/en/latest/) [![Issue statistics][issue-statistics-img]](http://isitmaintained.com/project/mwclient/mwclient) [![Gitter chat][gitter-chat-img]](https://gitter.im/mwclient/mwclient) [build-status-img]: https://github.com/mwclient/mwclient/actions/workflows/tox.yml/badge.svg [test-coverage-img]: https://img.shields.io/coveralls/mwclient/mwclient.svg [latest-version-img]: https://img.shields.io/pypi/v/mwclient.svg [mit-license-img]: https://img.shields.io/github/license/mwclient/mwclient.svg [documentation-status-img]: https://readthedocs.org/projects/mwclient/badge/ [issue-statistics-img]: http://isitmaintained.com/badge/resolution/mwclient/mwclient.svg [gitter-chat-img]: https://img.shields.io/gitter/room/mwclient/mwclient.svg mwclient is a lightweight Python client library to the [MediaWiki API](https://mediawiki.org/wiki/API) which provides access to most API functionality. It works with Python 3.5 and above, and supports MediaWiki 1.16 and above. For functions not available in the current MediaWiki, a `MediaWikiVersionError` is raised. The current stable [version 0.11.0](https://github.com/mwclient/mwclient/archive/v0.11.0.zip) is [available through PyPI](https://pypi.python.org/pypi/mwclient): ``` $ pip install mwclient ``` The current [development version](https://github.com/mwclient/mwclient) can be installed from GitHub: ``` $ pip install git+git://github.com/mwclient/mwclient.git ``` Please see the [changelog document](https://github.com/mwclient/mwclient/blob/master/CHANGELOG.md) for a list of changes. mwclient was originally written by Bryan Tong Minh. It was maintained for many years by Dan Michael O. Heggø, with assistance from Waldir Pimenta. It is currently maintained by Marc Trölitzsch, Adam Williamson and Megan Cutrofello. The best way to get in touch with the maintainers is by filing an issue or a pull request. ## Documentation Up-to-date documentation is hosted [at Read the Docs](http://mwclient.readthedocs.io/en/latest/). It includes a user guide to get started using mwclient, a reference guide, implementation and development notes. There is also some documentation on the [GitHub wiki](https://github.com/mwclient/mwclient/wiki) that hasn't been ported yet. If you want to help, you're welcome! ## Contributing Patches are welcome! See [this page](https://mwclient.readthedocs.io/en/latest/development/) for information on how to get started with mwclient development. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/README.md0000644000175100001770000000556614656212142014316 0ustar00runnerdocker
mwclient logo

mwclient

[![Build status][build-status-img]](https://github.com/mwclient/mwclient) [![Test coverage][test-coverage-img]](https://coveralls.io/r/mwclient/mwclient) [![Latest version][latest-version-img]](https://pypi.python.org/pypi/mwclient) [![MIT license][mit-license-img]](http://opensource.org/licenses/MIT) [![Documentation status][documentation-status-img]](http://mwclient.readthedocs.io/en/latest/) [![Issue statistics][issue-statistics-img]](http://isitmaintained.com/project/mwclient/mwclient) [![Gitter chat][gitter-chat-img]](https://gitter.im/mwclient/mwclient) [build-status-img]: https://github.com/mwclient/mwclient/actions/workflows/tox.yml/badge.svg [test-coverage-img]: https://img.shields.io/coveralls/mwclient/mwclient.svg [latest-version-img]: https://img.shields.io/pypi/v/mwclient.svg [mit-license-img]: https://img.shields.io/github/license/mwclient/mwclient.svg [documentation-status-img]: https://readthedocs.org/projects/mwclient/badge/ [issue-statistics-img]: http://isitmaintained.com/badge/resolution/mwclient/mwclient.svg [gitter-chat-img]: https://img.shields.io/gitter/room/mwclient/mwclient.svg mwclient is a lightweight Python client library to the [MediaWiki API](https://mediawiki.org/wiki/API) which provides access to most API functionality. It works with Python 3.5 and above, and supports MediaWiki 1.16 and above. For functions not available in the current MediaWiki, a `MediaWikiVersionError` is raised. The current stable [version 0.11.0](https://github.com/mwclient/mwclient/archive/v0.11.0.zip) is [available through PyPI](https://pypi.python.org/pypi/mwclient): ``` $ pip install mwclient ``` The current [development version](https://github.com/mwclient/mwclient) can be installed from GitHub: ``` $ pip install git+git://github.com/mwclient/mwclient.git ``` Please see the [changelog document](https://github.com/mwclient/mwclient/blob/master/CHANGELOG.md) for a list of changes. mwclient was originally written by Bryan Tong Minh. It was maintained for many years by Dan Michael O. Heggø, with assistance from Waldir Pimenta. It is currently maintained by Marc Trölitzsch, Adam Williamson and Megan Cutrofello. The best way to get in touch with the maintainers is by filing an issue or a pull request. ## Documentation Up-to-date documentation is hosted [at Read the Docs](http://mwclient.readthedocs.io/en/latest/). It includes a user guide to get started using mwclient, a reference guide, implementation and development notes. There is also some documentation on the [GitHub wiki](https://github.com/mwclient/mwclient/wiki) that hasn't been ported yet. If you want to help, you're welcome! ## Contributing Patches are welcome! See [this page](https://mwclient.readthedocs.io/en/latest/development/) for information on how to get started with mwclient development. ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1723405414.3513734 mwclient-0.11.0/docs/0000755000175100001770000000000014656212146013757 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/docs/Makefile0000644000175100001770000001517314656212142015422 0ustar00runnerdocker# Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = BUILDDIR = build # User-friendly check for sphinx-build ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) endif # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " devhelp to make HTML files and a Devhelp project" @echo " epub to make an epub" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" @echo " text to make text files" @echo " man to make manual pages" @echo " texinfo to make Texinfo files" @echo " info to make Texinfo files and run them through makeinfo" @echo " gettext to make PO message catalogs" @echo " changes to make an overview of all changed/added/deprecated items" @echo " xml to make Docutils-native XML files" @echo " pseudoxml to make pseudoxml-XML files for display purposes" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" clean: rm -rf $(BUILDDIR)/* html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." singlehtml: $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." qthelp: $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/mwclient.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/mwclient.qhc" devhelp: $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @echo "# mkdir -p $$HOME/.local/share/devhelp/mwclient" @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/mwclient" @echo "# devhelp" epub: $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." latexpdf: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." $(MAKE) -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." latexpdfja: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through platex and dvipdfmx..." $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." text: $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." man: $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." texinfo: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." @echo "Run \`make' in that directory to run these through makeinfo" \ "(use \`make info' here to do that automatically)." info: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo "Running Texinfo files through makeinfo..." make -C $(BUILDDIR)/texinfo info @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." gettext: $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale @echo @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." xml: $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml @echo @echo "Build finished. The XML files are in $(BUILDDIR)/xml." pseudoxml: $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml @echo @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/docs/make.bat0000644000175100001770000001451014656212142015361 0ustar00runnerdocker@ECHO OFF REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set BUILDDIR=build set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% source set I18NSPHINXOPTS=%SPHINXOPTS% source if NOT "%PAPER%" == "" ( set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% ) if "%1" == "" goto help if "%1" == "help" ( :help echo.Please use `make ^` where ^ is one of echo. html to make standalone HTML files echo. dirhtml to make HTML files named index.html in directories echo. singlehtml to make a single large HTML file echo. pickle to make pickle files echo. json to make JSON files echo. htmlhelp to make HTML files and a HTML help project echo. qthelp to make HTML files and a qthelp project echo. devhelp to make HTML files and a Devhelp project echo. epub to make an epub echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter echo. text to make text files echo. man to make manual pages echo. texinfo to make Texinfo files echo. gettext to make PO message catalogs echo. changes to make an overview over all changed/added/deprecated items echo. xml to make Docutils-native XML files echo. pseudoxml to make pseudoxml-XML files for display purposes echo. linkcheck to check all external links for integrity echo. doctest to run all doctests embedded in the documentation if enabled goto end ) if "%1" == "clean" ( for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i del /q /s %BUILDDIR%\* goto end ) %SPHINXBUILD% 2> nul if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.http://sphinx-doc.org/ exit /b 1 ) if "%1" == "html" ( %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/html. goto end ) if "%1" == "dirhtml" ( %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. goto end ) if "%1" == "singlehtml" ( %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. goto end ) if "%1" == "pickle" ( %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can process the pickle files. goto end ) if "%1" == "json" ( %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can process the JSON files. goto end ) if "%1" == "htmlhelp" ( %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can run HTML Help Workshop with the ^ .hhp project file in %BUILDDIR%/htmlhelp. goto end ) if "%1" == "qthelp" ( %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can run "qcollectiongenerator" with the ^ .qhcp project file in %BUILDDIR%/qthelp, like this: echo.^> qcollectiongenerator %BUILDDIR%\qthelp\mwclient.qhcp echo.To view the help file: echo.^> assistant -collectionFile %BUILDDIR%\qthelp\mwclient.ghc goto end ) if "%1" == "devhelp" ( %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp if errorlevel 1 exit /b 1 echo. echo.Build finished. goto end ) if "%1" == "epub" ( %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub if errorlevel 1 exit /b 1 echo. echo.Build finished. The epub file is in %BUILDDIR%/epub. goto end ) if "%1" == "latex" ( %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex if errorlevel 1 exit /b 1 echo. echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. goto end ) if "%1" == "latexpdf" ( %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex cd %BUILDDIR%/latex make all-pdf cd %BUILDDIR%/.. echo. echo.Build finished; the PDF files are in %BUILDDIR%/latex. goto end ) if "%1" == "latexpdfja" ( %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex cd %BUILDDIR%/latex make all-pdf-ja cd %BUILDDIR%/.. echo. echo.Build finished; the PDF files are in %BUILDDIR%/latex. goto end ) if "%1" == "text" ( %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text if errorlevel 1 exit /b 1 echo. echo.Build finished. The text files are in %BUILDDIR%/text. goto end ) if "%1" == "man" ( %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man if errorlevel 1 exit /b 1 echo. echo.Build finished. The manual pages are in %BUILDDIR%/man. goto end ) if "%1" == "texinfo" ( %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo if errorlevel 1 exit /b 1 echo. echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. goto end ) if "%1" == "gettext" ( %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale if errorlevel 1 exit /b 1 echo. echo.Build finished. The message catalogs are in %BUILDDIR%/locale. goto end ) if "%1" == "changes" ( %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes if errorlevel 1 exit /b 1 echo. echo.The overview file is in %BUILDDIR%/changes. goto end ) if "%1" == "linkcheck" ( %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck if errorlevel 1 exit /b 1 echo. echo.Link check complete; look for any errors in the above output ^ or in %BUILDDIR%/linkcheck/output.txt. goto end ) if "%1" == "doctest" ( %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest if errorlevel 1 exit /b 1 echo. echo.Testing of doctests in the sources finished, look at the ^ results in %BUILDDIR%/doctest/output.txt. goto end ) if "%1" == "xml" ( %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml if errorlevel 1 exit /b 1 echo. echo.Build finished. The XML files are in %BUILDDIR%/xml. goto end ) if "%1" == "pseudoxml" ( %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml if errorlevel 1 exit /b 1 echo. echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. goto end ) :end ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/docs/requirements.txt0000644000175100001770000000004614656212142017237 0ustar00runnerdockersphinx==7.2.5 sphinx_rtd_theme==1.3.0 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1723405414.3513734 mwclient-0.11.0/docs/source/0000755000175100001770000000000014656212146015257 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/docs/source/conf.py0000644000175100001770000002102214656212142016547 0ustar00runnerdocker# -*- coding: utf-8 -*- # # mwclient documentation build configuration file, created by # sphinx-quickstart on Sat Sep 27 11:19:56 2014. # # This file is execfile()d with the current directory set to its # containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. import datetime import sys import os # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. sys.path.insert(0, os.path.abspath('../..')) import sphinx_rtd_theme import mwclient from mwclient import __version__ # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. #needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 'sphinx.ext.todo', 'sphinx.ext.coverage', 'sphinx.ext.viewcode', 'sphinx.ext.napoleon' ] # Add any paths that contain templates here, relative to this directory. # templates_path = ['_templates'] # The suffix of source filenames. source_suffix = '.rst' # The encoding of source files. #source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' # General information about the project. project = 'mwclient' copyright = '{0}, Bryan Tong Minh'.format(datetime.datetime.now().year) # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. version = __version__ # The full version, including alpha/beta/rc tags. release = version # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. #language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: #today = '' # Else, today_fmt is used as the format for a strftime call. #today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = [] # The reST default role (used for this markup: `text`) to use for all # documents. #default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. #add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). #add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. #show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. #modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. #keep_warnings = False # -- Options for HTML output ---------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] html_theme = 'sphinx_rtd_theme' # html_theme_options = {'github_fork': 'mwclient/mwclient'} html_theme_options = { # 'sticky_navigation': True # Set to False to disable the sticky nav while scrolling. # 'logo_only': True, # if we have a html_logo below, this shows /only/ the logo with no title text } # html_style = 'css/my_theme.css' # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". #html_title = None # A shorter title for the navigation bar. Default is the same as html_title. #html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. # html_logo = 'logo.png' # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. #html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". # html_static_path = ['_static'] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied # directly to the root of the documentation. #html_extra_path = [] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. #html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. html_use_smartypants = True # Custom sidebar templates, maps document names to template names. #html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. #html_additional_pages = {} # If false, no module index is generated. #html_domain_indices = True # If false, no index is generated. html_use_index = True # If true, the index is split into individual pages for each letter. html_split_index = False # If true, links to the reST sources are added to the pages. html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. #html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. #html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). #html_file_suffix = None # Output file base name for HTML help builder. htmlhelp_basename = 'mwclientdoc' # -- Options for LaTeX output --------------------------------------------- latex_elements = { # The paper size ('letterpaper' or 'a4paper'). #'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). #'pointsize': '10pt', # Additional stuff for the LaTeX preamble. #'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ ('index', 'mwclient.tex', 'mwclient Documentation', 'Bryan Tong Minh', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of # the title page. latex_logo = 'logo.png' # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. #latex_use_parts = False # If true, show page references after internal links. #latex_show_pagerefs = False # If true, show URL addresses after external links. #latex_show_urls = False # Documents to append as an appendix to all manuals. #latex_appendices = [] # If false, no module index is generated. #latex_domain_indices = True # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ ('index', 'mwclient', 'mwclient Documentation', ['Bryan Tong Minh'], 1) ] # If true, show URL addresses after external links. #man_show_urls = False # -- Options for Texinfo output ------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ ('index', 'mwclient', 'mwclient Documentation', 'Bryan Tong Minh', 'mwclient', 'One line description of project.', 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. #texinfo_appendices = [] # If false, no module index is generated. #texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. #texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. #texinfo_no_detailmenu = False # Example configuration for intersphinx: refer to the Python standard library. intersphinx_mapping = { 'requests': ('http://requests.readthedocs.org/en/latest/', None) } ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1723405414.3513734 mwclient-0.11.0/docs/source/development/0000755000175100001770000000000014656212146017601 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/docs/source/development/index.rst0000644000175100001770000000702114656212142021436 0ustar00runnerdocker.. _development: Development =========== Mwclient development is coordinated at https://github.com/mwclient/mwclient. Patches are very welcome. If there's something you want to discuss first, we have a `Gitter chatroom `_. Development environment ----------------------- If you plan to submit a pull request, you should first `fork `_ the mwclient repo on GitHub, then check out the original repository and configure your fork as a remote: .. code:: bash $ 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 and automated) testing: .. code:: bash $ pip install -e . Create a new branch for your changes: .. code:: bash $ git checkout -b my-branch Test suite ---------- mwclient ships with a test suite based on `pytest `_. While it's far from complete, it can sometimes alert you if you break things. The easiest way to run the tests is: .. code:: bash $ python setup.py test This will make an in-place build and download test dependencies locally if needed. Tests will run faster, however, if you do an `editable install `_ and run pytest directly: .. code:: bash $ pip install pytest pytest-cov flake8 responses mock $ pip install -e . $ py.test If you want to test with different Python versions in isolated virtualenvs, you can use `Tox `_. A `tox.ini` file is included. .. code:: bash $ pip install tox $ tox If you would like to expand the test suite by adding more tests, please go ahead! Updating/expanding the documentation ------------------------------------ Documentation consists of both a manually compiled user guide (under ``docs/user``) and a reference guide generated from the docstrings, using Sphinx autodoc with the napoleon extension. Documentation is built automatically on `ReadTheDocs `_ after each commit. To build the documentation locally for testing: .. code:: bash $ pip install Sphinx sphinx-rtd-theme $ cd docs $ make html When writing docstrings, try to adhere to the `Google style `_. Making a pull request --------------------- Make sure to run tests before committing. When it comes to the commit message, there's no specific requirements for the format, but try to explain your changes in a clear and concise manner. If it's been some time since you forked, please consider rebasing your branch on the main master branch to ease merging: .. code:: bash $ 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 `_: .. code:: bash $ pip install bump-my-version $ bump-my-version bump major|minor|patch Then check the commit looks correct and is tagged vX.Y.Z, and push. The ``.github/workflows/release.yml`` action will publish to PyPI. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/docs/source/index.rst0000644000175100001770000000466214656212142017124 0ustar00runnerdocker mwclient: lightweight MediaWiki client ====================================== .. image:: logo.png :align: right :width: 30% Mwclient is a :ref:`MIT licensed ` client library to the `MediaWiki API`_ that should work well with both Wikimedia wikis and other wikis running MediaWiki 1.16 or above. It works with Python 2.7 and 3.3+. .. _install: Installation ------------ Installing Mwclient is simple with `pip `_, just run this in your terminal: .. code:: bash pip install mwclient Quickstart ---------- .. code-block:: python >>> user_agent = 'MyCoolTool/0.2 (xyz@example.org)' >>> site = mwclient.Site('en.wikipedia.org', clients_useragent=user_agent) >>> page = site.pages['Leipäjuusto'] >>> page.text() '{{Unreferenced|date=September 2009}}\n[[Image:Leip\xe4juusto cheese with cloudberry jam.jpg|thumb|Leip\xe4juusto with [[cloudberry]] jam]]\n\'\'\'Leip\xe4juusto\'\'\' (bread cheese) or \'\'juustoleip\xe4\'\', which is also known in English as \'\'\'Finnish squeaky cheese\'\'\', is a fresh [[cheese]] traditionally made from cow\'s [[beestings]], rich milk from a cow that has recently calved.' >>> [x for x in page.categories()] [>, >, >, >] User guide ---------- This guide is intended as an introductory overview, and explains how to make use of the most important features of mwclient. .. toctree:: :maxdepth: 2 user/index Reference guide --------------- If you are looking for information on a specific function, class or method, this part of the documentation is for you. It's autogenerated from the source code. .. toctree:: :maxdepth: 3 reference/index Development ----------- Looking for information on contributing to mwclient development? .. toctree:: :maxdepth: 3 development/index .. _license: MIT License ----------- .. include:: ../../LICENSE.md Indices and tables ------------------ * :ref:`genindex` * :ref:`modindex` * :ref:`search` .. _`MediaWiki API`: https://www.mediawiki.org/wiki/API ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/docs/source/logo.png0000644000175100001770000001120114656212142016714 0ustar00runnerdockerPNG  IHDR/f?sBIT|d pHYs))"ߌtEXtSoftwarewww.inkscape.org<tEXtTitleMwClient logo"IDATx{}(P.NZM6ZHDR7tIjQ@kJ0a1UtdL4Z୉ӚʊI"6Q\T.?~sx^~=qsg{q/1B !GC"1 !DbB$8Ip!B$&!HLC"1 !DbB$8Ip!;ȃ͛7oa]f а 3aJr"H3 @0  =wGEH` 0 |B(!,\`08s` pcAE NDρ2mHp#ˁkS `񃇁nϴ,f!7D}L{%,6@ ^p`lfEX zNALD&r#a0t&`-p5EZ62PϴP?Z"&" )rbJww5[񃋁'jxL ==d[+37o^&`t7ra:~x|B3B։cD֊A$8 ׀2yxY_(x p`wc >|Q|hT! =vOJ|uoux * o M5 ?8X L] ChԚ&N]@&SUnhcFhT".dOѺk^x,hO $8 ,)NP7jx;p>΢8xȚF@3%`ݵԣ,9(GA*4RШ<:;7QgKo0TyG'0򤑩R;~K&9X&O[!J?3ok&2:c4J?Ip!&=re]}[viN@09w0sWVu`vji&EN$8rRv:rl{/uu20.?9tz}cOn]!?ʵH?&jTӌAȘG*3w5N',L'.]D#JG)obٞVW1M|W0͖GAnY;~3oj 8~pʹBq`A1v pr Vj#^,[.T74>gſLJ?i>8H궙hGzSgĩǏjF7S>qWFj5_*f})8~Q{SiS; z.d'9V󵩗gYfv*L0>ym1G%\ &Aj/U7|^CPm':$Nݙb`G{w_ۓh SVX iCM tgg[;X4Č)\ ΰFxrmM!4~XEڪhOöIppB~M?jZ, 5ۦLfwf~׬yOe>Ͻ3QsIVdM>hN#>xu+lccxk˻|g_})4 =њ*i>TۉɥJ Uwo6gk6v߷Y0G[5Y]DIpwc+Sh|%YGdfGJd7vBB}fN0v.$8!}[u>wڪiOj+GBL([h. =W-:Y"`v]w!GFB([h03^"(`9f_TCGw!G:F8u8E  %sUW<ƚZȉGF*46ּKhH#'M£l ^oj5 IpNx-4Gz+U*jMB9 )Wh||)_Y󁻴UԚFAd[*qEu(KD<m&Q 9(X Bc0'ܧ^?i5 IpW&Ⱥ 5$44(HIBs{%#!"Q.ۊKQC5ZBduPHF#g% _$:z TPe.VJxO#G% C5K1[8} Ip$qc3CU\ШșGJhd}Gxk74$0NS-t#1w>k{w`fj婪L/ hayjkSQLYh.>04q?cnu\h]@ +_: a3`UtMkh|&.tjcocn>x.*a|{zrD+NlvƓHgx?Vš҅u ޑELn;Ip"^+Q;Uq U?x|z|j*CFG /뮣*u"ka]=}/crGz?]@46k_im߃[հsYiHpgj'x ВIN{Q-_ɏljGzkuQH<^2{~.֛GaM\t1kQm&RH2x0f]gV< _>qv\hG6~s?W zQm%R@;GuQh+Fo Ŏw.?Ȏg_s ߁tQ/D$82zFvua3W^˘py;ިB#[.E #jT֣FdD#Cen]G-;~x\[5Ż;ޥD#c><:f9~0f{.ZvP(EUE%;^B@.C]9~T}<Ԍ \t$8 z#] p`v u#݅ 4T<ügUL myr&{`֣ڈs䞆>ULNb0]@fKyzf~g5qhTot043o޼DH  5;~!] uhTq5 XY([P2{}y3TM<<&Ȁ5Hp&ܞsG 4CX|;>_ƛGPl:YO.yhTٖ%asw{+p p?HШ6梲Tև tPA*:fY#Q&ԥEXP{K !{B$8Ip!B$&!HLC"1 !DbB$8Ip!B$&!HLC"1 !DbB$8?ВȍIENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/docs/source/logo.svg0000644000175100001770000000442114656212142016735 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1723405414.3513734 mwclient-0.11.0/docs/source/reference/0000755000175100001770000000000014656212146017215 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/docs/source/reference/errors.rst0000644000175100001770000000022114656212142021252 0ustar00runnerdocker.. _errors: :class:`InsufficientPermission` ------------------------------- .. autoclass:: mwclient.errors.InsufficientPermission :members: ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/docs/source/reference/image.rst0000644000175100001770000000012014656212142021016 0ustar00runnerdocker:class:`Image` -------------- .. autoclass:: mwclient.image.Image :members: ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/docs/source/reference/index.rst0000644000175100001770000000027314656212142021054 0ustar00runnerdocker.. _reference: Reference guide =============== This is the mwclient API reference, autogenerated from the source code. .. toctree:: :maxdepth: 1 site page image errors ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/docs/source/reference/page.rst0000644000175100001770000000020414656212142020653 0ustar00runnerdocker:class:`Page` -------------- .. autoclass:: mwclient.page.Page :members: .. autoclass:: mwclient.listing.Category :members: ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/docs/source/reference/site.rst0000644000175100001770000000011714656212142020706 0ustar00runnerdocker:class:`Site` -------------- .. autoclass:: mwclient.client.Site :members: ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1723405414.3513734 mwclient-0.11.0/docs/source/user/0000755000175100001770000000000014656212146016235 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/docs/source/user/connecting.rst0000644000175100001770000001712514656212142021120 0ustar00runnerdocker.. _connecting: Connecting to your site ======================= To connect to a MediaWiki site, you need to create a :class:`~mwclient.client.Site` object and pass it the hostname of the site you want to connect to. The hostname should not include the protocol (http or https) or the path to the API endpoint (see :ref:`endpoint`). .. code-block:: python from mwclient import Site user_agent = 'MyCoolTool/0.2 (xyz@example.org)' site = Site('en.wikipedia.org', clients_useragent=user_agent) .. warning:: The ``clients_useragent`` parameter, while optional, is highly recommended and may be required by some sites, such as the Wikimedia wikis (e.g. Wikipedia). Requests without a user agent may be rejected or rate-limited by the site. See :ref:`user-agent` for more information. By default, mwclient will connect using https. If your site doesn't support https, you need to explicitly request http like so: >>> site = Site('test.wikipedia.org', scheme='http') .. _endpoint: The API endpoint location ------------------------- The API endpoint location on a MediaWiki site depends on the configurable `$wgScriptPath`_. Mwclient defaults to the script path '/w/' used by the Wikimedia wikis. If you get a 404 Not Found or a :class:`mwclient.errors.InvalidResponse` error upon connecting, your site might use a different script path. You can specify it using the ``path`` argument: >>> site = Site('my-awesome-wiki.org', path='/wiki/') .. _$wgScriptPath: https://www.mediawiki.org/wiki/Manual:$wgScriptPath .. _user-agent: Specifying a user agent ----------------------- If you are connecting to a Wikimedia site, you should follow the `Wikimedia User-Agent policy`_. The user agent should contain the tool name, the tool version and a way to contact you: >>> user_agent = 'MyCoolTool/0.2 (xyz@example.org)' >>> site = Site('test.wikipedia.org', clients_useragent=user_agent) It should follow the pattern ``{tool_name}/{tool_version} ({contact})``. The contact info can also be your user name and the tool version may be omitted: ``RudolphBot (User:Santa Claus)``. Note that MwClient appends its own user agent to the end of your string. The final user agent will look like this: >>> site.clients_useragent 'MyCoolTool/0.2 (xyz@example.org) mwclient/0.8.0' .. _Wikimedia User-Agent policy: https://meta.wikimedia.org/wiki/User-Agent_policy .. _errors: Using a proxy ------------- If you need to use a proxy, you can configure the :class:`requests.Session` using the `connection_options` parameter of the :class:`~mwclient.client.Site`. .. code-block:: python import mwclient proxies = { 'http': 'http://10.10.1.10:3128', 'https': 'http://10.10.1.10:1080', } site = mwclient.Site('en.wikipedia.org', connection_options={"proxy": proxies}) Errors and warnings ------------------- Deprecations and other warnings from the API are logged using the `standard Python logging facility`_, so you can handle them in any way you like. To print them to stdout: >>> import logging >>> logging.basicConfig(level=logging.WARNING) .. _standard Python logging facility: https://docs.python.org/3/library/logging.html Errors are thrown as exceptions. All exceptions inherit :class:`mwclient.errors.MwClientError`. .. _auth: Authenticating -------------- Mwclient supports several methods for authentication described below. By default it will also protect you from editing when not authenticated by raising a :class:`mwclient.errors.LoginError`. If you actually *do* want to edit unauthenticated, just set >>> site.force_login = False .. _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 called *consumer key*), the *consumer secret*, the *access token* and the *access secret*, you can authenticate like so: >>> site = Site('test.wikipedia.org', consumer_token='my_consumer_token', consumer_secret='my_consumer_secret', access_token='my_access_token', access_secret='my_access_secret') .. _owner-only consumer: https://www.mediawiki.org/wiki/OAuth/Owner-only_consumers .. _clientlogin: Clientlogin authentication ^^^^^^^^^^^^^^^^^^^^^^^^^^ 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') 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 ^^^^^^^^^^^^^^^^^^^ .. 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. 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: >>> from requests.auth import HTTPBasicAuth >>> site = Site('awesome.site', httpauth=HTTPBasicAuth('my_username', 'my_password')) .. _ssl-auth: SSL client certificate authentication ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ If your server requires an SSL client certificate to authenticate, you can pass the ``client_certificate`` parameter: >>> site = Site('awesome.site', client_certificate='/path/to/client-and-key.pem') This parameter being a proxy to :class:`requests`' cert_ parameter, you can also specify a tuple (certificate, key) like: >>> site = Site('awesome.site', client_certificate=('client.pem', 'key.pem')) Please note that the private key must not be encrypted. .. _cert: http://docs.python-requests.org/en/master/user/advanced/#ssl-cert-verification .. _logout: Logging out ^^^^^^^^^^^ There is no logout method because merely exiting the script deletes all cookies, achieving the same effect. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/docs/source/user/files.rst0000644000175100001770000000370114656212142020066 0ustar00runnerdocker.. _files: Working with files ================== Assuming you have :ref:`connected ` to your site. Getting info about a file ------------------------- To get information about a file: >>> file = site.images['Example.jpg'] where ``file`` is now an instance of :class:`Image ` that you can query for various properties: >>> file.imageinfo {'comment': 'Reverted to version as of 17:58, 12 March 2010', 'descriptionshorturl': 'https://commons.wikimedia.org/w/index.php?curid=6428847', 'descriptionurl': 'https://commons.wikimedia.org/wiki/File:Example.jpg', 'height': 178, 'metadata': [{'name': 'MEDIAWIKI_EXIF_VERSION', 'value': 1}], 'sha1': 'd01b79a6781c72ac9bfff93e5e2cfbeef4efc840', 'size': 9022, 'timestamp': '2010-03-14T17:20:20Z', 'url': 'https://upload.wikimedia.org/wikipedia/commons/a/a9/Example.jpg', 'user': 'SomeUser', 'width': 172} You also have easy access to file usage: >>> for page in image.imageusage(): >>> print('Page:', page.name, '; namespace:', page.namespace) See the :class:`API reference ` for more options. .. caution:: Note that ``Image.exists`` refers to whether a file exists *locally*. If a file does not exist locally, but in a shared repo like Wikimedia Commons, it will return ``False``. To check if a file exists locally *or* in a shared repo, you could test if ``image.imageinfo != {}``. Downloading a file ------------------ The :meth:`Image.download() ` method can be used to download the full size file. Pass it a file object and it will stream the image to it, avoiding the need for keeping the whole file in memory: >>> file = site.images['Example.jpg'] >>> with open('Example.jpg', 'wb') as fd: ... image.download(fd) Uploading a file ---------------- >>> site.upload(open('file.jpg'), 'destination.jpg', 'Image description') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/docs/source/user/implementation-notes.rst0000644000175100001770000000466114656212142023145 0ustar00runnerdocker.. _implementation-notes: Implementation notes ==================== Most properties and generators accept the same parameters as the API, without their two-letter prefix. Some notable exceptions: * ``Image.imageinfo`` is the imageinfo of the latest image. Earlier versions can be fetched using ``imagehistory()``. * ``Site.all*``: parameter ``(ap)from`` renamed to ``start`` * ``categorymembers`` is implemented as ``Category.members`` * ``deletedrevs`` is ``deletedrevisions`` * ``usercontribs`` is ``usercontributions`` * First parameters of ``search`` and ``usercontributions`` are ``search`` and ``user``, respectively Properties and generators are implemented as Python generators which yield one item per iteration. Their deprecated ``limit`` parameter is only an indication of the number of items retrieved from the API per request. It is not the total limit. Doing ``list(generator(limit = 50))`` will return ALL items, not 50, but it will query the API in chunks of 50 items at a time (so after yielding one item from the generator, the next 49 will be "free", then the next will trigger a new API call). The replacement ``api_chunk_size`` parameter does the same thing, but is more clearly named. If both ``limit`` and ``api_chunk_size`` are specified, ``limit`` will be ignored. The ``max_items`` parameter sets a total limit on the number of items which will be yielded. Use ``list(generator(max_items = 50))`` to limit the amount of items returned to 50. Higher level functions that have a ``limit`` parameter also now have ``api_chunk_size`` and ``max_items`` parameters that should be preferred. Default API chunk size is generally the maximum chunk size (500 for most wikis). Page objects ------------ The base Page object is called ``Page`` and from that derive ``Category`` and ``Image``. When the page is retrieved via ``Site.pages`` or a generator, it will check automatically which of those three specific types should be returned. For convenience, ``Site.images`` and ``Site.categories`` are also provided. These do exactly the same as `Site.Pages`, except that they require the page name without its namespace prefixed. >>> page = site.Pages['Template:Stub'] # a Page object >>> image = site.Pages['Image:Wiki.png'] # an Image object >>> image = site.Images['Wiki.png'] # the same Image object >>> cat = site.Pages['Category:Python'] # a Category object >>> cat = site.Images['Python'] # the same Category object ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/docs/source/user/index.rst0000644000175100001770000000057314656212142020077 0ustar00runnerdocker.. _userguide: User guide ================= This guide is intended as an introductory overview, and explains how to make use of the most important features of mwclient. For detailed reference documentation of the functions and classes contained in the package, see the :ref:`reference`. .. toctree:: :maxdepth: 2 connecting page-ops files implementation-notes ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/docs/source/user/page-ops.rst0000644000175100001770000000601214656212142020475 0ustar00runnerdocker.. _page-ops: Page operations =============== Start by :ref:`connecting ` to your site as described in the :ref:`Connecting to your site ` section. .. code-block:: python from mwclient import Site user_agent = 'MyCoolTool/0.2 (xyz@example.org)' site = Site('en.wikipedia.org', clients_useragent=user_agent) For information about authenticating, please see :ref:`the section on authenticating `. Editing or creating a page -------------------------- To get the content of a specific page: >>> page = site.pages['Greater guinea pig'] >>> text = page.text() If a page doesn't exist, :meth:`Page.text() ` just returns an empty string. If you need to test the existence of the page, use `page.exists`: >>> page.exists True Edit the text as you like before saving it back to the wiki: >>> page.edit(text, 'Edit summary') If the page didn't exist, this operation will create it. Listing page revisions ---------------------- :meth:`Page.revisions() ` returns a List object that you can iterate over using a for loop. Continuation is handled under the hood so you don't have to worry about it. *Example:* Let's find out which users did the most number of edits to a page: >>> users = [rev['user'] for rev in page.revisions()] >>> unique_users = set(users) >>> user_revisions = [{'user': user, 'count': users.count(user)} for user in unique_users] >>> sorted(user_revisions, key=lambda x: x['count'], reverse=True)[:5] [{'count': 6, 'user': 'Wolf12345'}, {'count': 4, 'user': 'Test-bot'}, {'count': 4, 'user': 'Mirxaeth'}, {'count': 3, 'user': '192.251.192.201'}, {'count': 3, 'user': '78.50.51.180'}] *Tip:* If you want to retrieve a specific number of revisions, the :code:`itertools.islice` method can come in handy: >>> from datetime import datetime >>> from time import mktime >>> from itertools import islice >>> for revision in islice(page.revisions(), 5): ... dt = datetime.fromtimestamp(mktime(revision['timestamp'])) ... print '{}'.format(dt.strftime('%F %T')) Categories ---------- Categories can be retrieved in the same way as pages, but you can also use :meth:`Site.categories() ` and skip the namespace prefix. The returned :class:`Category ` object supports the same methods as the :class:`Page ` object, but also provides an extra function, :meth:`members() `, to list all members of a category. The Category object can also be used itself as an iterator to yield all its members: >>> category = site.categories['Python'] >>> for page in category: >>> print(page.name) Other page operations --------------------- There are many other page operations like :meth:`backlinks() `, :meth:`embeddedin() `, etc. See the :class:`API reference ` for more. ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1723405414.3513734 mwclient-0.11.0/examples/0000755000175100001770000000000014656212146014645 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/examples/basic_edit.py0000644000175100001770000000367614656212142017315 0ustar00runnerdockerimport sys import os if len(sys.argv) > 3: sys.path.append(os.path.abspath(sys.argv[3])) if len(sys.argv) < 3: print('python basic_edit_test.py []\n') sys.exit() # Create a config file containing: # host = 'test.wikipedia.org' # path = '/w/' # ext = '.php' # username = 'Bryan' # password = 'xyz' prefix = sys.argv[2] # import cgitb; cgitb.enable(format = 'text') try: import apiedit as mwclient except ImportError: import mwclient site = mwclient.ex.ConfiguredSite(sys.argv[1]) site.compress = False print('Running configured site', sys.argv[1]) page = site.Pages[prefix + '/text1'] print('Editing page1') text1 = u"""== [[Test page]] == This is a [[test]] page generated by [http://mwclient.sourceforge.org/ mwclient]. This test is done using the [[w:mw:API]].""" comment1 = 'Test page1' page.edit(text1, comment1) rev = page.revisions(limit=1, prop='timestamp|comment|content').next() assert rev['comment'] == comment1, rev assert rev['*'] == rev['*'], rev print('Page edited on', rev['timestamp']) print('Links:', list(page.links(generator=False))) print('External links:', list(page.extlinks())) print('Uploading image') site.upload(open('test-image.png', 'rb'), prefix + '-test-image.png', 'desc', ignore=True) print('Uploading image for the second time') site.upload(open('test-image.png', 'rb'), prefix + '-test-image.png', 'desc', ignore=True) image = site.Images[prefix + '-test-image.png'] print('Imageinfo:', image.imageinfo) history = list(image.imagehistory()) print('History:', history) print('Deleting old version') archivename = history[1]['archivename'] image.delete('Testing history deletion', oldimage=archivename) print('History:', list(image.imagehistory())) text = page.text() text += '\n[[Image:%s-test-image.png]]' % prefix page.edit(text, 'Adding image') print('Images:', list(page.images(generator=False))) print('Cleaning up') image.delete('Cleanup') page.delete('Cleanup') print('Done') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/examples/test-image.png0000644000175100001770000000421114656212142017404 0ustar00runnerdockerPNG  IHDR@`)<sRGBgAMA a cHRMz&u0`:pQ<IDATx^mHEyF$Q(0k,@!@Ki'{Uš1_ُ{6?(_n^$+|G_s(0(]`& : c_Bľ0lؗ6/ (:%MK0(& }I` : c_ľ$0lؗ6/ (:%MK0(& }I` : c_ľ$0lؗ6/ (:%MK0(& }I` : c_ľ$0lؗ6/ (:%MK0(& }I`9Z& ~x7e?^>/۔x t>%òfϺ_Nog'$̬nΊHmM&R8|ZFy@+7#[? m_+gܧP|5'}~̶Ϟܧ-Ap,uuLd`_|:='صo~eR$a~^J +?J .> r|Kpx͏ɕˊЅ}|8f‡F.-Ɩ<>,Sa.hؼ"gtGe6ey?y~핀:>'2Ȗ٬9QսUFP±I,-g;;#&g,Gȷ/ Z *;{Z10w+jSAE8x9?y>xŔg;C&/oP0t*>0]^#[ XYAB ʳh۞ |^-81;`_{Ǵ|g.=t>(/'YhiLwN7pgug^ 2JGyXo f^uUy2_][&Æc3UW v 3: host = sys.argv[3] else: host = 'test.wikipedia.org' if len(sys.argv) > 4: path = sys.argv[4] else: path = '/w/' site = mwclient.Site(host, path) site.login(sys.argv[1], sys.argv[2]) name = ''.join(random.choice('abcdefghijklmnopqrstuvwxyz') for i in range(8)) + '.png' print('Using http://%s%sindex.php?title=File:' % (host, path) + name) print('Regular upload test') res = site.upload(open('test-image.png', 'rb'), name, 'Regular upload test', ignore=True) pprint.pprint(res) assert res['result'] == 'Success' assert 'exists' not in res['warnings'] print('Overwriting; should give a warning') res = site.upload(open('test-image.png', 'rb'), name, 'Overwrite upload test') pprint.pprint(res) assert res['result'] == 'Warning' assert 'exists' in res['warnings'] ses = res['sessionkey'] print('Overwriting with stashed file') res = site.upload(filename=name, filekey=ses) pprint.pprint(res) assert res['result'] == 'Warning' assert 'duplicate' in res['warnings'] assert 'exists' in res['warnings'] print('Uploading empty file; error expected') res = site.upload(StringIO(), name, 'Empty upload test') ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1723405414.3553734 mwclient-0.11.0/mwclient/0000755000175100001770000000000014656212146014651 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/mwclient/__init__.py0000644000175100001770000000253714656212142016765 0ustar00runnerdocker""" Copyright (c) 2006-2011 Bryan Tong Minh Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ from mwclient.errors import * # noqa: F401, F403 from mwclient.client import Site, __version__ # noqa: F401 import logging import warnings # Show DeprecationWarning warnings.simplefilter('always', DeprecationWarning) logging.getLogger(__name__).addHandler(logging.NullHandler()) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/mwclient/client.py0000644000175100001770000020236514656212142016505 0ustar00runnerdockerimport warnings import logging from collections import OrderedDict import json import requests from requests.auth import HTTPBasicAuth, AuthBase from requests_oauthlib import OAuth1 import mwclient.errors as errors import mwclient.listing as listing from mwclient.sleep import Sleepers from mwclient.util import parse_timestamp, read_in_chunks, handle_limit __version__ = '0.11.0' log = logging.getLogger(__name__) USER_AGENT = 'mwclient/{} ({})'.format(__version__, 'https://github.com/mwclient/mwclient') class Site: """A MediaWiki site identified by its hostname. Examples: >>> import mwclient >>> wikipedia_site = mwclient.Site('en.wikipedia.org') >>> wikia_site = mwclient.Site('vim.wikia.com', path='/') Args: host (str): The hostname of a MediaWiki instance. Must not include a scheme (e.g. `https://`) - use the `scheme` argument instead. path (str): The instances script path (where the `index.php` and `api.php` scripts are located). Must contain a trailing slash (`/`). Defaults to `/w/`. ext (str): The file extension used by the MediaWiki API scripts. Defaults to `.php`. pool (requests.Session): A preexisting :class:`~requests.Session` to be used when executing API requests. retry_timeout (int): The number of seconds to sleep for each past retry of a failing API request. Defaults to `30`. max_retries (int): The maximum number of retries to perform for failing API requests. Defaults to `25`. wait_callback (Callable): A callback function to be executed for each failing API request. clients_useragent (str): A prefix to be added to the default mwclient user-agent. Should follow the pattern `'{tool_name}/{tool_version} ({contact})'`. Check the `User-Agent policy `_ for more information. max_lag (int): A `maxlag` parameter to be used in `index.php` calls. Consult the `documentation `_ for more information. Defaults to `3`. compress (bool): Whether to request and accept gzip compressed API responses. Defaults to `True`. force_login (bool): Whether to require authentication when editing pages. Set to `False` to allow unauthenticated edits. Defaults to `True`. do_init (bool): Whether to automatically initialize the :py:class:`Site` on initialization. When set to `False`, the :py:class:`Site` must be initialized manually using the :py:meth:`.site_init` method. Defaults to `True`. httpauth (Union[tuple[basestring, basestring], requests.auth.AuthBase]): An authentication method to be used when making API requests. This can be either an authentication object as provided by the :py:mod:`requests` library, or a tuple in the form `{username, password}`. Usernames and passwords provided as 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. 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. consumer_secret (str): OAuth1 consumer secret for owner-only consumers. access_token (str): OAuth1 access key for owner-only consumers. access_secret (str): OAuth1 access secret for owner-only consumers. client_certificate (Union[str, tuple[str, str]]): A client certificate to be added to the session. custom_headers (Dict[str, str]): A dictionary of custom headers to be added to all API requests. scheme (str): The URI scheme to use. This should be either `http` or `https` in most cases. Defaults to `https`. Raises: RuntimeError: The authentication passed to the `httpauth` parameter is invalid. You must pass either a tuple or a :class:`requests.auth.AuthBase` object. errors.OAuthAuthorizationError: The OAuth authorization is invalid. errors.LoginError: Login failed, the reason can be obtained from e.code and e.info (where e is the exception object) and will be one of the API:Login errors. The most common error code is "Failed", indicating a wrong username or password. """ api_limit = 500 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, 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 self.ext = ext self.credentials = None self.compress = compress self.max_lag = str(max_lag) self.force_login = force_login 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 if consumer_token is not None: auth = OAuth1(consumer_token, consumer_secret, access_token, access_secret) elif isinstance(httpauth, (list, tuple)): # workaround weird requests default to encode as latin-1 # https://github.com/mwclient/mwclient/issues/315 # https://github.com/psf/requests/issues/4564 httpauth = [ it.encode("utf-8") if isinstance(it, str) else it for it in httpauth ] auth = HTTPBasicAuth(*httpauth) elif httpauth is None or isinstance(httpauth, (AuthBase,)): auth = httpauth else: # FIXME: Raise a specific exception instead of a generic RuntimeError. raise RuntimeError('Authentication is not a tuple or an instance of AuthBase') self.sleepers = Sleepers(max_retries, retry_timeout, wait_callback) # Site properties self.blocked = False # Whether current user is blocked self.hasmsg = False # Whether current user has new messages self.groups = [] # Groups current user belongs to self.rights = [] # Rights current user has self.tokens = {} # Edit tokens of the current user self.version = None self.namespaces = self.default_namespaces # Setup connection if pool is None: self.connection = requests.Session() self.connection.auth = auth if client_certificate: self.connection.cert = client_certificate # Set User-Agent header field if clients_useragent: ua = clients_useragent + ' ' + USER_AGENT else: ua = USER_AGENT self.connection.headers['User-Agent'] = ua if custom_headers: self.connection.headers.update(custom_headers) else: self.connection = pool # Page generators self.pages = listing.PageList(self) self.categories = listing.PageList(self, namespace=14) self.images = listing.PageList(self, namespace=6) # Compat page generators self.Pages = self.pages self.Categories = self.categories self.Images = self.images # Initialization status self.initialized = False # Upload chunk size in bytes self.chunk_size = 1048576 if do_init: try: self.site_init() except errors.APIError as e: if e.args[0] == 'mwoauth-invalid-authorization': raise errors.OAuthAuthorizationError(self, e.code, e.info) # Private wiki, do init after login if e.args[0] not in {'unknown_action', 'readapidenied'}: raise def site_init(self): """Populates the object with information about the current user and site. This is done automatically when creating the object, unless explicitly disabled using the `do_init=False` constructor argument.""" if self.initialized: info = self.get('query', meta='userinfo', uiprop='groups|rights') userinfo = info['query']['userinfo'] self.username = userinfo['name'] self.groups = userinfo.get('groups', []) self.rights = userinfo.get('rights', []) self.tokens = {} return meta = self.get('query', meta='siteinfo|userinfo', siprop='general|namespaces', uiprop='groups|rights', retry_on_error=False) # Extract site info self.site = meta['query']['general'] self.namespaces = { namespace['id']: namespace.get('*', '') for namespace in meta['query']['namespaces'].values() } self.version = self.version_tuple_from_generator(self.site['generator']) # Require MediaWiki version >= 1.16 self.require(1, 16) # User info userinfo = meta['query']['userinfo'] self.username = userinfo['name'] self.groups = userinfo.get('groups', []) self.rights = userinfo.get('rights', []) self.initialized = True @staticmethod def version_tuple_from_generator(string, prefix='MediaWiki '): """Return a version tuple from a MediaWiki Generator string. Example: >>> Site.version_tuple_from_generator("MediaWiki 1.5.1") (1, 5, 1) Args: string (str): The MediaWiki Generator string. prefix (str): The expected prefix of the string. Returns: A tuple containing the individual elements of the given version number. """ if not string.startswith(prefix): raise errors.MediaWikiVersionError('Unknown generator {}'.format(string)) version = string[len(prefix):].split('.') def split_num(s): """Split the string on the first non-digit character. Returns: A tuple of the digit part as int and, if available, the rest of the string. """ i = 0 while i < len(s): if s[i] < '0' or s[i] > '9': break i += 1 if s[i:]: return (int(s[:i]), s[i:], ) else: return (int(s[:i]), ) version_tuple = sum((split_num(s) for s in version), ()) if len(version_tuple) < 2: raise errors.MediaWikiVersionError('Unknown MediaWiki {}' .format('.'.join(version))) return version_tuple default_namespaces = { 0: '', 1: 'Talk', 2: 'User', 3: 'User talk', 4: 'Project', 5: 'Project talk', 6: 'Image', 7: 'Image talk', 8: 'MediaWiki', 9: 'MediaWiki talk', 10: 'Template', 11: 'Template talk', 12: 'Help', 13: 'Help talk', 14: 'Category', 15: 'Category talk', -1: 'Special', -2: 'Media' } def __repr__(self): return "<%s object '%s%s'>" % (self.__class__.__name__, self.host, self.path) def get(self, action, *args, **kwargs): """Perform a generic API call using GET. This is just a shorthand for calling api() with http_method='GET'. All arguments will be passed on. Args: action (str): The MediaWiki API action to be performed. Returns: The raw response from the API call, as a dictionary. """ return self.api(action, 'GET', *args, **kwargs) def post(self, action, *args, **kwargs): """Perform a generic API call using POST. This is just a shorthand for calling api() with http_method='POST'. All arguments will be passed on. Args: action (str): The MediaWiki API action to be performed. Returns: The raw response from the API call, as a dictionary. """ return self.api(action, 'POST', *args, **kwargs) def api(self, action, http_method='POST', *args, **kwargs): """Perform a generic API call and handle errors. All arguments will be passed on. Args: action (str): The MediaWiki API action to be performed. http_method (str): The HTTP method to use. Example: To get coordinates from the GeoData MediaWiki extension at English Wikipedia: >>> site = Site('en.wikipedia.org') >>> result = site.api('query', prop='coordinates', titles='Oslo|Copenhagen') >>> for page in result['query']['pages'].values(): ... if 'coordinates' in page: ... print('{} {} {}'.format(page['title'], ... page['coordinates'][0]['lat'], ... page['coordinates'][0]['lon'])) Oslo 59.95 10.75 Copenhagen 55.6761 12.5683 Returns: The raw response from the API call, as a dictionary. """ kwargs.update(args) if action == 'query' and 'continue' not in kwargs: kwargs['continue'] = '' if action == 'query': if 'meta' in kwargs: kwargs['meta'] += '|userinfo' else: kwargs['meta'] = 'userinfo' if 'uiprop' in kwargs: kwargs['uiprop'] += '|blockinfo|hasmsg' else: kwargs['uiprop'] = 'blockinfo|hasmsg' sleeper = self.sleepers.make() while True: info = self.raw_api(action, http_method, **kwargs) if not info: info = {} if self.handle_api_result(info, sleeper=sleeper): return info def handle_api_result(self, info, kwargs=None, sleeper=None): """Checks the given API response, raising an appropriate exception or sleeping if necessary. Args: info (dict): The API result. kwargs (dict): Additional arguments to be passed when raising an :class:`errors.APIError`. sleeper (sleep.Sleeper): A :class:`~sleep.Sleeper` instance to use when sleeping. Returns: `False` if the given API response contains an exception, else `True`. """ if sleeper is None: sleeper = self.sleepers.make() try: userinfo = info['query']['userinfo'] except KeyError: userinfo = () if 'blockedby' in userinfo: self.blocked = (userinfo['blockedby'], userinfo.get('blockreason', '')) else: self.blocked = False self.hasmsg = 'messages' in userinfo self.logged_in = 'anon' not in userinfo if 'warnings' in info: for module, warning in info['warnings'].items(): if '*' in warning: log.warning(warning['*']) if 'error' in info: if info['error'].get('code') in {'internal_api_error_DBConnectionError', 'internal_api_error_DBQueryError'}: sleeper.sleep() return False # cope with https://phabricator.wikimedia.org/T106066 if ( info['error'].get('code') == 'mwoauth-invalid-authorization' and 'Nonce already used' in info['error'].get('info') ): log.warning('Retrying due to nonce error, see' 'https://phabricator.wikimedia.org/T106066') sleeper.sleep() return False if 'query' in info['error']: # Semantic Mediawiki does not follow the standard error format raise errors.APIError(None, info['error']['query'], kwargs) if '*' in info['error']: raise errors.APIError(info['error']['code'], info['error']['info'], info['error']['*']) raise errors.APIError(info['error']['code'], info['error']['info'], kwargs) return True @staticmethod def _query_string(*args, **kwargs): kwargs.update(args) qs1 = [ (k, v) for k, v in kwargs.items() if k not in {'wpEditToken', 'token'} ] qs2 = [ (k, v) for k, v in kwargs.items() if k in {'wpEditToken', 'token'} ] return OrderedDict(qs1 + qs2) def raw_call(self, script, data, files=None, retry_on_error=True, http_method='POST'): """ Perform a generic request and return the raw text. In the event of a network problem, or an HTTP response with status code 5XX, we'll wait and retry the configured number of times before giving up if `retry_on_error` is True. `requests.exceptions.HTTPError` is still raised directly for HTTP responses with status codes in the 4XX range, and invalid HTTP responses. Args: script (str): Script name, usually 'api'. data (dict): Post data files (dict): Files to upload retry_on_error (bool): Retry on connection error http_method (str): The HTTP method, defaults to 'POST' Returns: The raw text response. Raises: errors.MaximumRetriesExceeded: The API request failed and the maximum number of retries was exceeded. requests.exceptions.HTTPError: Received an invalid HTTP response, or a status code in the 4xx range. requests.exceptions.ConnectionError: Encountered an unexpected error while performing the API request. requests.exceptions.Timeout: The API request timed out. """ headers = {} if self.compress: headers['Accept-Encoding'] = 'gzip' sleeper = self.sleepers.make((script, data)) scheme = self.scheme host = self.host if isinstance(host, (list, tuple)): warnings.warn( 'Specifying host as a tuple is deprecated as of mwclient 0.10.1. ' + 'Please use the new scheme argument instead.', DeprecationWarning ) scheme, host = host url = '{scheme}://{host}{path}{script}{ext}'.format(scheme=scheme, host=host, path=self.path, script=script, ext=self.ext) while True: toraise = None wait_time = 0 args = {'files': files, 'headers': headers} for k, v in self.requests.items(): args[k] = v if http_method == 'GET': args['params'] = data else: args['data'] = data try: stream = self.connection.request(http_method, url, **args) if stream.headers.get('x-database-lag'): wait_time = int(stream.headers.get('retry-after')) log.warning('Database lag exceeds max lag. ' 'Waiting for {} seconds'.format(wait_time)) # fall through to the sleep elif stream.status_code == 200: return stream.text elif stream.status_code < 500 or stream.status_code > 599: stream.raise_for_status() else: if not retry_on_error: stream.raise_for_status() log.warning('Received {status} response: {text}. ' 'Retrying in a moment.' .format(status=stream.status_code, text=stream.text)) toraise = "stream" # fall through to the sleep except ( requests.exceptions.ConnectionError, requests.exceptions.Timeout ) as err: # In the event of a network problem # (e.g. DNS failure, refused connection, etc), # Requests will raise a ConnectionError exception. if not retry_on_error: raise log.warning('Connection error. Retrying in a moment.') toraise = err # proceed to the sleep # all retry paths come here try: sleeper.sleep(wait_time) except errors.MaximumRetriesExceeded: if toraise == "stream": stream.raise_for_status() elif toraise: raise toraise else: raise def raw_api(self, action, http_method='POST', retry_on_error=True, *args, **kwargs): """Send a call to the API. Args: action (str): The MediaWiki API action to perform. http_method (str): The HTTP method to use in the request. retry_on_error (bool): Whether to retry API call on connection errors. *args (Tuple[str, Any]): Arguments to be passed to the `api.php` script as data. **kwargs (Any): Arguments to be passed to the `api.php` script as data. Returns: The API response. Raises: errors.APIDisabledError: The MediaWiki API is disabled for this instance. errors.InvalidResponse: The API response could not be decoded from JSON. errors.MaximumRetriesExceeded: The API request failed and the maximum number of retries was exceeded. requests.exceptions.HTTPError: Received an invalid HTTP response, or a status code in the 4xx range. requests.exceptions.ConnectionError: Encountered an unexpected error while performing the API request. requests.exceptions.Timeout: The API request timed out. """ kwargs['action'] = action kwargs['format'] = 'json' data = self._query_string(*args, **kwargs) res = self.raw_call('api', data, retry_on_error=retry_on_error, http_method=http_method) try: return json.loads(res, object_pairs_hook=OrderedDict) except ValueError: if res.startswith('MediaWiki API is not enabled for this site.'): raise errors.APIDisabledError raise errors.InvalidResponse(res) def raw_index(self, action, http_method='POST', *args, **kwargs): """Sends a call to index.php rather than the API. Args: action (str): The MediaWiki API action to perform. http_method (str): The HTTP method to use in the request. *args (Tuple[str, Any]): Arguments to be passed to the `index.php` script as data. **kwargs (Any): Arguments to be passed to the `index.php` script as data. Returns: The API response. Raises: errors.MaximumRetriesExceeded: The API request failed and the maximum number of retries was exceeded. requests.exceptions.HTTPError: Received an invalid HTTP response, or a status code in the 4xx range. requests.exceptions.ConnectionError: Encountered an unexpected error while performing the API request. requests.exceptions.Timeout: The API request timed out. """ kwargs['action'] = action kwargs['maxlag'] = self.max_lag data = self._query_string(*args, **kwargs) return self.raw_call('index', data, http_method=http_method) def require(self, major, minor, revision=None, raise_error=True): """Check whether the current wiki matches the required version. Args: major (int): The required major version. minor (int): The required minor version. revision (int): The required revision. raise_error (bool): Whether to throw an error if the version of the current wiki is below the required version. Defaults to `True`. Returns: `False` if the version of the current wiki is below the required version, else `True`. If either `raise_error=True` or the site is uninitialized and `raise_error=None` then nothing is returned. Raises: errors.MediaWikiVersionError: The current wiki is below the required version and `raise_error=True`. RuntimeError: It `raise_error` is `None` and the `version` attribute is unset This is usually done automatically on construction of the :class:`Site`, unless `do_init=False` is passed to the constructor. After instantiation, the :meth:`~Site.site_init` functon can be used to retrieve and set the `version`. NotImplementedError: If the `revision` argument was passed. The logic for this is currently unimplemented. """ if self.version is None: if raise_error is None: return # FIXME: Replace this with a specific error raise RuntimeError('Site %s has not yet been initialized' % repr(self)) if revision is None: if self.version[:2] >= (major, minor): return True elif raise_error: raise errors.MediaWikiVersionError( 'Requires version {required[0]}.{required[1]}, ' 'current version is {current[0]}.{current[1]}' .format(required=(major, minor), current=(self.version[:2])) ) else: return False else: raise NotImplementedError # Actions def email(self, user, text, subject, cc=False): """ Send email to a specified user on the wiki. >>> try: ... site.email('SomeUser', 'Some message', 'Some subject') ... except mwclient.errors.NoSpecifiedEmail: ... print('User does not accept email, or has no email address.') Args: user (str): User name of the recipient text (str): Body of the email subject (str): Subject of the email cc (bool): True to send a copy of the email to yourself (default is False) Returns: Dictionary of the JSON response Raises: NoSpecifiedEmail (mwclient.errors.NoSpecifiedEmail): User doesn't accept email EmailError (mwclient.errors.EmailError): Other email errors """ token = self.get_token('email') try: info = self.post('emailuser', target=user, subject=subject, text=text, ccme=cc, token=token) except errors.APIError as e: if e.args[0] == 'noemail': raise errors.NoSpecifiedEmail(user, e.args[1]) raise errors.EmailError(*e) return info def login(self, username=None, password=None, cookies=None, domain=None): """ Login to the wiki using a username and bot password. The method returns nothing if the login was successful, but raises and error if it was not. If you use mediawiki >= 1.27 and try to login with normal account (not botpassword account), you should use `clientlogin` instead, because login action is deprecated since 1.27 with normal account and will stop working in the near future. See these pages to learn more: - https://www.mediawiki.org/wiki/API:Login and - https://www.mediawiki.org/wiki/Manual:Bot_passwords Note: at least until v1.33.1, botpasswords accounts seem to not have "userrights" permission. If you need to update user's groups, this permission is required so you must use `client login` with a user who has userrights permission (a bureaucrat for eg.). Args: username (str): MediaWiki username password (str): MediaWiki password cookies (dict): Custom cookies to include with the log-in request. domain (str): Sends domain name for authentication; used by some MediaWiki plug-ins like the 'LDAP Authentication' extension. Raises: LoginError (mwclient.errors.LoginError): Login failed, the reason can be obtained from e.code and e.info (where e is the exception object) and will be one of the API:Login errors. The most common error code is "Failed", indicating a wrong username or password. MaximumRetriesExceeded: API call to log in failed and was retried until all retries were exhausted. This will not occur if the credentials are merely incorrect. See MaximumRetriesExceeded for possible reasons. APIError: An API error occurred. Rare, usually indicates an internal server error. """ if username and password: self.credentials = (username, password, domain) if cookies: self.connection.cookies.update(cookies) if self.credentials: sleeper = self.sleepers.make() kwargs = { 'lgname': self.credentials[0], 'lgpassword': self.credentials[1] } if self.credentials[2]: kwargs['lgdomain'] = self.credentials[2] # Try to login using the scheme for MW 1.27+. If the wiki is read protected, # it is not possible to get the wiki version upfront using the API, so we just # have to try. If the attempt fails, we try the old method. try: kwargs['lgtoken'] = self.get_token('login') except (errors.APIError, KeyError): log.debug('Failed to get login token, MediaWiki is older than 1.27.') while True: login = self.post('login', **kwargs) if login['login']['result'] == 'Success': break elif login['login']['result'] == 'NeedToken': kwargs['lgtoken'] = login['login']['token'] elif login['login']['result'] == 'Throttled': sleeper.sleep(int(login['login'].get('wait', 5))) else: raise errors.LoginError(self, login['login']['result'], login['login']['reason']) self.site_init() def clientlogin(self, cookies=None, **kwargs): """ Login to the wiki using a username and password. The method returns True if it's a success or the returned response if it's a multi-steps login process you started. In case of failure it raises some Errors. Example for classic username / password clientlogin request: >>> try: ... site.clientlogin(username='myusername', password='secret') ... except mwclient.errors.LoginError as e: ... print('Could not login to MediaWiki: %s' % e) Args: cookies (dict): Custom cookies to include with the log-in request. **kwargs (dict): Custom vars used for clientlogin as: - loginmergerequestfields - loginpreservestate - loginreturnurl, - logincontinue - logintoken - *: additional params depending on the available auth requests. to log with classic username / password, you need to add `username` and `password` See https://www.mediawiki.org/wiki/API:Login#Method_2._clientlogin Raises: LoginError (mwclient.errors.LoginError): Login failed, the reason can be obtained from e.code and e.info (where e is the exception object) and will be one of the API:Login errors. The most common error code is "Failed", indicating a wrong username or password. MaximumRetriesExceeded: API call to log in failed and was retried until all retries were exhausted. This will not occur if the credentials are merely incorrect. See MaximumRetriesExceeded for possible reasons. APIError: An API error occurred. Rare, usually indicates an internal server error. """ self.require(1, 27) if cookies: self.connection.cookies.update(cookies) if kwargs: # Try to login using the scheme for MW 1.27+. If the wiki is read protected, # it is not possible to get the wiki version upfront using the API, so we just # have to try. If the attempt fails, we try the old method. if 'logintoken' not in kwargs: try: kwargs['logintoken'] = self.get_token('login') except (errors.APIError, KeyError): log.debug('Failed to get login token, MediaWiki is older than 1.27.') if 'logincontinue' not in kwargs and 'loginreturnurl' not in kwargs: # should be great if API didn't require this... kwargs['loginreturnurl'] = '%s://%s' % (self.scheme, self.host) while True: login = self.post('clientlogin', **kwargs) status = login['clientlogin'].get('status') if status == 'PASS': return True elif status in ('UI', 'REDIRECT'): return login['clientlogin'] else: raise errors.LoginError(self, status, login['clientlogin'].get('message')) def get_token(self, type, force=False, title=None): """Request a MediaWiki access token of the given `type`. Args: type (str): The type of token to request. force (bool): Force the request of a new token, even if a token of that type has already been cached. title (str): The page title for which to request a token. Only used for MediaWiki versions below 1.24. Returns: A MediaWiki token of the requested `type`. Raises: errors.APIError: A token of the given type could not be retrieved. """ if self.version is None or self.version[:2] >= (1, 24): # The 'csrf' (cross-site request forgery) token introduced in 1.24 replaces # the majority of older tokens, like edittoken and movetoken. if type not in {'watch', 'patrol', 'rollback', 'userrights', 'login'}: type = 'csrf' if type not in self.tokens: self.tokens[type] = '0' if self.tokens.get(type, '0') == '0' or force: if self.version is None or self.version[:2] >= (1, 24): # We use raw_api() rather than api() because api() is adding "userinfo" # to the query and this raises a readapideniederror if the wiki is read # protected, and we're trying to fetch a login token. info = self.raw_api('query', 'GET', meta='tokens', type=type) self.handle_api_result(info) # Note that for read protected wikis, we don't know the version when # fetching the login token. If it's < 1.27, the request below will # raise a KeyError that we should catch. self.tokens[type] = info['query']['tokens']['%stoken' % type] else: if title is None: # Some dummy title was needed to get a token prior to 1.24 title = 'Test' info = self.post('query', titles=title, prop='info', intoken=type) for i in info['query']['pages'].values(): if i['title'] == title: self.tokens[type] = i['%stoken' % type] return self.tokens[type] def upload(self, file=None, filename=None, description='', ignore=False, file_size=None, url=None, filekey=None, comment=None): """Upload a file to the site. Note that one of `file`, `filekey` and `url` must be specified, but not more than one. For normal uploads, you specify `file`. Args: file (str): File object or stream to upload. filename (str): Destination filename, don't include namespace prefix like 'File:' description (str): Wikitext for the file description page. ignore (bool): True to upload despite any warnings. file_size (int): Deprecated in mwclient 0.7 url (str): URL to fetch the file from. filekey (str): Key that identifies a previous upload that was stashed temporarily. comment (str): Upload comment. Also used as the initial page text for new files if `description` is not specified. Example: >>> client.upload(open('somefile', 'rb'), filename='somefile.jpg', description='Some description') Returns: JSON result from the API. Raises: errors.InsufficientPermission requests.exceptions.HTTPError errors.FileExists: The file already exists and `ignore` is `False`. """ if file_size is not None: # Note that DeprecationWarning is hidden by default since Python 2.7 warnings.warn( 'file_size is deprecated since mwclient 0.7', DeprecationWarning ) if filename is None: raise TypeError('filename must be specified') if len([x for x in [file, filekey, url] if x is not None]) != 1: raise TypeError( "exactly one of 'file', 'filekey' and 'url' must be specified" ) image = self.Images[filename] if not image.can('upload'): raise errors.InsufficientPermission(filename) if comment is None: comment = description text = None else: comment = comment text = description if file is not None: if not hasattr(file, 'read'): file = open(file, 'rb') content_size = file.seek(0, 2) file.seek(0) if self.version[:2] >= (1, 20) and content_size > self.chunk_size: return self.chunk_upload(file, filename, ignore, comment, text) predata = { 'action': 'upload', 'format': 'json', 'filename': filename, 'comment': comment, 'text': text, 'token': image.get_token('edit'), } if ignore: predata['ignorewarnings'] = 'true' if url: predata['url'] = url # sessionkey was renamed to filekey in MediaWiki 1.18 # https://phabricator.wikimedia.org/rMW5f13517e36b45342f228f3de4298bb0fe186995d if self.version[:2] < (1, 18): predata['sessionkey'] = filekey else: predata['filekey'] = filekey postdata = predata files = None if file is not None: # Workaround for https://github.com/mwclient/mwclient/issues/65 # ---------------------------------------------------------------- # Since the filename in Content-Disposition is not interpreted, # we can send some ascii-only dummy name rather than the real # filename, which might contain non-ascii. files = {'file': ('fake-filename', file)} sleeper = self.sleepers.make() while True: data = self.raw_call('api', postdata, files) info = json.loads(data) if not info: 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 def chunk_upload(self, file, filename, ignorewarnings, comment, text): """Upload a file to the site in chunks. This method is called by `Site.upload` if you are connecting to a newer MediaWiki installation, so it's normally not necessary to call this method directly. Args: file (file-like object): File object or stream to upload. params (dict): Dict containing upload parameters. """ image = self.Images[filename] content_size = file.seek(0, 2) file.seek(0) params = { 'action': 'upload', 'format': 'json', 'stash': 1, 'offset': 0, 'filename': filename, 'filesize': content_size, 'token': image.get_token('edit'), } if ignorewarnings: params['ignorewarnings'] = 'true' sleeper = self.sleepers.make() offset = 0 for chunk in read_in_chunks(file, self.chunk_size): while True: data = self.raw_call('api', params, files={'chunk': chunk}) info = json.loads(data) if self.handle_api_result(info, kwargs=params, sleeper=sleeper): response = info.get('upload', {}) break offset += chunk.tell() chunk.close() log.debug('%s: Uploaded %d of %d bytes', filename, offset, content_size) params['filekey'] = response['filekey'] if response['result'] == 'Continue': params['offset'] = response['offset'] elif response['result'] == 'Success': file.close() break else: # Some kind or error or warning occurred. In any case, we do not # get the parameters we need to continue, so we should return # the response now. file.close() return response del params['action'] del params['stash'] del params['offset'] params['comment'] = comment params['text'] = text return self.post('upload', **params) def parse(self, text=None, title=None, page=None, prop=None, redirects=False, mobileformat=False): """Parses the given content and returns parser output. Args: text (str): Text to parse. title (str): Title of page the text belongs to. page (str): The name of a page to parse. Cannot be used together with text and title. prop (str): Which pieces of information to get. Multiple alues should be separated using the pipe (`|`) character. redirects (bool): Resolve the redirect, if the given `page` is a redirect. Defaults to `False`. mobileformat (bool): Return parse output in a format suitable for mobile devices. Defaults to `False`. Returns: The parse output as generated by MediaWiki. """ kwargs = {} if text is not None: kwargs['text'] = text if title is not None: kwargs['title'] = title if page is not None: kwargs['page'] = page if prop is not None: kwargs['prop'] = prop if redirects: kwargs['redirects'] = '1' if mobileformat: kwargs['mobileformat'] = '1' result = self.post('parse', **kwargs) return result['parse'] # def block(self): TODO? # def unblock: TODO? # def import: TODO? def patrol(self, rcid=None, revid=None, tags=None): """Patrol a page or a revision. Either ``rcid`` or ``revid`` (but not both) must be given. The ``rcid`` and ``revid`` arguments may be obtained using the :meth:`Site.recentchanges` function. API doc: https://www.mediawiki.org/wiki/API:Patrol Args: rcid (int): The recentchanges ID to patrol. revid (int): The revision ID to patrol. tags (str): Change tags to apply to the entry in the patrol log. Multiple tags can be given, by separating them with the pipe (|) character. Returns: Dict[str, Any]: The API response as a dictionary containing: - **rcid** (int): The recentchanges id. - **nsid** (int): The namespace id. - **title** (str): The page title. Raises: errors.APIError: The MediaWiki API returned an error. Notes: - ``autopatrol`` rights are required in order to use this function. - ``revid`` requires at least MediaWiki 1.22. - ``tags`` requires at least MediaWiki 1.27. """ if self.require(1, 17, raise_error=False): token = self.get_token('patrol') else: # For MediaWiki versions earlier than 1.17, the patrol token is the same the # edit token. token = self.get_token('edit') result = self.post('patrol', rcid=rcid, revid=revid, tags=tags, token=token) return result['patrol'] # Lists def allpages(self, start=None, prefix=None, namespace='0', filterredir='all', minsize=None, maxsize=None, prtype=None, prlevel=None, limit=None, dir='ascending', filterlanglinks='all', generator=True, end=None, max_items=None, api_chunk_size=None): """Retrieve all pages on the wiki as a generator.""" (max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size) pfx = listing.List.get_prefix('ap', generator) kwargs = dict(listing.List.generate_kwargs( pfx, ('from', start), ('to', end), prefix=prefix, minsize=minsize, maxsize=maxsize, prtype=prtype, prlevel=prlevel, namespace=namespace, filterredir=filterredir, dir=dir, filterlanglinks=filterlanglinks, )) return listing.List.get_list(generator)(self, 'allpages', 'ap', max_items=max_items, api_chunk_size=api_chunk_size, return_values='title', **kwargs) def allimages(self, start=None, prefix=None, minsize=None, maxsize=None, limit=None, dir='ascending', sha1=None, sha1base36=None, generator=True, end=None, max_items=None, api_chunk_size=None): """Retrieve all images on the wiki as a generator.""" (max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size) pfx = listing.List.get_prefix('ai', generator) kwargs = dict(listing.List.generate_kwargs( pfx, ('from', start), ('to', end), prefix=prefix, minsize=minsize, maxsize=maxsize, dir=dir, sha1=sha1, sha1base36=sha1base36, )) return listing.List.get_list(generator)(self, 'allimages', 'ai', max_items=max_items, api_chunk_size=api_chunk_size, return_values='timestamp|url', **kwargs) def alllinks(self, start=None, prefix=None, unique=False, prop='title', namespace='0', limit=None, generator=True, end=None, max_items=None, api_chunk_size=None): """Retrieve a list of all links on the wiki as a generator.""" (max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size) pfx = listing.List.get_prefix('al', generator) kwargs = dict(listing.List.generate_kwargs(pfx, ('from', start), ('to', end), prefix=prefix, prop=prop, namespace=namespace)) if unique: kwargs[pfx + 'unique'] = '1' return listing.List.get_list(generator)(self, 'alllinks', 'al', max_items=max_items, api_chunk_size=api_chunk_size, return_values='title', **kwargs) def allcategories(self, start=None, prefix=None, dir='ascending', limit=None, generator=True, end=None, max_items=None, api_chunk_size=None): """Retrieve all categories on the wiki as a generator.""" (max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size) pfx = listing.List.get_prefix('ac', generator) kwargs = dict(listing.List.generate_kwargs(pfx, ('from', start), ('to', end), prefix=prefix, dir=dir)) return listing.List.get_list(generator)(self, 'allcategories', 'ac', max_items=max_items, api_chunk_size=api_chunk_size, **kwargs) def allusers(self, start=None, prefix=None, group=None, prop=None, limit=None, witheditsonly=False, activeusers=False, rights=None, end=None, max_items=None, api_chunk_size=None): """Retrieve all users on the wiki as a generator.""" (max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size) kwargs = dict(listing.List.generate_kwargs('au', ('from', start), ('to', end), prefix=prefix, group=group, prop=prop, rights=rights, witheditsonly=witheditsonly, activeusers=activeusers)) return listing.List(self, 'allusers', 'au', max_items=max_items, api_chunk_size=api_chunk_size, **kwargs) def blocks(self, start=None, end=None, dir='older', ids=None, users=None, limit=None, prop='id|user|by|timestamp|expiry|reason|flags', max_items=None, api_chunk_size=None): """Retrieve blocks as a generator. API doc: https://www.mediawiki.org/wiki/API:Blocks Returns: mwclient.listings.List: Generator yielding dicts, each dict containing: - user: The username or IP address of the user - id: The ID of the block - timestamp: When the block was added - expiry: When the block runs out (infinity for indefinite blocks) - reason: The reason they are blocked - allowusertalk: Key is present (empty string) if the user is allowed to edit their user talk page - by: the administrator who blocked the user - nocreate: key is present (empty string) if the user's ability to create accounts has been disabled. See Also: When using the ``users`` filter to search for blocked users, only one block per given user will be returned. If you want to retrieve the entire block log for a specific user, you can use the :meth:`Site.logevents` method with ``type=block`` and ``title='User:JohnDoe'``. """ # TODO: Fix. Fix what? (max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size) kwargs = dict(listing.List.generate_kwargs('bk', start=start, end=end, dir=dir, ids=ids, users=users, prop=prop)) return listing.List(self, 'blocks', 'bk', max_items=max_items, api_chunk_size=api_chunk_size, **kwargs) def deletedrevisions(self, start=None, end=None, dir='older', namespace=None, limit=None, prop='user|comment', max_items=None, api_chunk_size=None): # TODO: Fix (max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size) kwargs = dict(listing.List.generate_kwargs('dr', start=start, end=end, dir=dir, namespace=namespace, prop=prop)) return listing.List(self, 'deletedrevs', 'dr', max_items=max_items, api_chunk_size=api_chunk_size, **kwargs) def exturlusage(self, query, prop=None, protocol='http', namespace=None, limit=None, max_items=None, api_chunk_size=None): r"""Retrieve the list of pages that link to a particular domain or URL, as a generator. This API call mirrors the Special:LinkSearch function on-wiki. Query can be a domain like 'bbc.co.uk'. Wildcards can be used, e.g. '\*.bbc.co.uk'. Alternatively, a query can contain a full domain name and some or all of a URL: e.g. '\*.wikipedia.org/wiki/\*' See for details. Returns: mwclient.listings.List: Generator yielding dicts, each dict containing: - url: The URL linked to. - ns: Namespace of the wiki page - pageid: The ID of the wiki page - title: The page title. """ (max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size) kwargs = dict(listing.List.generate_kwargs('eu', query=query, prop=prop, protocol=protocol, namespace=namespace)) return listing.List(self, 'exturlusage', 'eu', max_items=max_items, api_chunk_size=api_chunk_size, **kwargs) def logevents(self, type=None, prop=None, start=None, end=None, dir='older', user=None, title=None, limit=None, action=None, max_items=None, api_chunk_size=None): """Retrieve logevents as a generator.""" (max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size) kwargs = dict(listing.List.generate_kwargs('le', prop=prop, type=type, start=start, end=end, dir=dir, user=user, title=title, action=action)) return listing.List(self, 'logevents', 'le', max_items=max_items, api_chunk_size=api_chunk_size, **kwargs) def checkuserlog(self, user=None, target=None, limit=None, dir='older', start=None, end=None, max_items=None, api_chunk_size=10): """Retrieve checkuserlog items as a generator.""" (max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size) kwargs = dict(listing.List.generate_kwargs('cul', target=target, start=start, end=end, dir=dir, user=user)) return listing.NestedList('entries', self, 'checkuserlog', 'cul', max_items=max_items, api_chunk_size=api_chunk_size, **kwargs) # def protectedtitles requires 1.15 def random(self, namespace, limit=None, max_items=None, api_chunk_size=20): """Retrieve a generator of random pages from a particular namespace. max_items specifies the number of random articles retrieved. api_chunk_size and limit (deprecated) specify the API chunk size. namespace is a namespace identifier integer. Generator contains dictionary with namespace, page ID and title. """ (max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size) kwargs = dict(listing.List.generate_kwargs('rn', namespace=namespace)) return listing.List(self, 'random', 'rn', max_items=max_items, api_chunk_size=api_chunk_size, **kwargs) def recentchanges(self, start=None, end=None, dir='older', namespace=None, prop=None, show=None, limit=None, type=None, toponly=None, max_items=None, api_chunk_size=None): """List recent changes to the wiki, à la Special:Recentchanges. """ (max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size) kwargs = dict(listing.List.generate_kwargs('rc', start=start, end=end, dir=dir, namespace=namespace, prop=prop, show=show, type=type, toponly='1' if toponly else None)) return listing.List(self, 'recentchanges', 'rc', max_items=max_items, api_chunk_size=api_chunk_size, **kwargs) def revisions(self, revids, prop='ids|timestamp|flags|comment|user'): """Get data about a list of revisions. See also the `Page.revisions()` method. API doc: https://www.mediawiki.org/wiki/API:Revisions Example: Get revision text for two revisions: >>> for revision in site.revisions([689697696, 689816909], prop='content'): ... print(revision['*']) Args: revids (list): A list of (max 50) revisions. prop (str): Which properties to get for each revision. Returns: A list of revisions """ kwargs = { 'prop': 'revisions', 'rvprop': prop, 'revids': '|'.join(map(str, revids)) } revisions = [] pages = self.get('query', **kwargs).get('query', {}).get('pages', {}).values() for page in pages: for revision in page.get('revisions', ()): revision['pageid'] = page.get('pageid') revision['pagetitle'] = page.get('title') revision['timestamp'] = parse_timestamp(revision['timestamp']) revisions.append(revision) return revisions def search(self, search, namespace='0', what=None, redirects=False, limit=None, max_items=None, api_chunk_size=None): """Perform a full text search. API doc: https://www.mediawiki.org/wiki/API:Search Example: >>> for result in site.search('prefix:Template:Citation/'): ... print(result.get('title')) Args: search (str): The query string namespace (int): The namespace to search (default: 0) what (str): Search scope: 'text' for fulltext, or 'title' for titles only. Depending on the search backend, both options may not be available. For instance `CirrusSearch `_ doesn't support 'title', but instead provides an "intitle:" query string filter. redirects (bool): Include redirect pages in the search (option removed in MediaWiki 1.23). Returns: mwclient.listings.List: Search results iterator """ (max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size) kwargs = dict(listing.List.generate_kwargs('sr', search=search, namespace=namespace, what=what)) if redirects: kwargs['srredirects'] = '1' return listing.List(self, 'search', 'sr', max_items=max_items, api_chunk_size=api_chunk_size, **kwargs) def usercontributions(self, user, start=None, end=None, dir='older', namespace=None, prop=None, show=None, limit=None, uselang=None, max_items=None, api_chunk_size=None): """ List the contributions made by a given user to the wiki. API doc: https://www.mediawiki.org/wiki/API:Usercontribs """ (max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size) kwargs = dict(listing.List.generate_kwargs('uc', user=user, start=start, end=end, dir=dir, namespace=namespace, prop=prop, show=show)) return listing.List(self, 'usercontribs', 'uc', max_items=max_items, api_chunk_size=api_chunk_size, uselang=uselang, **kwargs) def users(self, users, prop='blockinfo|groups|editcount'): """ Get information about a list of users. API doc: https://www.mediawiki.org/wiki/API:Users """ return listing.List(self, 'users', 'us', ususers='|'.join(users), usprop=prop) def watchlist(self, allrev=False, start=None, end=None, namespace=None, dir='older', prop=None, show=None, limit=None, max_items=None, api_chunk_size=None): """ List the pages on the current user's watchlist. API doc: https://www.mediawiki.org/wiki/API:Watchlist """ (max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size) kwargs = dict(listing.List.generate_kwargs('wl', start=start, end=end, namespace=namespace, dir=dir, prop=prop, show=show)) if allrev: kwargs['wlallrev'] = '1' return listing.List(self, 'watchlist', 'wl', max_items=max_items, api_chunk_size=api_chunk_size, **kwargs) def expandtemplates(self, text, title=None, generatexml=False): """ Takes wikitext (text) and expands templates. API doc: https://www.mediawiki.org/wiki/API:Expandtemplates Args: text (str): Wikitext to convert. title (str): Title of the page. generatexml (bool): Generate the XML parse tree. Defaults to `False`. """ kwargs = {} if title is not None: kwargs['title'] = title if generatexml: # FIXME: Deprecated and replaced by `prop=parsetree`. kwargs['generatexml'] = '1' result = self.post('expandtemplates', text=text, **kwargs) if generatexml: return result['expandtemplates']['*'], result['parsetree']['*'] else: return result['expandtemplates']['*'] def ask(self, query, title=None): """ Ask a query against Semantic MediaWiki. API doc: https://semantic-mediawiki.org/wiki/Ask_API Args: query (str): The SMW query to be executed. Returns: Generator for retrieving all search results, with each answer as a dictionary. If the query is invalid, an APIError is raised. A valid query with zero results will not raise any error. Examples: >>> query = "[[Category:my cat]]|[[Has name::a name]]|?Has property" >>> for answer in site.ask(query): >>> for title, data in answer.items() >>> print(title) >>> print(data) """ kwargs = {} if title is None: kwargs['title'] = title offset = 0 while offset is not None: results = self.raw_api('ask', query='{query}|offset={offset}'.format( query=query, offset=offset), http_method='GET', **kwargs) self.handle_api_result(results) # raises APIError on error offset = results.get('query-continue-offset') answers = results['query'].get('results', []) if isinstance(answers, dict): # In older versions of Semantic MediaWiki (at least until 2.3.0) # a list was returned. In newer versions an object is returned # with the page title as key. answers = [answer for answer in answers.values()] for answer in answers: yield answer ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/mwclient/errors.py0000644000175100001770000000531714656212142016541 0ustar00runnerdockerclass MwClientError(RuntimeError): pass class MediaWikiVersionError(MwClientError): pass class APIDisabledError(MwClientError): pass class MaximumRetriesExceeded(MwClientError): pass class APIError(MwClientError): def __init__(self, code, info, kwargs): self.code = code self.info = info super(APIError, self).__init__(code, info, kwargs) class InsufficientPermission(MwClientError): pass class UserBlocked(InsufficientPermission): pass class EditError(MwClientError): pass class ProtectedPageError(EditError, InsufficientPermission): def __init__(self, page, code=None, info=None): self.page = page self.code = code self.info = info def __str__(self): if self.info is not None: return self.info return 'You do not have the "edit" right.' class FileExists(EditError): """ 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): def __init__(self, site, code, info): super(LoginError, self).__init__( site, {'result': code, 'reason': info} # For backwards-compability ) self.site = site self.code = code self.info = info def __str__(self): return self.info class OAuthAuthorizationError(LoginError): pass class AssertUserFailedError(MwClientError): def __init__(self): super(AssertUserFailedError, self).__init__(( 'By default, mwclient protects you from accidentally editing ' 'without being logged in. If you actually want to edit without ' 'logging in, you can set force_login on the Site object to False.' )) def __str__(self): return self.args[0] class EmailError(MwClientError): pass class NoSpecifiedEmail(EmailError): pass class NoWriteApi(MwClientError): pass class InvalidResponse(MwClientError): def __init__(self, response_text=None): super(InvalidResponse, self).__init__(( 'Did not get a valid JSON response from the server. Check that ' 'you used the correct hostname. If you did, the server might ' 'be wrongly configured or experiencing temporary problems.'), response_text ) self.response_text = response_text def __str__(self): return self.args[0] class InvalidPageTitle(MwClientError): pass ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/mwclient/image.py0000644000175100001770000000654214656212142016310 0ustar00runnerdockerfrom mwclient.util import handle_limit import mwclient.listing import mwclient.page class Image(mwclient.page.Page): def __init__(self, site, name, info=None): super(Image, self).__init__( site, name, info, extra_properties={ 'imageinfo': ( ('iiprop', 'timestamp|user|comment|url|size|sha1|metadata|mime|archivename'), ) } ) self.imagerepository = self._info.get('imagerepository', '') self.imageinfo = self._info.get('imageinfo', ({}, ))[0] def imagehistory(self): """ Get file revision info for the given file. API doc: https://www.mediawiki.org/wiki/API:Imageinfo """ return mwclient.listing.PageProperty( self, 'imageinfo', 'ii', iiprop='timestamp|user|comment|url|size|sha1|metadata|mime|archivename' ) def imageusage(self, namespace=None, filterredir='all', redirect=False, limit=None, generator=True, max_items=None, api_chunk_size=None): """ List pages that use the given file. API doc: https://www.mediawiki.org/wiki/API:Imageusage """ prefix = mwclient.listing.List.get_prefix('iu', generator) kwargs = dict(mwclient.listing.List.generate_kwargs( prefix, title=self.name, namespace=namespace, filterredir=filterredir )) (max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size) if redirect: kwargs['%sredirect' % prefix] = '1' return mwclient.listing.List.get_list(generator)( self.site, 'imageusage', 'iu', max_items=max_items, api_chunk_size=api_chunk_size, return_values='title', **kwargs ) def duplicatefiles(self, limit=None, max_items=None, api_chunk_size=None): """ List duplicates of the current file. API doc: https://www.mediawiki.org/wiki/API:Duplicatefiles limit sets a hard cap on the total number of results, it does not only specify the API chunk size. """ (max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size) return mwclient.listing.PageProperty( self, 'duplicatefiles', 'df', max_items=max_items, api_chunk_size=api_chunk_size ) def download(self, destination=None): """ Download the file. If `destination` is given, the file will be written directly to the stream. Otherwise the file content will be stored in memory and returned (with the risk of running out of memory for large files). Recommended usage: >>> with open(filename, 'wb') as fd: ... image.download(fd) Args: destination (file object): Destination file """ url = self.imageinfo['url'] if destination is not None: res = self.site.connection.get(url, stream=True) for chunk in res.iter_content(1024): destination.write(chunk) else: return self.site.connection.get(url).content def __repr__(self): return "<%s object '%s' for %s>" % ( self.__class__.__name__, self.name, self.site ) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/mwclient/listing.py0000644000175100001770000002643014656212142016675 0ustar00runnerdockerfrom mwclient.util import parse_timestamp, handle_limit import mwclient.page import mwclient.image class List: """Base class for lazy iteration over api response content This is a class providing lazy iteration. This means that the content is loaded in chunks as long as the response hints at continuing content. max_items limits the total number of items that will be yielded by this iterator. api_chunk_size sets the number of items that will be requested from the wiki per API call (this iterator itself always yields one item at a time). limit does the same as api_chunk_size for backward compatibility, but is deprecated due to its misleading name. """ def __init__(self, site, list_name, prefix, limit=None, return_values=None, max_items=None, api_chunk_size=None, *args, **kwargs): self.site = site self.list_name = list_name self.generator = 'list' self.prefix = prefix kwargs.update(args) self.args = kwargs (max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size) # for efficiency, if max_items is set and api_chunk_size is not, # set the chunk size to max_items so we don't retrieve # unneeded extra items (so long as it's below API limit) api_limit = site.api_limit api_chunk_size = api_chunk_size or min(max_items or api_limit, api_limit) self.args[self.prefix + 'limit'] = str(api_chunk_size) self.count = 0 self.max_items = max_items self._iter = iter(range(0)) self.last = False self.result_member = list_name self.return_values = return_values def __iter__(self): return self def __next__(self): if self.max_items is not None: if self.count >= self.max_items: raise StopIteration # For filered lists, we might have to do several requests # to get the next element due to miser mode. # See: https://github.com/mwclient/mwclient/issues/194 while True: try: item = next(self._iter) if item is not None: break except StopIteration: if self.last: raise self.load_chunk() self.count += 1 if 'timestamp' in item: item['timestamp'] = parse_timestamp(item['timestamp']) if isinstance(self, GeneratorList): return item if type(self.return_values) is tuple: return tuple((item[i] for i in self.return_values)) if self.return_values is not None: return item[self.return_values] return item def load_chunk(self): """Query a new chunk of data If the query is empty, `raise StopIteration`. Else, update the iterator accordingly. If 'continue' is in the response, it is added to `self.args` (new style continuation, added in MediaWiki 1.21). If not, but 'query-continue' is in the response, query its item called `self.list_name` and add this to `self.args` (old style continuation). Else, set `self.last` to True. """ data = self.site.get( 'query', (self.generator, self.list_name), *[(str(k), v) for k, v in self.args.items()] ) if not data: # Non existent page raise StopIteration # Process response if not empty. # See: https://github.com/mwclient/mwclient/issues/194 if 'query' in data: self.set_iter(data) if data.get('continue'): # New style continuation, added in MediaWiki 1.21 self.args.update(data['continue']) elif self.list_name in data.get('query-continue', ()): # Old style continuation self.args.update(data['query-continue'][self.list_name]) else: self.last = True def set_iter(self, data): """Set `self._iter` to the API response `data`.""" if self.result_member not in data['query']: self._iter = iter(range(0)) elif type(data['query'][self.result_member]) is list: self._iter = iter(data['query'][self.result_member]) else: self._iter = iter(data['query'][self.result_member].values()) def __repr__(self): return "<%s object '%s' for %s>" % ( self.__class__.__name__, self.list_name, self.site ) @staticmethod def generate_kwargs(_prefix, *args, **kwargs): kwargs.update(args) for key, value in kwargs.items(): if value is not None and value is not False: yield _prefix + key, value @staticmethod def get_prefix(prefix, generator=False): return ('g' if generator else '') + prefix @staticmethod def get_list(generator=False): return GeneratorList if generator else List class NestedList(List): def __init__(self, nested_param, *args, **kwargs): super(NestedList, self).__init__(*args, **kwargs) self.nested_param = nested_param def set_iter(self, data): self._iter = iter(data['query'][self.result_member][self.nested_param]) class GeneratorList(List): """Lazy-loaded list of Page, Image or Category objects While the standard List class yields raw response data (optionally filtered based on the value of List.return_values), this subclass turns the data into Page, Image or Category objects. """ def __init__(self, site, list_name, prefix, *args, **kwargs): super(GeneratorList, self).__init__(site, list_name, prefix, *args, **kwargs) self.args['g' + self.prefix + 'limit'] = self.args[self.prefix + 'limit'] del self.args[self.prefix + 'limit'] self.generator = 'generator' self.args['prop'] = 'info|imageinfo' self.args['inprop'] = 'protection' self.result_member = 'pages' self.page_class = mwclient.page.Page def __next__(self): info = super(GeneratorList, self).__next__() if info['ns'] == 14: return Category(self.site, '', info) if info['ns'] == 6: return mwclient.image.Image(self.site, '', info) return mwclient.page.Page(self.site, '', info) def load_chunk(self): # Put this here so that the constructor does not fail # on uninitialized sites self.args['iiprop'] = 'timestamp|user|comment|url|size|sha1|metadata|archivename' return super(GeneratorList, self).load_chunk() class Category(mwclient.page.Page, GeneratorList): def __init__(self, site, name, info=None, namespace=None): mwclient.page.Page.__init__(self, site, name, info) kwargs = {} kwargs['gcmtitle'] = self.name if namespace: kwargs['gcmnamespace'] = namespace GeneratorList.__init__(self, site, 'categorymembers', 'cm', **kwargs) def __repr__(self): return "<%s object '%s' for %s>" % ( self.__class__.__name__, self.name, self.site ) def members(self, prop='ids|title', namespace=None, sort='sortkey', dir='asc', start=None, end=None, generator=True): prefix = self.get_prefix('cm', generator) kwargs = dict(self.generate_kwargs(prefix, prop=prop, namespace=namespace, sort=sort, dir=dir, start=start, end=end, title=self.name)) return self.get_list(generator)(self.site, 'categorymembers', 'cm', **kwargs) class PageList(GeneratorList): def __init__(self, site, prefix=None, start=None, namespace=0, redirects='all', end=None): self.namespace = namespace kwargs = {} if prefix: kwargs['gapprefix'] = prefix if start: kwargs['gapfrom'] = start if end: kwargs['gapto'] = end super(PageList, self).__init__(site, 'allpages', 'ap', gapnamespace=str(namespace), gapfilterredir=redirects, **kwargs) def __getitem__(self, name): return self.get(name, None) def get(self, name, info=()): """Return the page of name `name` as an object. If self.namespace is not zero, use {namespace}:{name} as the page name, otherwise guess the namespace from the name using `self.guess_namespace`. Returns: One of Category, Image or Page (default), according to namespace. """ if self.namespace != 0: full_page_name = u"{namespace}:{name}".format( namespace=self.site.namespaces[self.namespace], name=name, ) namespace = self.namespace else: full_page_name = name try: namespace = self.guess_namespace(name) except AttributeError: # raised when `namespace` doesn't have a `startswith` attribute namespace = 0 cls = { 14: Category, 6: mwclient.image.Image, }.get(namespace, mwclient.page.Page) return cls(self.site, full_page_name, info) def guess_namespace(self, name): """Guess the namespace from name If name starts with any of the site's namespaces' names or default_namespaces, use that. Else, return zero. Args: name (str): The pagename as a string (having `.startswith`) Returns: The id of the guessed namespace or zero. """ for ns in self.site.namespaces: if ns == 0: continue namespace = '%s:' % self.site.namespaces[ns].replace(' ', '_') if name.startswith(namespace): return ns elif ns in self.site.default_namespaces: namespace = '%s:' % self.site.default_namespaces[ns].replace(' ', '_') if name.startswith(namespace): return ns return 0 class PageProperty(List): def __init__(self, page, prop, prefix, *args, **kwargs): super(PageProperty, self).__init__(page.site, prop, prefix, titles=page.name, *args, **kwargs) self.page = page self.generator = 'prop' def set_iter(self, data): for page in data['query']['pages'].values(): if page['title'] == self.page.name: self._iter = iter(page.get(self.list_name, ())) return raise StopIteration class PagePropertyGenerator(GeneratorList): def __init__(self, page, prop, prefix, *args, **kwargs): super(PagePropertyGenerator, self).__init__(page.site, prop, prefix, titles=page.name, *args, **kwargs) self.page = page class RevisionsIterator(PageProperty): def load_chunk(self): if 'rvstartid' in self.args and 'rvstart' in self.args: del self.args['rvstart'] return super(RevisionsIterator, self).load_chunk() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/mwclient/page.py0000644000175100001770000005231314656212142016137 0ustar00runnerdockerimport time from mwclient.util import parse_timestamp, handle_limit import mwclient.listing import mwclient.errors class Page: def __init__(self, site, name, info=None, extra_properties=None): if type(name) is type(self): self.__dict__.update(name.__dict__) return self.site = site self.name = name self._textcache = {} if not info: if extra_properties: prop = 'info|' + '|'.join(extra_properties.keys()) extra_props = [] for extra_prop in extra_properties.values(): extra_props.extend(extra_prop) else: prop = 'info' extra_props = () if type(name) is int: info = self.site.get('query', prop=prop, pageids=name, inprop='protection', *extra_props) else: info = self.site.get('query', prop=prop, titles=name, inprop='protection', *extra_props) info = next(iter(info['query']['pages'].values())) self._info = info if 'invalid' in info: raise mwclient.errors.InvalidPageTitle(info.get('invalidreason')) self.namespace = info.get('ns', 0) self.name = info.get('title', '') if self.namespace: self.page_title = self.strip_namespace(self.name) else: self.page_title = self.name self.base_title = self.page_title.split('/')[0] self.base_name = self.name.split('/')[0] self.touched = parse_timestamp(info.get('touched')) self.revision = info.get('lastrevid', 0) self.exists = 'missing' not in info self.length = info.get('length') self.protection = { i['type']: (i['level'], i.get('expiry')) for i in info.get('protection', ()) if i } self.redirect = 'redirect' in info self.pageid = info.get('pageid', None) self.contentmodel = info.get('contentmodel', None) self.pagelanguage = info.get('pagelanguage', None) self.restrictiontypes = info.get('restrictiontypes', None) self.last_rev_time = None self.edit_time = None def redirects_to(self): """ Get the redirect target page, or None if the page is not a redirect.""" info = self.site.get('query', prop='pageprops', titles=self.name, redirects='') if 'redirects' in info['query']: for page in info['query']['redirects']: if page['from'] == self.name: return Page(self.site, page['to']) return None else: return None def resolve_redirect(self): """ Get the redirect target page, or the current page if its not a redirect.""" target_page = self.redirects_to() if target_page is None: return self else: return target_page def __repr__(self): return "<%s object '%s' for %s>" % ( self.__class__.__name__, self.name, self.site ) @staticmethod def strip_namespace(title): if title[0] == ':': title = title[1:] return title[title.find(':') + 1:] @staticmethod def normalize_title(title): # TODO: Make site dependent title = title.strip() if title[0] == ':': title = title[1:] title = title[0].upper() + title[1:] title = title.replace(' ', '_') return title def can(self, action): """Check if the current user has the right to carry out some action with the current page. Example: >>> page.can('edit') True """ level = self.protection.get(action, (action,))[0] if level == 'sysop': level = 'editprotected' return level in self.site.rights def get_token(self, type, force=False): return self.site.get_token(type, force, title=self.name) def text(self, section=None, expandtemplates=False, cache=True, slot='main'): """Get the current wikitext of the page, or of a specific section. If the page does not exist, an empty string is returned. By default, results will be cached and if you call text() again with the same section and expandtemplates the result will come from the cache. The cache is stored on the instance, so it lives as long as the instance does. Args: section (int): Section number, to only get text from a single section. expandtemplates (bool): Expand templates (default: `False`) cache (bool): Use in-memory caching (default: `True`) """ if not self.can('read'): raise mwclient.errors.InsufficientPermission(self) if not self.exists: return '' if section is not None: section = str(section) key = hash((section, expandtemplates)) if cache and key in self._textcache: return self._textcache[key] # we set api_chunk_size not max_items because otherwise revisions' # default api_chunk_size of 50 gets used and we get 50 revisions; # no need to set max_items as well as we only iterate one time revs = self.revisions(prop='content|timestamp', api_chunk_size=1, section=section, slots=slot) try: rev = next(revs) if 'slots' in rev: text = rev['slots'][slot]['*'] else: text = rev['*'] self.last_rev_time = rev['timestamp'] except StopIteration: text = '' self.last_rev_time = None if not expandtemplates: self.edit_time = time.gmtime() else: # The 'rvexpandtemplates' option was removed in MediaWiki 1.32, so we have to # make an extra API call, see https://github.com/mwclient/mwclient/issues/214 text = self.site.expandtemplates(text) if cache: self._textcache[key] = text return text def save(self, *args, **kwargs): """Alias for edit, for maintaining backwards compatibility.""" return self.edit(*args, **kwargs) def edit(self, text, summary='', minor=False, bot=True, section=None, **kwargs): """Update the text of a section or the whole page by performing an edit operation. """ return self._edit(summary, minor, bot, section, text=text, **kwargs) def append(self, text, summary='', minor=False, bot=True, section=None, **kwargs): """Append text to a section or the whole page by performing an edit operation. """ return self._edit(summary, minor, bot, section, appendtext=text, **kwargs) def prepend(self, text, summary='', minor=False, bot=True, section=None, **kwargs): """Prepend text to a section or the whole page by performing an edit operation. """ return self._edit(summary, minor, bot, section, prependtext=text, **kwargs) def _edit(self, summary, minor, bot, section, **kwargs): if not self.site.logged_in and self.site.force_login: raise mwclient.errors.AssertUserFailedError() if self.site.blocked: raise mwclient.errors.UserBlocked(self.site.blocked) if not self.can('edit'): raise mwclient.errors.ProtectedPageError(self) data = {} if minor: data['minor'] = '1' if not minor: data['notminor'] = '1' if self.last_rev_time: data['basetimestamp'] = time.strftime('%Y%m%d%H%M%S', self.last_rev_time) if self.edit_time: data['starttimestamp'] = time.strftime('%Y%m%d%H%M%S', self.edit_time) if bot: data['bot'] = '1' if section is not None: data['section'] = section data.update(kwargs) if self.site.force_login: data['assert'] = 'user' def do_edit(): result = self.site.post('edit', title=self.name, summary=summary, token=self.get_token('edit'), **data) if result['edit'].get('result').lower() == 'failure': raise mwclient.errors.EditError(self, result['edit']) return result try: result = do_edit() except mwclient.errors.APIError as e: if e.code == 'badtoken': # Retry, but only once to avoid an infinite loop self.get_token('edit', force=True) try: result = do_edit() except mwclient.errors.APIError as e: self.handle_edit_error(e, summary) else: self.handle_edit_error(e, summary) # 'newtimestamp' is not included if no change was made if 'newtimestamp' in result['edit'].keys(): self.last_rev_time = parse_timestamp(result['edit'].get('newtimestamp')) # Workaround for https://phabricator.wikimedia.org/T211233 for cookie in self.site.connection.cookies: if 'PostEditRevision' in cookie.name: self.site.connection.cookies.clear(cookie.domain, cookie.path, cookie.name) # clear the page text cache self._textcache = {} return result['edit'] def handle_edit_error(self, e, summary): if e.code == 'editconflict': raise mwclient.errors.EditError(self, summary, e.info) elif e.code in {'protectedtitle', 'cantcreate', 'cantcreate-anon', 'noimageredirect-anon', 'noimageredirect', 'noedit-anon', 'noedit', 'protectedpage', 'cascadeprotected', 'customcssjsprotected', 'protectednamespace-interface', 'protectednamespace'}: raise mwclient.errors.ProtectedPageError(self, e.code, e.info) elif e.code == 'assertuserfailed': raise mwclient.errors.AssertUserFailedError() else: raise e def touch(self): """Perform a "null edit" on the page to update the wiki's cached data of it. This is useful in contrast to purge when needing to update stored data on a wiki, for example Semantic MediaWiki properties or Cargo table values, since purge only forces update of a page's displayed values and not its store. """ if not self.exists: return self.append('') def move(self, new_title, reason='', move_talk=True, no_redirect=False, move_subpages=False, ignore_warnings=False): """Move (rename) page to new_title. If user account is an administrator, specify no_redirect as True to not leave a redirect. If user does not have permission to move page, an InsufficientPermission exception is raised. """ if not self.can('move'): raise mwclient.errors.InsufficientPermission(self) data = {} if move_talk: data['movetalk'] = '1' if no_redirect: data['noredirect'] = '1' if move_subpages: data['movesubpages'] = '1' if ignore_warnings: data['ignorewarnings'] = '1' result = self.site.post('move', ('from', self.name), to=new_title, token=self.get_token('move'), reason=reason, **data) return result['move'] def delete(self, reason='', watch=False, unwatch=False, oldimage=False): """Delete page. If user does not have permission to delete page, an InsufficientPermission exception is raised. """ if not self.can('delete'): raise mwclient.errors.InsufficientPermission(self) data = {} if watch: data['watch'] = '1' if unwatch: data['unwatch'] = '1' if oldimage: data['oldimage'] = oldimage result = self.site.post('delete', title=self.name, token=self.get_token('delete'), reason=reason, **data) return result['delete'] def purge(self): """Purge server-side cache of page. This will re-render templates and other dynamic content. """ self.site.post('purge', titles=self.name) # def watch: requires 1.14 # Properties def backlinks(self, namespace=None, filterredir='all', redirect=False, limit=None, generator=True, max_items=None, api_chunk_size=None): """List pages that link to the current page, similar to Special:Whatlinkshere. API doc: https://www.mediawiki.org/wiki/API:Backlinks """ (max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size) prefix = mwclient.listing.List.get_prefix('bl', generator) kwargs = dict(mwclient.listing.List.generate_kwargs( prefix, namespace=namespace, filterredir=filterredir, )) if redirect: kwargs['%sredirect' % prefix] = '1' kwargs[prefix + 'title'] = self.name return mwclient.listing.List.get_list(generator)( self.site, 'backlinks', 'bl', max_items=max_items, api_chunk_size=api_chunk_size, return_values='title', **kwargs ) def categories(self, generator=True, show=None): """List categories used on the current page. API doc: https://www.mediawiki.org/wiki/API:Categories Args: generator (bool): Return generator (Default: True) show (str): Set to 'hidden' to only return hidden categories or '!hidden' to only return non-hidden ones. Returns: mwclient.listings.PagePropertyGenerator """ prefix = mwclient.listing.List.get_prefix('cl', generator) kwargs = dict(mwclient.listing.List.generate_kwargs( prefix, show=show )) if generator: return mwclient.listing.PagePropertyGenerator( self, 'categories', 'cl', **kwargs ) else: # TODO: return sortkey if wanted return mwclient.listing.PageProperty( self, 'categories', 'cl', return_values='title', **kwargs ) def embeddedin(self, namespace=None, filterredir='all', limit=None, generator=True, max_items=None, api_chunk_size=None): """List pages that transclude the current page. API doc: https://www.mediawiki.org/wiki/API:Embeddedin Args: namespace (int): Restricts search to a given namespace (Default: None) filterredir (str): How to filter redirects, either 'all' (default), 'redirects' or 'nonredirects'. limit (int): The API request chunk size (deprecated) generator (bool): Return generator (Default: True) max_items(int): The maximum number of pages to yield api_chunk_size(int): The API request chunk size Returns: mwclient.listings.List: Page iterator """ (max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size) prefix = mwclient.listing.List.get_prefix('ei', generator) kwargs = dict(mwclient.listing.List.generate_kwargs(prefix, namespace=namespace, filterredir=filterredir)) kwargs[prefix + 'title'] = self.name return mwclient.listing.List.get_list(generator)( self.site, 'embeddedin', 'ei', max_items=max_items, api_chunk_size=api_chunk_size, return_values='title', **kwargs ) def extlinks(self): """List external links from the current page. API doc: https://www.mediawiki.org/wiki/API:Extlinks """ return mwclient.listing.PageProperty(self, 'extlinks', 'el', return_values='*') def images(self, generator=True): """List files/images embedded in the current page. API doc: https://www.mediawiki.org/wiki/API:Images """ if generator: return mwclient.listing.PagePropertyGenerator(self, 'images', '') else: return mwclient.listing.PageProperty(self, 'images', '', return_values='title') def iwlinks(self): """List interwiki links from the current page. API doc: https://www.mediawiki.org/wiki/API:Iwlinks """ return mwclient.listing.PageProperty(self, 'iwlinks', 'iw', return_values=('prefix', '*')) def langlinks(self, **kwargs): """List interlanguage links from the current page. API doc: https://www.mediawiki.org/wiki/API:Langlinks """ return mwclient.listing.PageProperty(self, 'langlinks', 'll', return_values=('lang', '*'), **kwargs) def links(self, namespace=None, generator=True, redirects=False): """List links to other pages from the current page. API doc: https://www.mediawiki.org/wiki/API:Links """ prefix = mwclient.listing.List.get_prefix('pl', generator) kwargs = dict(mwclient.listing.List.generate_kwargs(prefix, namespace=namespace)) if redirects: kwargs['redirects'] = '1' if generator: return mwclient.listing.PagePropertyGenerator(self, 'links', 'pl', **kwargs) else: return mwclient.listing.PageProperty(self, 'links', 'pl', return_values='title', **kwargs) def revisions(self, startid=None, endid=None, start=None, end=None, dir='older', user=None, excludeuser=None, limit=None, prop='ids|timestamp|flags|comment|user', expandtemplates=False, section=None, diffto=None, slots=None, uselang=None, max_items=None, api_chunk_size=50): """List revisions of the current page. API doc: https://www.mediawiki.org/wiki/API:Revisions Args: startid (int): Revision ID to start listing from. endid (int): Revision ID to stop listing at. start (str): Timestamp to start listing from. end (str): Timestamp to end listing at. dir (str): Direction to list in: 'older' (default) or 'newer'. user (str): Only list revisions made by this user. excludeuser (str): Exclude revisions made by this user. limit (int): The API request chunk size (deprecated). prop (str): Which properties to get for each revision, default: 'ids|timestamp|flags|comment|user' expandtemplates (bool): Expand templates in rvprop=content output section (int): Section number. If rvprop=content is set, only the contents of this section will be retrieved. diffto (str): Revision ID to diff each revision to. Use "prev", "next" and "cur" for the previous, next and current revision respectively. slots (str): The content slot (Mediawiki >= 1.32) to retrieve content from. uselang (str): Language to use for parsed edit comments and other localized messages. max_items(int): The maximum number of revisions to yield. api_chunk_size(int): The API request chunk size (as a number of revisions). Returns: mwclient.listings.List: Revision iterator """ (max_items, api_chunk_size) = handle_limit(limit, max_items, api_chunk_size) kwargs = dict(mwclient.listing.List.generate_kwargs( 'rv', startid=startid, endid=endid, start=start, end=end, user=user, excludeuser=excludeuser, diffto=diffto, slots=slots )) if self.site.version[:2] < (1, 32) and 'rvslots' in kwargs: # https://github.com/mwclient/mwclient/issues/199 del kwargs['rvslots'] kwargs['rvdir'] = dir kwargs['rvprop'] = prop kwargs['uselang'] = uselang if expandtemplates: kwargs['rvexpandtemplates'] = '1' if section is not None: kwargs['rvsection'] = section return mwclient.listing.RevisionsIterator(self, 'revisions', 'rv', max_items=max_items, api_chunk_size=api_chunk_size, **kwargs) def templates(self, namespace=None, generator=True): """List templates used on the current page. API doc: https://www.mediawiki.org/wiki/API:Templates """ prefix = mwclient.listing.List.get_prefix('tl', generator) kwargs = dict(mwclient.listing.List.generate_kwargs(prefix, namespace=namespace)) if generator: return mwclient.listing.PagePropertyGenerator(self, 'templates', prefix, **kwargs) else: return mwclient.listing.PageProperty(self, 'templates', prefix, return_values='title', **kwargs) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/mwclient/sleep.py0000644000175100001770000000672514656212142016341 0ustar00runnerdockerimport time import logging from mwclient.errors import MaximumRetriesExceeded log = logging.getLogger(__name__) class Sleepers: """ A class that allows for the creation of multiple `Sleeper` objects with shared arguments. Examples: Firstly a `Sleepers` object containing the shared attributes has to be created. >>> max_retries, retry_timeout = 5, 5 >>> sleepers = Sleepers(max_retries, retry_timeout) From this `Sleepers` object multiple individual `Sleeper` objects can be created using the `make` method. >>> sleeper = sleepers.make() Args: max_retries (int): The maximum number of retries to perform. retry_timeout (int): The time to sleep for each past retry. callback (Callable[[int, Any], None]): A callable to be called on each retry. Attributes: max_retries (int): The maximum number of retries to perform. retry_timeout (int): The time to sleep for each past retry. callback (callable): A callable to be called on each retry. """ def __init__(self, max_retries, retry_timeout, callback=lambda *x: None): self.max_retries = max_retries self.retry_timeout = retry_timeout self.callback = callback def make(self, args=None): """ Creates a new `Sleeper` object. Args: args (Any): Arguments to be passed to the `callback` callable. Returns: Sleeper: A `Sleeper` object. """ return Sleeper(args, self.max_retries, self.retry_timeout, self.callback) class Sleeper: """ For any given operation, a `Sleeper` object keeps count of the number of retries. For each retry, the sleep time increases until the max number of retries is reached and a `MaximumRetriesExceeded` is raised. The sleeper object should be discarded once the operation is successful. Args: args (Any): Arguments to be passed to the `callback` callable. max_retries (int): The maximum number of retries to perform. retry_timeout (int): The time to sleep for each past retry. callback (callable, None]): A callable to be called on each retry. Attributes: args (Any): Arguments to be passed to the `callback` callable. retries (int): The number of retries that have been performed. max_retries (int): The maximum number of retries to perform. retry_timeout (int): The time to sleep for each past retry. callback (callable): A callable to be called on each retry. """ def __init__(self, args, max_retries, retry_timeout, callback): self.args = args self.retries = 0 self.max_retries = max_retries self.retry_timeout = retry_timeout self.callback = callback def sleep(self, min_time=0): """ Sleeps for a minimum of `min_time` seconds. The actual sleeping time will increase with the number of retries. Args: min_time (int): The minimum sleeping time. Raises: MaximumRetriesExceeded: If the number of retries exceeds the maximum. """ self.retries += 1 if self.retries > self.max_retries: raise MaximumRetriesExceeded(self, self.args) self.callback(self, self.retries, self.args) timeout = self.retry_timeout * (self.retries - 1) if timeout < min_time: timeout = min_time log.debug('Sleeping for %d seconds', timeout) time.sleep(timeout) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/mwclient/util.py0000644000175100001770000000321414656212142016174 0ustar00runnerdockerimport time import io import warnings def parse_timestamp(t): """Parses a string containing a timestamp. Args: t (str): A string containing a timestamp. Returns: time.struct_time: A timestamp. """ if t is None or t == '0000-00-00T00:00:00Z': return time.struct_time((0, 0, 0, 0, 0, 0, 0, 0, 0)) return time.strptime(t, '%Y-%m-%dT%H:%M:%SZ') def read_in_chunks(stream, chunk_size): while True: data = stream.read(chunk_size) if not data: break yield io.BytesIO(data) def handle_limit(limit, max_items, api_chunk_size): """ Consistently handles 'limit', 'api_chunk_size' and 'max_items' - https://github.com/mwclient/mwclient/issues/259 . In version 0.11, 'api_chunk_size' was introduced as a better name for 'limit', but we still accept 'limit' with a deprecation warning. 'max_items' does what 'limit' sounds like it should. """ if limit: if api_chunk_size: warnings.warn( "limit and api_chunk_size both specified, this is not supported! limit " "is deprecated, will use value of api_chunk_size", DeprecationWarning ) else: warnings.warn( "limit is deprecated as its name and purpose are confusing. use " "api_chunk_size to set the number of items retrieved from the API at " "once, and/or max_items to limit the total number of items that will be " "yielded", DeprecationWarning ) api_chunk_size = limit return (max_items, api_chunk_size) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1723405414.3553734 mwclient-0.11.0/mwclient.egg-info/0000755000175100001770000000000014656212146016343 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405414.0 mwclient-0.11.0/mwclient.egg-info/PKG-INFO0000644000175100001770000000720414656212146017443 0ustar00runnerdockerMetadata-Version: 2.1 Name: mwclient Version: 0.11.0 Summary: MediaWiki API client Home-page: https://github.com/mwclient/mwclient Author: Bryan Tong Minh Author-email: bryan.tongminh@gmail.com License: MIT Keywords: mediawiki wikipedia Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3.5 Classifier: Programming Language :: Python :: 3.6 Classifier: Programming Language :: Python :: 3.7 Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Description-Content-Type: text/markdown License-File: LICENSE.md Requires-Dist: requests-oauthlib
mwclient logo

mwclient

[![Build status][build-status-img]](https://github.com/mwclient/mwclient) [![Test coverage][test-coverage-img]](https://coveralls.io/r/mwclient/mwclient) [![Latest version][latest-version-img]](https://pypi.python.org/pypi/mwclient) [![MIT license][mit-license-img]](http://opensource.org/licenses/MIT) [![Documentation status][documentation-status-img]](http://mwclient.readthedocs.io/en/latest/) [![Issue statistics][issue-statistics-img]](http://isitmaintained.com/project/mwclient/mwclient) [![Gitter chat][gitter-chat-img]](https://gitter.im/mwclient/mwclient) [build-status-img]: https://github.com/mwclient/mwclient/actions/workflows/tox.yml/badge.svg [test-coverage-img]: https://img.shields.io/coveralls/mwclient/mwclient.svg [latest-version-img]: https://img.shields.io/pypi/v/mwclient.svg [mit-license-img]: https://img.shields.io/github/license/mwclient/mwclient.svg [documentation-status-img]: https://readthedocs.org/projects/mwclient/badge/ [issue-statistics-img]: http://isitmaintained.com/badge/resolution/mwclient/mwclient.svg [gitter-chat-img]: https://img.shields.io/gitter/room/mwclient/mwclient.svg mwclient is a lightweight Python client library to the [MediaWiki API](https://mediawiki.org/wiki/API) which provides access to most API functionality. It works with Python 3.5 and above, and supports MediaWiki 1.16 and above. For functions not available in the current MediaWiki, a `MediaWikiVersionError` is raised. The current stable [version 0.11.0](https://github.com/mwclient/mwclient/archive/v0.11.0.zip) is [available through PyPI](https://pypi.python.org/pypi/mwclient): ``` $ pip install mwclient ``` The current [development version](https://github.com/mwclient/mwclient) can be installed from GitHub: ``` $ pip install git+git://github.com/mwclient/mwclient.git ``` Please see the [changelog document](https://github.com/mwclient/mwclient/blob/master/CHANGELOG.md) for a list of changes. mwclient was originally written by Bryan Tong Minh. It was maintained for many years by Dan Michael O. Heggø, with assistance from Waldir Pimenta. It is currently maintained by Marc Trölitzsch, Adam Williamson and Megan Cutrofello. The best way to get in touch with the maintainers is by filing an issue or a pull request. ## Documentation Up-to-date documentation is hosted [at Read the Docs](http://mwclient.readthedocs.io/en/latest/). It includes a user guide to get started using mwclient, a reference guide, implementation and development notes. There is also some documentation on the [GitHub wiki](https://github.com/mwclient/mwclient/wiki) that hasn't been ported yet. If you want to help, you're welcome! ## Contributing Patches are welcome! See [this page](https://mwclient.readthedocs.io/en/latest/development/) for information on how to get started with mwclient development. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405414.0 mwclient-0.11.0/mwclient.egg-info/SOURCES.txt0000644000175100001770000000214614656212146020232 0ustar00runnerdocker.editorconfig .landscape.yaml CHANGELOG.md CONTRIBUTING.md CREDITS.md LICENSE.md MANIFEST.in README.md pyproject.toml setup.cfg setup.py tox.ini docs/Makefile docs/make.bat docs/requirements.txt docs/source/conf.py docs/source/index.rst docs/source/logo.png docs/source/logo.svg docs/source/development/index.rst docs/source/reference/errors.rst docs/source/reference/image.rst docs/source/reference/index.rst docs/source/reference/page.rst docs/source/reference/site.rst docs/source/user/connecting.rst docs/source/user/files.rst docs/source/user/implementation-notes.rst docs/source/user/index.rst docs/source/user/page-ops.rst examples/basic_edit.py examples/test-image.png examples/upload.py mwclient/__init__.py mwclient/client.py mwclient/errors.py mwclient/image.py mwclient/listing.py mwclient/page.py mwclient/sleep.py mwclient/util.py mwclient.egg-info/PKG-INFO mwclient.egg-info/SOURCES.txt mwclient.egg-info/dependency_links.txt mwclient.egg-info/requires.txt mwclient.egg-info/top_level.txt mwclient.egg-info/zip-safe test/test_client.py test/test_listing.py test/test_page.py test/test_sleep.py test/test_util.py././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405414.0 mwclient-0.11.0/mwclient.egg-info/dependency_links.txt0000644000175100001770000000000114656212146022411 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405414.0 mwclient-0.11.0/mwclient.egg-info/requires.txt0000644000175100001770000000002214656212146020735 0ustar00runnerdockerrequests-oauthlib ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405414.0 mwclient-0.11.0/mwclient.egg-info/top_level.txt0000644000175100001770000000001114656212146021065 0ustar00runnerdockermwclient ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405414.0 mwclient-0.11.0/mwclient.egg-info/zip-safe0000644000175100001770000000000114656212146017773 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/pyproject.toml0000644000175100001770000000105014656212142015733 0ustar00runnerdocker[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.11.0" 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" search = "__version__ = '{current_version}'" replace = "__version__ = '{new_version}'" [[tool.bumpversion.files]] filename = "README.md" ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1723405414.3553734 mwclient-0.11.0/setup.cfg0000644000175100001770000000015614656212146014652 0ustar00runnerdocker[aliases] test = pytest [flake8] max-line-length = 90 ignore = W503 [egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/setup.py0000644000175100001770000000302714656212142014537 0ustar00runnerdocker#!/usr/bin/env python import os import sys from setuptools import setup here = os.path.abspath(os.path.dirname(__file__)) README = open(os.path.join(here, 'README.md')).read() needs_pytest = set(['pytest', 'test', 'ptr']).intersection(sys.argv) pytest_runner = ['pytest-runner'] if needs_pytest else [] setup(name='mwclient', # See https://mwclient.readthedocs.io/en/latest/development/#making-a-release # for how to update this field and release a new version. version='0.11.0', description='MediaWiki API client', long_description=README, long_description_content_type='text/markdown', classifiers=[ 'Programming Language :: Python', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', ], keywords='mediawiki wikipedia', author='Bryan Tong Minh', author_email='bryan.tongminh@gmail.com', url='https://github.com/mwclient/mwclient', license='MIT', packages=['mwclient'], install_requires=['requests-oauthlib'], setup_requires=pytest_runner, tests_require=['pytest', 'pytest-cov', 'responses>=0.3.0', 'responses!=0.6.0', 'setuptools'], zip_safe=True ) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1723405414.3553734 mwclient-0.11.0/test/0000755000175100001770000000000014656212146014006 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/test/test_client.py0000644000175100001770000010616014656212142016675 0ustar00runnerdockerfrom io import StringIO import unittest import pytest import mwclient import logging import requests import responses import pkg_resources # part of setuptools import time import json from requests_oauthlib import OAuth1 import unittest.mock as mock if __name__ == "__main__": print() print("Note: Running in stand-alone mode. Consult the README") print(" (section 'Contributing') for advice on running tests.") print() logging.basicConfig(level=logging.DEBUG) class TestCase(unittest.TestCase): def metaResponse(self, **kwargs): tpl = '{"query":{"general":{"generator":"MediaWiki %(version)s"},"namespaces":{"-1":{"*":"Special","canonical":"Special","case":"first-letter","id":-1},"-2":{"*":"Media","canonical":"Media","case":"first-letter","id":-2},"0":{"*":"","case":"first-letter","content":"","id":0},"1":{"*":"Talk","canonical":"Talk","case":"first-letter","id":1,"subpages":""},"10":{"*":"Template","canonical":"Template","case":"first-letter","id":10,"subpages":""},"100":{"*":"Test namespace 1","canonical":"Test namespace 1","case":"first-letter","id":100,"subpages":""},"101":{"*":"Test namespace 1 talk","canonical":"Test namespace 1 talk","case":"first-letter","id":101,"subpages":""},"102":{"*":"Test namespace 2","canonical":"Test namespace 2","case":"first-letter","id":102,"subpages":""},"103":{"*":"Test namespace 2 talk","canonical":"Test namespace 2 talk","case":"first-letter","id":103,"subpages":""},"11":{"*":"Template talk","canonical":"Template talk","case":"first-letter","id":11,"subpages":""},"1198":{"*":"Translations","canonical":"Translations","case":"first-letter","id":1198,"subpages":""},"1199":{"*":"Translations talk","canonical":"Translations talk","case":"first-letter","id":1199,"subpages":""},"12":{"*":"Help","canonical":"Help","case":"first-letter","id":12,"subpages":""},"13":{"*":"Help talk","canonical":"Help talk","case":"first-letter","id":13,"subpages":""},"14":{"*":"Category","canonical":"Category","case":"first-letter","id":14},"15":{"*":"Category talk","canonical":"Category talk","case":"first-letter","id":15,"subpages":""},"2":{"*":"User","canonical":"User","case":"first-letter","id":2,"subpages":""},"2500":{"*":"VisualEditor","canonical":"VisualEditor","case":"first-letter","id":2500},"2501":{"*":"VisualEditor talk","canonical":"VisualEditor talk","case":"first-letter","id":2501},"2600":{"*":"Topic","canonical":"Topic","case":"first-letter","defaultcontentmodel":"flow-board","id":2600},"3":{"*":"User talk","canonical":"User talk","case":"first-letter","id":3,"subpages":""},"4":{"*":"Wikipedia","canonical":"Project","case":"first-letter","id":4,"subpages":""},"460":{"*":"Campaign","canonical":"Campaign","case":"case-sensitive","defaultcontentmodel":"Campaign","id":460},"461":{"*":"Campaign talk","canonical":"Campaign talk","case":"case-sensitive","id":461},"5":{"*":"Wikipedia talk","canonical":"Project talk","case":"first-letter","id":5,"subpages":""},"6":{"*":"File","canonical":"File","case":"first-letter","id":6},"7":{"*":"File talk","canonical":"File talk","case":"first-letter","id":7,"subpages":""},"710":{"*":"TimedText","canonical":"TimedText","case":"first-letter","id":710},"711":{"*":"TimedText talk","canonical":"TimedText talk","case":"first-letter","id":711},"8":{"*":"MediaWiki","canonical":"MediaWiki","case":"first-letter","id":8,"subpages":""},"828":{"*":"Module","canonical":"Module","case":"first-letter","id":828,"subpages":""},"829":{"*":"Module talk","canonical":"Module talk","case":"first-letter","id":829,"subpages":""},"866":{"*":"CNBanner","canonical":"CNBanner","case":"first-letter","id":866},"867":{"*":"CNBanner talk","canonical":"CNBanner talk","case":"first-letter","id":867,"subpages":""},"9":{"*":"MediaWiki talk","canonical":"MediaWiki talk","case":"first-letter","id":9,"subpages":""},"90":{"*":"Thread","canonical":"Thread","case":"first-letter","id":90},"91":{"*":"Thread talk","canonical":"Thread talk","case":"first-letter","id":91},"92":{"*":"Summary","canonical":"Summary","case":"first-letter","id":92},"93":{"*":"Summary talk","canonical":"Summary talk","case":"first-letter","id":93}},"userinfo":{"anon":"","groups":["*"],"id":0,"name":"127.0.0.1","rights": %(rights)s}}}' tpl = tpl % {'version': kwargs.get('version', '1.24wmf17'), 'rights': json.dumps(kwargs.get('rights', ["createaccount", "read", "edit", "createpage", "createtalk", "editmyusercss", "editmyuserjs", "viewmywatchlist", "editmywatchlist", "viewmyprivateinfo", "editmyprivateinfo", "editmyoptions", "centralauth-merge", "abusefilter-view", "abusefilter-log", "translate", "vipsscaler-test", "upload"])) } res = json.loads(tpl) return res def metaResponseAsJson(self, **kwargs): return json.dumps(self.metaResponse(**kwargs)) def httpShouldReturn(self, body=None, callback=None, scheme='https', host='test.wikipedia.org', path='/w/', script='api', headers=None, status=200, method='GET'): url = '{scheme}://{host}{path}{script}.php'.format(scheme=scheme, host=host, path=path, script=script) mock = responses.GET if method == 'GET' else responses.POST if body is None: responses.add_callback(mock, url, callback=callback) else: responses.add(mock, url, body=body, content_type='application/json', adding_headers=headers, status=status) def stdSetup(self): self.httpShouldReturn(self.metaResponseAsJson()) site = mwclient.Site('test.wikipedia.org') responses.reset() return site def makePageResponse(self, title='Dummy.jpg', **kwargs): # Creates a dummy page response pageinfo = { "contentmodel": "wikitext", "lastrevid": 112353797, "length": 389, "ns": 6, "pageid": 738154, "pagelanguage": "en", "protection": [], "title": title, "touched": "2014-09-10T20:37:25Z" } pageinfo.update(**kwargs) res = { "query": { "pages": { "9": pageinfo } } } return json.dumps(res) class TestClient(TestCase): def setUp(self): pass def testVersion(self): # The version specified in setup.py should equal the one specified in client.py version = pkg_resources.require("mwclient")[0].version assert version == mwclient.__version__ @responses.activate def test_https_as_default(self): # 'https' should be the default scheme self.httpShouldReturn(self.metaResponseAsJson(), scheme='https') site = mwclient.Site('test.wikipedia.org') assert len(responses.calls) == 1 assert responses.calls[0].request.method == 'GET' @responses.activate def test_max_lag(self): # Client should wait and retry if lag exceeds max-lag def request_callback(request): if len(responses.calls) == 0: return (200, {'x-database-lag': '0', 'retry-after': '0'}, '') else: return (200, {}, self.metaResponseAsJson()) self.httpShouldReturn(callback=request_callback, scheme='https') site = mwclient.Site('test.wikipedia.org') assert len(responses.calls) == 2 assert 'retry-after' in responses.calls[0].response.headers assert 'retry-after' not in responses.calls[1].response.headers @responses.activate def test_http_error(self): # Client should raise HTTPError self.httpShouldReturn('Uh oh', scheme='https', status=400) with pytest.raises(requests.exceptions.HTTPError): site = mwclient.Site('test.wikipedia.org') @responses.activate def test_force_http(self): # Setting http should work self.httpShouldReturn(self.metaResponseAsJson(), scheme='http') site = mwclient.Site('test.wikipedia.org', scheme='http') assert len(responses.calls) == 1 @responses.activate def test_user_agent_is_sent(self): # User specified user agent should be sent sent to server self.httpShouldReturn(self.metaResponseAsJson()) site = mwclient.Site('test.wikipedia.org', clients_useragent='MyFabulousClient') assert 'MyFabulousClient' in responses.calls[0].request.headers['user-agent'] @responses.activate def test_custom_headers_are_sent(self): # Custom headers should be sent to the server self.httpShouldReturn(self.metaResponseAsJson()) site = mwclient.Site('test.wikipedia.org', custom_headers={'X-Wikimedia-Debug': 'host=mw1099.eqiad.wmnet; log'}) assert 'host=mw1099.eqiad.wmnet; log' in responses.calls[0].request.headers['X-Wikimedia-Debug'] @responses.activate def test_basic_request(self): self.httpShouldReturn(self.metaResponseAsJson()) site = mwclient.Site('test.wikipedia.org') assert 'action=query' in responses.calls[0].request.url assert 'meta=siteinfo%7Cuserinfo' in responses.calls[0].request.url @responses.activate def test_httpauth_defaults_to_basic_auth(self): self.httpShouldReturn(self.metaResponseAsJson()) site = mwclient.Site('test.wikipedia.org', httpauth=('me', 'verysecret')) assert isinstance(site.connection.auth, requests.auth.HTTPBasicAuth) @responses.activate def test_basic_auth_non_latin(self): self.httpShouldReturn(self.metaResponseAsJson()) site = mwclient.Site('test.wikipedia.org', httpauth=('我', '非常秘密')) assert isinstance(site.connection.auth, requests.auth.HTTPBasicAuth) @responses.activate def test_httpauth_raise_error_on_invalid_type(self): self.httpShouldReturn(self.metaResponseAsJson()) with pytest.raises(RuntimeError): site = mwclient.Site('test.wikipedia.org', httpauth=1) @responses.activate def test_oauth(self): self.httpShouldReturn(self.metaResponseAsJson()) site = mwclient.Site('test.wikipedia.org', consumer_token='a', consumer_secret='b', access_token='c', access_secret='d') assert isinstance(site.connection.auth, OAuth1) @responses.activate def test_api_disabled(self): # Should raise APIDisabledError if API is not enabled self.httpShouldReturn('MediaWiki API is not enabled for this site.') with pytest.raises(mwclient.errors.APIDisabledError): site = mwclient.Site('test.wikipedia.org') @responses.activate def test_version(self): # Should parse the MediaWiki version number correctly self.httpShouldReturn(self.metaResponseAsJson(version='1.16')) site = mwclient.Site('test.wikipedia.org') assert site.initialized is True assert site.version == (1, 16) @responses.activate def test_min_version(self): # Should raise MediaWikiVersionError if API version is < 1.16 self.httpShouldReturn(self.metaResponseAsJson(version='1.15')) with pytest.raises(mwclient.errors.MediaWikiVersionError): site = mwclient.Site('test.wikipedia.org') @responses.activate def test_private_wiki(self): # Should not raise error self.httpShouldReturn(json.dumps({ 'error': { 'code': 'readapidenied', 'info': 'You need read permission to use this module' } })) site = mwclient.Site('test.wikipedia.org') assert site.initialized is False # ----- Use standard setup for rest @responses.activate def test_headers(self): # Content-type should be 'application/x-www-form-urlencoded' for POST requests site = self.stdSetup() self.httpShouldReturn('{}', method='POST') site.post('purge', title='Main Page') assert len(responses.calls) == 1 assert 'content-type' in responses.calls[0].request.headers assert responses.calls[0].request.headers['content-type'] == 'application/x-www-form-urlencoded' @responses.activate def test_raw_index(self): # Initializing the client should result in one request site = self.stdSetup() self.httpShouldReturn('Some data', script='index') site.raw_index(action='purge', title='Main Page', http_method='GET') assert len(responses.calls) == 1 @responses.activate def test_api_error_response(self): # Test that APIError is thrown on error response site = self.stdSetup() self.httpShouldReturn(json.dumps({ 'error': { 'code': 'assertuserfailed', 'info': 'Assertion that the user is logged in failed', '*': 'See https://en.wikipedia.org/w/api.php for API usage' } }), method='POST') with pytest.raises(mwclient.errors.APIError) as excinfo: site.api(action='edit', title='Wikipedia:Sandbox') assert excinfo.value.code == 'assertuserfailed' assert excinfo.value.info == 'Assertion that the user is logged in failed' assert len(responses.calls) == 1 @responses.activate def test_smw_error_response(self): # Test that APIError is thrown on error response from SMW site = self.stdSetup() self.httpShouldReturn(json.dumps({ 'error': { 'query': 'Certains « [[ » dans votre requête n’ont pas été clos par des « ]] » correspondants.' } }), method='GET') with pytest.raises(mwclient.errors.APIError) as excinfo: list(site.ask('test')) assert excinfo.value.code is None assert excinfo.value.info == 'Certains « [[ » dans votre requête n’ont pas été clos par des « ]] » correspondants.' assert len(responses.calls) == 1 @responses.activate def test_smw_response_v0_5(self): # Test that the old SMW results format is handled site = self.stdSetup() self.httpShouldReturn(json.dumps({ "query": { "results": [ { "exists": "", "fulltext": "Indeks (bibliotekfag)", "fullurl": "http://example.com/wiki/Indeks_(bibliotekfag)", "namespace": 0, "printouts": [ { "0": "1508611329", "label": "Endringsdato" } ] }, { "exists": "", "fulltext": "Serendipitet", "fullurl": "http://example.com/wiki/Serendipitet", "namespace": 0, "printouts": [ { "0": "1508611394", "label": "Endringsdato" } ] } ], "serializer": "SMW\\Serializers\\QueryResultSerializer", "version": 0.5 } }), method='GET') answers = set(result['fulltext'] for result in site.ask('test')) assert answers == set(('Serendipitet', 'Indeks (bibliotekfag)')) @responses.activate def test_smw_response_v2(self): # Test that the new SMW results format is handled site = self.stdSetup() self.httpShouldReturn(json.dumps({ "query": { "results": { "Indeks (bibliotekfag)": { "exists": "1", "fulltext": "Indeks (bibliotekfag)", "fullurl": "http://example.com/wiki/Indeks_(bibliotekfag)", "namespace": 0, "printouts": { "Endringsdato": [{ "raw": "1/2017/10/17/22/50/4/0", "label": "Endringsdato" }] } }, "Serendipitet": { "exists": "1", "fulltext": "Serendipitet", "fullurl": "http://example.com/wiki/Serendipitet", "namespace": 0, "printouts": { "Endringsdato": [{ "raw": "1/2017/10/17/22/50/4/0", "label": "Endringsdato" }] } } }, "serializer": "SMW\\Serializers\\QueryResultSerializer", "version": 2 } }), method='GET') answers = set(result['fulltext'] for result in site.ask('test')) assert answers == set(('Serendipitet', 'Indeks (bibliotekfag)')) @responses.activate def test_repr(self): # Test repr() site = self.stdSetup() assert repr(site) == '' @mock.patch("time.sleep") @responses.activate def test_api_http_error(self, timesleep): # Test error paths in raw_call, via raw_api as it's more # convenient to call. This would be way nicer with pytest # parametrization but you can't use parametrization inside # unittest.TestCase :( site = self.stdSetup() # All HTTP errors should raise HTTPError with or without retries self.httpShouldReturn(body="foo", status=401) with pytest.raises(requests.exceptions.HTTPError): site.raw_api("query", "GET") # for a 4xx response we should *not* retry assert timesleep.call_count == 0 with pytest.raises(requests.exceptions.HTTPError): site.raw_api("query", "GET", retry_on_error=False) self.httpShouldReturn(body="foo", status=503) with pytest.raises(requests.exceptions.HTTPError): site.raw_api("query", "GET") # for a 5xx response we *should* retry assert timesleep.call_count == 25 timesleep.reset_mock() with pytest.raises(requests.exceptions.HTTPError): site.raw_api("query", "GET", retry_on_error=False) # check we did not retry assert timesleep.call_count == 0 # stop sending bad statuses self.httpShouldReturn(body="foo", status=200) # ConnectionError should retry then pass through, takes # advantage of responses raising ConnectionError if you # hit a URL that hasn't been configured. Timeout follows # the same path so we don't bother testing it separately realhost = site.host site.host = "notthere" with pytest.raises(requests.exceptions.ConnectionError): site.raw_api("query", "GET") assert timesleep.call_count == 25 timesleep.reset_mock() with pytest.raises(requests.exceptions.ConnectionError): site.raw_api("query", "GET", retry_on_error=False) # check we did not retry assert timesleep.call_count == 0 @mock.patch("time.sleep") @responses.activate def test_api_dblag(self, timesleep): site = self.stdSetup() # db lag should retry then raise MaximumRetriesExceeded, # even with retry_on_error set self.httpShouldReturn( body="foo", status=200, headers={"x-database-lag": "true", "retry-after": "5"} ) with pytest.raises(mwclient.errors.MaximumRetriesExceeded): site.raw_api("query", "GET") assert timesleep.call_count == 25 timesleep.reset_mock() with pytest.raises(mwclient.errors.MaximumRetriesExceeded): 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') @mock.patch('mwclient.client.Site.raw_api') def test_old_login_flow(self, raw_api, site_init): # The login flow used before MW 1.27 that starts with a action=login POST request login_token = 'abc+\\' def side_effect(*args, **kwargs): if 'lgtoken' not in kwargs: return { 'login': {'result': 'NeedToken', 'token': login_token} } elif 'lgname' in kwargs: assert kwargs['lgtoken'] == login_token return { 'login': {'result': 'Success'} } raw_api.side_effect = side_effect site = mwclient.Site('test.wikipedia.org') site.login('myusername', 'mypassword') call_args = raw_api.call_args_list assert len(call_args) == 3 assert call_args[0] == mock.call('query', 'GET', meta='tokens', type='login') assert call_args[1] == mock.call('login', 'POST', lgname='myusername', lgpassword='mypassword') assert call_args[2] == mock.call('login', 'POST', lgname='myusername', lgpassword='mypassword', lgtoken=login_token) @mock.patch('mwclient.client.Site.site_init') @mock.patch('mwclient.client.Site.raw_api') def test_new_login_flow(self, raw_api, site_init): # The login flow used from MW 1.27 that starts with a meta=tokens GET request login_token = 'abc+\\' def side_effect(*args, **kwargs): if kwargs.get('meta') == 'tokens': return { 'query': {'tokens': {'logintoken': login_token}} } elif 'lgname' in kwargs: assert kwargs['lgtoken'] == login_token return { 'login': {'result': 'Success'} } raw_api.side_effect = side_effect site = mwclient.Site('test.wikipedia.org') site.login('myusername', 'mypassword') call_args = raw_api.call_args_list assert len(call_args) == 2 assert call_args[0] == mock.call('query', 'GET', meta='tokens', type='login') assert call_args[1] == mock.call('login', 'POST', lgname='myusername', lgpassword='mypassword', lgtoken=login_token) @mock.patch('mwclient.client.Site.site_init') @mock.patch('mwclient.client.Site.raw_api') def test_clientlogin_success(self, raw_api, site_init): login_token = 'abc+\\' def api_side_effect(*args, **kwargs): if kwargs.get('meta') == 'tokens': return { 'query': {'tokens': {'logintoken': login_token}} } elif 'username' in kwargs: assert kwargs['logintoken'] == login_token assert kwargs.get('loginreturnurl') return { 'clientlogin': {'status': 'PASS'} } raw_api.side_effect = api_side_effect site = mwclient.Site('test.wikipedia.org') # this would be done by site_init usually, but we're mocking it site.version = (1, 28, 0) success = site.clientlogin(username='myusername', password='mypassword') url = '%s://%s' % (site.scheme, site.host) call_args = raw_api.call_args_list assert success is True assert len(call_args) == 2 assert call_args[0] == mock.call('query', 'GET', meta='tokens', type='login') assert call_args[1] == mock.call( 'clientlogin', 'POST', username='myusername', password='mypassword', loginreturnurl=url, logintoken=login_token ) @mock.patch('mwclient.client.Site.site_init') @mock.patch('mwclient.client.Site.raw_api') def test_clientlogin_fail(self, raw_api, site_init): login_token = 'abc+\\' def side_effect(*args, **kwargs): if kwargs.get('meta') == 'tokens': return { 'query': {'tokens': {'logintoken': login_token}} } elif 'username' in kwargs: assert kwargs['logintoken'] == login_token assert kwargs.get('loginreturnurl') return { 'clientlogin': {'status': 'FAIL'} } raw_api.side_effect = side_effect site = mwclient.Site('test.wikipedia.org') # this would be done by site_init usually, but we're mocking it site.version = (1, 28, 0) with pytest.raises(mwclient.errors.LoginError): success = site.clientlogin(username='myusername', password='mypassword') call_args = raw_api.call_args_list assert len(call_args) == 2 assert call_args[0] == mock.call('query', 'GET', meta='tokens', type='login') assert call_args[1] == mock.call( 'clientlogin', 'POST', username='myusername', password='mypassword', loginreturnurl='%s://%s' % (site.scheme, site.host), logintoken=login_token ) @mock.patch('mwclient.client.Site.site_init') @mock.patch('mwclient.client.Site.raw_api') def test_clientlogin_continue(self, raw_api, site_init): login_token = 'abc+\\' def side_effect(*args, **kwargs): if kwargs.get('meta') == 'tokens': return { 'query': {'tokens': {'logintoken': login_token}} } elif 'username' in kwargs: assert kwargs['logintoken'] == login_token assert kwargs.get('loginreturnurl') return { 'clientlogin': {'status': 'UI'} } raw_api.side_effect = side_effect site = mwclient.Site('test.wikipedia.org') # this would be done by site_init usually, but we're mocking it site.version = (1, 28, 0) success = site.clientlogin(username='myusername', password='mypassword') url = '%s://%s' % (site.scheme, site.host) call_args = raw_api.call_args_list assert success == {'status': 'UI'} assert len(call_args) == 2 assert call_args[0] == mock.call('query', 'GET', meta='tokens', type='login') assert call_args[1] == mock.call( 'clientlogin', 'POST', username='myusername', password='mypassword', loginreturnurl=url, logintoken=login_token ) class TestClientApiMethods(TestCase): def setUp(self): self.api = mock.patch('mwclient.client.Site.api').start() self.api.return_value = self.metaResponse() self.site = mwclient.Site('test.wikipedia.org') def tearDown(self): mock.patch.stopall() def test_revisions(self): self.api.return_value = { 'query': {'pages': {'1': { 'pageid': 1, 'title': 'Test page', 'revisions': [{ 'revid': 689697696, 'timestamp': '2015-11-08T21:52:46Z', 'comment': 'Test comment 1' }, { 'revid': 689816909, 'timestamp': '2015-11-09T16:09:28Z', 'comment': 'Test comment 2' }] }}}} revisions = [rev for rev in self.site.revisions([689697696, 689816909], prop='content')] args, kwargs = self.api.call_args assert kwargs.get('revids') == '689697696|689816909' assert len(revisions) == 2 assert revisions[0]['pageid'] == 1 assert revisions[0]['pagetitle'] == 'Test page' assert revisions[0]['revid'] == 689697696 assert revisions[0]['timestamp'] == time.strptime('2015-11-08T21:52:46Z', '%Y-%m-%dT%H:%M:%SZ') assert revisions[1]['revid'] == 689816909 class TestClientUploadArgs(TestCase): def setUp(self): self.raw_call = mock.patch('mwclient.client.Site.raw_call').start() def configure(self, rights=['read', 'upload']): self.raw_call.side_effect = [self.metaResponseAsJson(rights=rights)] self.site = mwclient.Site('test.wikipedia.org') self.vars = { 'fname': 'Some "ßeta" æøå.jpg', 'comment': 'Some slightly complex comment
π ≈ 3, © Me.jpg', 'token': 'abc+\\' } self.raw_call.side_effect = [ # 1st response: 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 }]), # 2nd response: json.dumps({'query': {'tokens': {'csrftoken': self.vars['token']}}}), # 3rd response: json.dumps({ "upload": { "result": "Success", "filename": self.vars['fname'], "imageinfo": [] } }) ] def tearDown(self): mock.patch.stopall() def test_upload_args(self): # Test that methods are called, and arguments sent as expected self.configure() self.site.upload(file=StringIO('test'), filename=self.vars['fname'], comment=self.vars['comment']) args, kwargs = self.raw_call.call_args data = args[1] files = args[2] assert data.get('action') == 'upload' assert data.get('filename') == self.vars['fname'] assert data.get('comment') == self.vars['comment'] assert data.get('token') == self.vars['token'] assert 'file' in files def test_upload_missing_filename(self): self.configure() with pytest.raises(TypeError): self.site.upload(file=StringIO('test')) def test_upload_ambigitious_args(self): self.configure() with pytest.raises(TypeError): self.site.upload(filename='Test', file=StringIO('test'), filekey='abc') def test_upload_missing_upload_permission(self): self.configure(rights=['read']) 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): def setUp(self): self.raw_call = mock.patch('mwclient.client.Site.raw_call').start() def configure(self, version='1.24'): self.raw_call.return_value = self.metaResponseAsJson(version=version) self.site = mwclient.Site('test.wikipedia.org') responses.reset() def tearDown(self): mock.patch.stopall() def test_token_new_system(self): # Test get_token for MW >= 1.24 self.configure(version='1.24') self.raw_call.return_value = json.dumps({ 'query': {'tokens': {'csrftoken': 'sometoken'}} }) self.site.get_token('edit') args, kwargs = self.raw_call.call_args data = args[1] assert 'intoken' not in data assert data.get('type') == 'csrf' assert 'csrf' in self.site.tokens assert self.site.tokens['csrf'] == 'sometoken' assert 'edit' not in self.site.tokens def test_token_old_system_without_specifying_title(self): # Test get_token for MW < 1.24 self.configure(version='1.23') self.raw_call.return_value = self.makePageResponse(edittoken='sometoken', title='Test') self.site.get_token('edit') args, kwargs = self.raw_call.call_args data = args[1] assert 'type' not in data assert data.get('intoken') == 'edit' assert 'edit' in self.site.tokens assert self.site.tokens['edit'] == 'sometoken' assert 'csrf' not in self.site.tokens def test_token_old_system_with_specifying_title(self): # Test get_token for MW < 1.24 self.configure(version='1.23') self.raw_call.return_value = self.makePageResponse(edittoken='sometoken', title='Some page') self.site.get_token('edit', title='Some page') args, kwargs = self.raw_call.call_args data = args[1] assert self.site.tokens['edit'] == 'sometoken' class TestClientPatrol(TestCase): def setUp(self): self.raw_call = mock.patch('mwclient.client.Site.raw_call').start() def configure(self, version='1.24'): self.raw_call.return_value = self.metaResponseAsJson(version=version) self.site = mwclient.Site('test.wikipedia.org') def tearDown(self): mock.patch.stopall() @mock.patch('mwclient.client.Site.get_token') def test_patrol(self, get_token): self.configure('1.24') get_token.return_value = 'sometoken' patrol_response = {"patrol": {"rcid": 12345, "ns": 0, "title": "Foo"}} self.raw_call.return_value = json.dumps(patrol_response) resp = self.site.patrol(12345) assert resp == patrol_response["patrol"] get_token.assert_called_once_with('patrol') @mock.patch('mwclient.client.Site.get_token') def test_patrol_on_mediawiki_below_1_17(self, get_token): self.configure('1.16') get_token.return_value = 'sometoken' patrol_response = {"patrol": {"rcid": 12345, "ns": 0, "title": "Foo"}} self.raw_call.return_value = json.dumps(patrol_response) resp = self.site.patrol(12345) assert resp == patrol_response["patrol"] get_token.assert_called_once_with('edit') if __name__ == '__main__': unittest.main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/test/test_listing.py0000644000175100001770000001457314656212142017076 0ustar00runnerdockerimport unittest import pytest import logging import requests import responses import json import mwclient from mwclient.listing import List, GeneratorList import unittest.mock as mock if __name__ == "__main__": print() print("Note: Running in stand-alone mode. Consult the README") print(" (section 'Contributing') for advice on running tests.") print() class TestList(unittest.TestCase): def setUp(self): pass def setupDummyResponsesOne(self, mock_site, result_member, ns=None): if ns is None: ns = [0, 0, 0] mock_site.get.side_effect = [ { 'continue': { 'apcontinue': 'Kre_Mbaye', 'continue': '-||' }, 'query': { result_member: [ { "pageid": 19839654, "ns": ns[0], "title": "Kre'fey", }, ] } }, { 'continue': { 'apcontinue': 'Kre_Blip', 'continue': '-||' }, 'query': { result_member: [ { "pageid": 19839654, "ns": ns[1], "title": "Kre-O", } ] } }, { 'query': { result_member: [ { "pageid": 30955295, "ns": ns[2], "title": "Kre-O Transformers", } ] } }, ] def setupDummyResponsesTwo(self, mock_site, result_member, ns=None): if ns is None: ns = [0, 0, 0] mock_site.get.side_effect = [ { 'continue': { 'apcontinue': 'Kre_Mbaye', 'continue': '-||' }, 'query': { result_member: [ { "pageid": 19839654, "ns": ns[0], "title": "Kre'fey", }, { "pageid": 19839654, "ns": ns[1], "title": "Kre-O", } ] } }, { 'query': { result_member: [ { "pageid": 30955295, "ns": ns[2], "title": "Kre-O Transformers", } ] } }, ] @mock.patch('mwclient.client.Site') def test_list_continuation(self, mock_site): # Test that the list fetches all three responses # and yields dicts when return_values not set lst = List(mock_site, 'allpages', 'ap', api_chunk_size=2) self.setupDummyResponsesTwo(mock_site, 'allpages') vals = [x for x in lst] assert len(vals) == 3 assert type(vals[0]) == dict assert lst.args["aplimit"] == "2" assert mock_site.get.call_count == 2 @mock.patch('mwclient.client.Site') def test_list_limit_deprecated(self, mock_site): # Test that the limit arg acts as api_chunk_size but generates # DeprecationWarning with pytest.deprecated_call(): lst = List(mock_site, 'allpages', 'ap', limit=2) self.setupDummyResponsesTwo(mock_site, 'allpages') vals = [x for x in lst] assert len(vals) == 3 assert type(vals[0]) == dict assert lst.args["aplimit"] == "2" assert mock_site.get.call_count == 2 @mock.patch('mwclient.client.Site') def test_list_max_items(self, mock_site): # Test that max_items properly caps the list # iterations mock_site.api_limit = 500 lst = List(mock_site, 'allpages', 'ap', max_items=2) self.setupDummyResponsesTwo(mock_site, 'allpages') vals = [x for x in lst] assert len(vals) == 2 assert type(vals[0]) == dict assert lst.args["aplimit"] == "2" assert mock_site.get.call_count == 1 @mock.patch('mwclient.client.Site') def test_list_max_items_continuation(self, mock_site): # Test that max_items and api_chunk_size work together mock_site.api_limit = 500 lst = List(mock_site, 'allpages', 'ap', max_items=2, api_chunk_size=1) self.setupDummyResponsesOne(mock_site, 'allpages') vals = [x for x in lst] assert len(vals) == 2 assert type(vals[0]) == dict assert lst.args["aplimit"] == "1" assert mock_site.get.call_count == 2 @mock.patch('mwclient.client.Site') def test_list_with_str_return_value(self, mock_site): # Test that the List yields strings when return_values is string lst = List(mock_site, 'allpages', 'ap', limit=2, return_values='title') self.setupDummyResponsesTwo(mock_site, 'allpages') vals = [x for x in lst] assert len(vals) == 3 assert type(vals[0]) == str @mock.patch('mwclient.client.Site') def test_list_with_tuple_return_value(self, mock_site): # Test that the List yields tuples when return_values is tuple lst = List(mock_site, 'allpages', 'ap', limit=2, return_values=('title', 'ns')) self.setupDummyResponsesTwo(mock_site, 'allpages') vals = [x for x in lst] assert len(vals) == 3 assert type(vals[0]) == tuple @mock.patch('mwclient.client.Site') def test_generator_list(self, mock_site): # Test that the GeneratorList yields Page objects mock_site.api_limit = 500 lst = GeneratorList(mock_site, 'pages', 'p') self.setupDummyResponsesTwo(mock_site, 'pages', ns=[0, 6, 14]) vals = [x for x in lst] assert len(vals) == 3 assert type(vals[0]) == mwclient.page.Page assert type(vals[1]) == mwclient.image.Image assert type(vals[2]) == mwclient.listing.Category if __name__ == '__main__': unittest.main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/test/test_page.py0000644000175100001770000003170614656212142016336 0ustar00runnerdockerimport unittest import pytest import logging import requests import responses import json import mwclient from mwclient.page import Page from mwclient.client import Site from mwclient.listing import Category from mwclient.errors import APIError, AssertUserFailedError, ProtectedPageError, InvalidPageTitle import unittest.mock as mock if __name__ == "__main__": print() print("Note: Running in stand-alone mode. Consult the README") print(" (section 'Contributing') for advice on running tests.") print() class TestPage(unittest.TestCase): def setUp(self): pass @mock.patch('mwclient.client.Site') def test_api_call_on_page_init(self, mock_site): # Check that site.get() is called once on Page init title = 'Some page' mock_site.get.return_value = { 'query': {'pages': {'1': {}}} } page = Page(mock_site, title) # test that Page called site.get with the right parameters mock_site.get.assert_called_once_with('query', inprop='protection', titles=title, prop='info') @mock.patch('mwclient.client.Site') def test_nonexisting_page(self, mock_site): # Check that API response results in page.exists being set to False title = 'Some nonexisting page' mock_site.get.return_value = { 'query': {'pages': {'-1': {'missing': ''}}} } page = Page(mock_site, title) assert page.exists is False @mock.patch('mwclient.client.Site') def test_existing_page(self, mock_site): # Check that API response results in page.exists being set to True title = 'Norge' mock_site.get.return_value = { 'query': {'pages': {'728': {}}} } page = Page(mock_site, title) assert page.exists is True @mock.patch('mwclient.client.Site') def test_invalid_title(self, mock_site): # Check that API page.exists is False for invalid title title = '[Test]' mock_site.get.return_value = { "query": { "pages": { "-1": { "title": "[Test]", "invalidreason": "The requested page title contains invalid characters: \"[\".", "invalid": "" } } } } with pytest.raises(InvalidPageTitle): page = Page(mock_site, title) @mock.patch('mwclient.client.Site') def test_pageprops(self, mock_site): # Check that variouse page props are read correctly from API response title = 'Some page' mock_site.get.return_value = { 'query': { 'pages': { '728': { 'contentmodel': 'wikitext', 'counter': '', 'lastrevid': 13355471, 'length': 58487, 'ns': 0, 'pageid': 728, 'pagelanguage': 'nb', 'protection': [], 'title': title, 'touched': '2014-09-14T21:11:52Z' } } } } page = Page(mock_site, title) assert page.exists is True assert page.redirect is False assert page.revision == 13355471 assert page.length == 58487 assert page.namespace == 0 assert page.name == title assert page.page_title == title @mock.patch('mwclient.client.Site') def test_protection_levels(self, mock_site): # If page is protected, check that protection is parsed correctly title = 'Some page' mock_site.get.return_value = { 'query': { 'pages': { '728': { 'protection': [ { 'expiry': 'infinity', 'level': 'autoconfirmed', 'type': 'edit' }, { 'expiry': 'infinity', 'level': 'sysop', 'type': 'move' } ] } } } } mock_site.rights = ['read', 'edit', 'move'] page = Page(mock_site, title) assert page.protection == {'edit': ('autoconfirmed', 'infinity'), 'move': ('sysop', 'infinity')} assert page.can('read') is True assert page.can('edit') is False # User does not have 'autoconfirmed' right assert page.can('move') is False # User does not have 'sysop' right mock_site.rights = ['read', 'edit', 'move', 'autoconfirmed'] assert page.can('edit') is True # User has 'autoconfirmed' right assert page.can('move') is False # User doesn't have 'sysop' right mock_site.rights = ['read', 'edit', 'move', 'autoconfirmed', 'editprotected'] assert page.can('edit') is True # User has 'autoconfirmed' right assert page.can('move') is True # User has 'sysop' right # check an unusual case: no 'expiry' key, see # https://github.com/mwclient/mwclient/issues/290 del mock_site.get.return_value['query']['pages']['728']['protection'][0]['expiry'] page = Page(mock_site, title) assert page.protection == {'edit': ('autoconfirmed', None), 'move': ('sysop', 'infinity')} @mock.patch('mwclient.client.Site') def test_redirect(self, mock_site): # Check that page.redirect is set correctly title = 'Some redirect page' mock_site.get.return_value = { "query": { "pages": { "796917": { "contentmodel": "wikitext", "counter": "", "lastrevid": 9342494, "length": 70, "ns": 0, "pageid": 796917, "pagelanguage": "nb", "protection": [], "redirect": "", "title": title, "touched": "2014-08-29T22:25:15Z" } } } } page = Page(mock_site, title) assert page.exists is True assert page.redirect is True @mock.patch('mwclient.client.Site') def test_captcha(self, mock_site): # Check that Captcha results in EditError mock_site.blocked = False mock_site.rights = ['read', 'edit'] title = 'Norge' mock_site.get.return_value = { 'query': {'pages': {'728': {'protection': []}}} } page = Page(mock_site, title) mock_site.post.return_value = { 'edit': {'result': 'Failure', 'captcha': { 'type': 'math', 'mime': 'text/tex', 'id': '509895952', 'question': '36 + 4 = ' }} } # For now, mwclient will just raise an EditError. # with pytest.raises(mwclient.errors.EditError): page.edit('Some text') class TestPageApiArgs(unittest.TestCase): def setUp(self): title = 'Some page' self.page_text = 'Hello world' MockSite = mock.patch('mwclient.client.Site').start() self.site = MockSite() self.site.get.return_value = {'query': {'pages': {'1': {'title': title}}}} self.site.rights = ['read'] self.site.api_limit = 500 self.site.version = (1, 32, 0) self.page = Page(self.site, title) self.site.get.return_value = {'query': {'pages': {'2': { 'ns': 0, 'pageid': 2, 'revisions': [{'*': 'Hello world', 'timestamp': '2014-08-29T22:25:15Z'}], 'title': title }}}} def get_last_api_call_args(self, http_method='POST'): if http_method == 'GET': args, kwargs = self.site.get.call_args else: args, kwargs = self.site.post.call_args action = args[0] args = args[1:] kwargs.update(args) return kwargs def tearDown(self): mock.patch.stopall() def test_get_page_text(self): # Check that page.text() works, and that a correct API call is made text = self.page.text() args = self.get_last_api_call_args(http_method='GET') assert text == self.page_text assert args == { 'prop': 'revisions', 'rvdir': 'older', 'titles': self.page.page_title, 'uselang': None, 'rvprop': 'content|timestamp', 'rvlimit': '1', 'rvslots': 'main', } def test_get_page_text_cached(self): # Check page.text() caching self.page.revisions = mock.Mock(return_value=iter([])) self.page.text() self.page.text() # When cache is hit, revisions is not, so call_count should be 1 assert self.page.revisions.call_count == 1 self.page.text(cache=False) # With cache explicitly disabled, we should hit revisions assert self.page.revisions.call_count == 2 def test_get_section_text(self): # Check that the 'rvsection' parameter is sent to the API text = self.page.text(section=0) args = self.get_last_api_call_args(http_method='GET') assert args['rvsection'] == '0' def test_get_text_expanded(self): # Check that the 'rvexpandtemplates' parameter is sent to the API text = self.page.text(expandtemplates=True) args = self.get_last_api_call_args(http_method='GET') assert self.site.expandtemplates.call_count == 1 assert args.get('rvexpandtemplates') is None def test_assertuser_true(self): # Check that assert=user is sent when force_login=True self.site.blocked = False self.site.rights = ['read', 'edit'] self.site.logged_in = True self.site.force_login = True self.site.api.return_value = { 'edit': {'result': 'Ok'} } self.page.edit('Some text') args = self.get_last_api_call_args() assert args['assert'] == 'user' def test_assertuser_false(self): # Check that assert=user is not sent when force_login=False self.site.blocked = False self.site.rights = ['read', 'edit'] self.site.logged_in = False self.site.force_login = False self.site.api.return_value = { 'edit': {'result': 'Ok'} } self.page.edit('Some text') args = self.get_last_api_call_args() assert 'assert' not in args def test_handle_edit_error_assertuserfailed(self): # Check that AssertUserFailedError is triggered api_error = APIError('assertuserfailed', 'Assertion that the user is logged in failed', 'See https://en.wikipedia.org/w/api.php for API usage') with pytest.raises(AssertUserFailedError): self.page.handle_edit_error(api_error, 'n/a') def test_handle_edit_error_protected(self): # Check that ProtectedPageError is triggered api_error = APIError('protectedpage', 'The "editprotected" right is required to edit this page', 'See https://en.wikipedia.org/w/api.php for API usage') with pytest.raises(ProtectedPageError) as pp_error: self.page.handle_edit_error(api_error, 'n/a') assert pp_error.value.code == 'protectedpage' assert str(pp_error.value) == 'The "editprotected" right is required to edit this page' def test_get_page_categories(self): # Check that page.categories() works, and that a correct API call is made self.site.get.return_value = { "batchcomplete": "", "query": { "pages": { "1009371": { "pageid": 1009371, "ns": 14, "title": "Category:1879 births", }, "1005547": { "pageid": 1005547, "ns": 14, "title": "Category:1955 deaths", } } } } cats = list(self.page.categories()) args = self.get_last_api_call_args(http_method='GET') assert { 'generator': 'categories', 'titles': self.page.page_title, 'iiprop': 'timestamp|user|comment|url|size|sha1|metadata|archivename', 'inprop': 'protection', 'prop': 'info|imageinfo', 'gcllimit': repr(self.page.site.api_limit), } == args assert set([c.name for c in cats]) == set([ 'Category:1879 births', 'Category:1955 deaths', ]) if __name__ == '__main__': unittest.main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/test/test_sleep.py0000644000175100001770000000300214656212142016516 0ustar00runnerdockerimport unittest import time import pytest from mwclient.sleep import Sleepers from mwclient.sleep import Sleeper from mwclient.errors import MaximumRetriesExceeded import unittest.mock as mock if __name__ == "__main__": print() print("Note: Running in stand-alone mode. Consult the README") print(" (section 'Contributing') for advice on running tests.") print() class TestSleepers(unittest.TestCase): def setUp(self): self.sleep = mock.patch('time.sleep').start() self.max_retries = 10 self.sleepers = Sleepers(self.max_retries, 30) def tearDown(self): mock.patch.stopall() def test_make(self): sleeper = self.sleepers.make() assert type(sleeper) == Sleeper assert sleeper.retries == 0 def test_sleep(self): sleeper = self.sleepers.make() sleeper.sleep() sleeper.sleep() self.sleep.assert_has_calls([mock.call(0), mock.call(30)]) def test_min_time(self): sleeper = self.sleepers.make() sleeper.sleep(5) self.sleep.assert_has_calls([mock.call(5)]) def test_retries_count(self): sleeper = self.sleepers.make() sleeper.sleep() sleeper.sleep() assert sleeper.retries == 2 def test_max_retries(self): sleeper = self.sleepers.make() for x in range(self.max_retries): sleeper.sleep() with pytest.raises(MaximumRetriesExceeded): sleeper.sleep() if __name__ == '__main__': unittest.main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/test/test_util.py0000644000175100001770000000141614656212142016372 0ustar00runnerdockerimport unittest import time from mwclient.util import parse_timestamp if __name__ == "__main__": print() print("Note: Running in stand-alone mode. Consult the README") print(" (section 'Contributing') for advice on running tests.") print() class TestUtil(unittest.TestCase): def test_parse_missing_timestamp(self): assert time.struct_time((0, 0, 0, 0, 0, 0, 0, 0, 0)) == parse_timestamp(None) def test_parse_empty_timestamp(self): assert time.struct_time((0, 0, 0, 0, 0, 0, 0, 0, 0)) == parse_timestamp('0000-00-00T00:00:00Z') def test_parse_nonempty_timestamp(self): assert time.struct_time((2015, 1, 2, 20, 18, 36, 4, 2, -1)) == parse_timestamp('2015-01-02T20:18:36Z') if __name__ == '__main__': unittest.main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1723405410.0 mwclient-0.11.0/tox.ini0000644000175100001770000000063314656212142014340 0ustar00runnerdocker[tox] envlist = py35,py36,py37,py38,py39,py310,py311,py312,py313,flake [gh-actions] python = 3.6: py36 3.7: py37 3.8: py38 3.9: py39 3.10: py310 3.11: py311, flake 3.12: py312 3.13: py313 [testenv] deps = pytest pytest-cov responses setuptools mock commands = py.test -v --cov mwclient test [testenv:flake] deps = flake8 commands = flake8 mwclient