wikimedia/pywikibot

View on GitHub
setup.py

Summary

Maintainability
A
45 mins
Test Coverage
#!/usr/bin/env python3
"""Installer script for Pywikibot framework.

**How to create a new distribution:**

- replace the developmental version string in ``pywikibot.__metadata__.py``
  by the corresponding final release
- create the package with::

    make_dist -remote

- create a new tag with the version number of the final release
- synchronize the local tags with the remote repositoy
- merge current master branch to stable branch
- push new stable branch to Gerrit and merge it the stable repository
- prepare the next master release by increasing the version number in
  ``pywikibot.__metadata__.py`` and adding developmental identifier
- upload this patchset to Gerrit and merge it.

.. warning:: do not upload a development release to pypi.
"""
#
# (C) Pywikibot team, 2009-2024
#
# Distributed under the terms of the MIT license.
#
from __future__ import annotations

import configparser
import os
import re
import sys
from contextlib import suppress
from pathlib import Path


# ------- setup extra_requires ------- #
extra_deps = {
    # Core library dependencies
    'eventstreams': ['sseclient<0.0.23,>=0.0.18'],  # T222885
    'isbn': ['python-stdnum>=1.19'],
    'Graphviz': ['pydot>=1.4.1'],
    'Google': ['google>=1.7'],
    'memento': ['memento_client==0.6.1'],
    'wikitextparser': ['wikitextparser>=0.47.0'],
    'mysql': ['PyMySQL >= 1.0.0'],
    # vulnerability found in Pillow<8.1.2 but toolforge uses 5.4.1
    'Tkinter': [
        'Pillow>=8.1.2, != 10.0, != 10.1; python_version < "3.13"',
        'Pillow>=10.4; python_version >= "3.13"',
    ],
    'mwoauth': ['mwoauth!=0.3.1,>=0.2.4'],
    'html': ['beautifulsoup4>=4.7.1'],
    'http': ['fake-useragent>=1.4.0'],
    'flake8': [  # Due to incompatibilities between packages the order matters.
        'flake8>=5.0.4',
        'darglint2',
        'pydocstyle>=6.3.0',
        'flake8-bugbear!=24.1.17',
        'flake8-comprehensions>=3.13.0',
        'flake8-docstrings>=1.4.0',
        'flake8-future-annotations',
        'flake8-mock-x2',
        'flake8-print>=5.0.0',
        'flake8-quotes>=3.3.2',
        'flake8-raise',
        'flake8-tuple>=0.4.1',
        'flake8-no-u-prefixed-strings>=0.2',
        'pep8-naming==0.13.3; python_version < "3.8"',
        'pep8-naming>=0.14.0; python_version >= "3.8"',
    ],
    'hacking': [
        'hacking',
        # importlib-metadata module is already installed with hacking 4.1.0
        # used by Python 3.7 but importlib-metadata >= 5 fails, so adjust it
        'importlib-metadata<5.0.0; python_version < "3.8"',
    ],
}


# ------- setup extra_requires for scripts ------- #
script_deps = {
    'create_isbn_edition.py': ['isbnlib', 'unidecode'],
    'weblinkchecker.py': extra_deps['memento'],
}

extra_deps.update(script_deps)
extra_deps.update({'scripts': [i for k, v in script_deps.items() for i in v]})

# ------- setup install_requires ------- #
# packages which are mandatory
dependencies = [
    'importlib_metadata ; python_version < "3.8"',
    'mwparserfromhell>=0.5.2',
    'packaging',
    'requests>=2.21.0',
]

# ------- setup tests_require ------- #
test_deps = ['mock']

# Add all dependencies as test dependencies,
# so all scripts can be compiled for script_tests, etc.
if 'PYSETUP_TEST_EXTRAS' in os.environ:  # pragma: no cover
    test_deps += [i for k, v in extra_deps.items() if k != 'flake8' for i in v]

# These extra dependencies are needed other unittest fails to load tests.
test_deps += extra_deps['eventstreams']


class _DottedDict(dict):
    __getattr__ = dict.__getitem__


path = Path(__file__).parent


def read_project() -> str:
    """Read the project name from toml file.

    ``tomllib`` was introduced with Python 3.11. To support earlier versions
    ``configparser`` is used. Therefore the tomlfile must be readable as
    config file until the first comment.

    .. versionadded:: 9.0
    """
    toml = []
    with open(path / 'pyproject.toml') as f:
        for line in f:
            if line.startswith('#'):
                break
            toml.append(line)

    config = configparser.ConfigParser()
    config.read_string(''.join(toml))
    return config['project']['name'].strip('"')


def get_validated_version(name: str) -> str:  # pragma: no cover
    """Get a validated pywikibot module version string.

    The version number from pywikibot.__metadata__.__version__ is used.
    setup.py with 'sdist' option is used to create a new source distribution.
    In that case the version number is validated: Read tags from git.
    Verify that the new release is higher than the last repository tag
    and is not a developmental release.

    :return: pywikibot module version string
    """
    # import metadata
    metadata = _DottedDict()
    with open(path / name / '__metadata__.py') as f:
        exec(f.read(), None, metadata)
    assert metadata.__url__.endswith(
        name.title())  # type: ignore[attr-defined]

    version = metadata.__version__  # type: ignore[attr-defined]
    if 'sdist' not in sys.argv:
        return version

    # validate version for sdist
    from subprocess import PIPE, run

    from packaging.version import InvalidVersion, Version

    try:
        tags = run(['git', 'tag'], check=True, stdout=PIPE,
                   text=True).stdout.splitlines()
    except Exception as e:
        print(e)
        sys.exit('Creating source distribution canceled.')

    last_tag = None
    if tags:
        for tag in ('stable', 'python2'):
            with suppress(ValueError):
                tags.remove(tag)

        last_tag = tags[-1]

    warning = ''
    try:
        vrsn = Version(version)
    except InvalidVersion:
        warning = f'{version} is not a valid version string following PEP 440.'
    else:
        if last_tag and vrsn <= Version(last_tag):
            warning = (
                f'New version {version!r} is not higher than last version '
                f'{last_tag!r}.'
            )

    if warning:
        print(__doc__)
        print('\n\n{warning}')
        sys.exit('\nBuild of distribution package canceled.')

    return version


def read_desc(filename) -> str:
    """Read long description.

    Combine included restructured text files which must be done before
    uploading because the source isn't available after creating the package.
    """
    pattern = r'(?:\:\w+\:`([^`]+?)(?:<.+>)?` *)', r'\1'
    desc = []
    with open(filename) as f:
        for line in f:
            if line.strip().startswith('.. include::'):
                include = os.path.relpath(line.rsplit('::')[1].strip())
                if os.path.exists(include):
                    with open(include) as g:
                        desc.append(re.sub(pattern[0], pattern[1], g.read()))
                else:  # pragma: no cover
                    print(f'Cannot include {include}; file not found')
            else:
                desc.append(re.sub(pattern[0], pattern[1], line))
    return ''.join(desc)


def get_packages(name: str) -> list[str]:
    """Find framework packages."""
    try:
        from setuptools import find_namespace_packages
    except ImportError:
        sys.exit(
            'setuptools >= 40.1.0 is required to create a new distribution.')
    packages = find_namespace_packages(include=[name + '.*'])
    for cache_variant in ('', '-py3'):
        with suppress(ValueError):
            packages.remove(f'{name}.apicache{cache_variant}')
    return [str(name)] + packages


def main() -> None:  # pragma: no cover
    """Setup entry point."""
    from setuptools import setup

    name = read_project()
    setup(
        version=get_validated_version(name),
        long_description=read_desc('README.rst'),
        long_description_content_type='text/x-rst',
        packages=get_packages(name),
        include_package_data=True,
        install_requires=dependencies,
        extras_require=extra_deps,
        test_suite='tests.collector',
        tests_require=test_deps,
    )


if __name__ == '__main__':
    main()