#!/usr/bin/env python3
#
# (C) Pywikibot team, 2022-2026
#
# Distributed under the terms of the MIT license.
#
"""Script to create a new distribution.
The following options are supported:
pywikibot The pywikibot repository to build (default)
scripts The pywikibot-scripts repository to build
-help Print documentation of this file and of setup.py
-local Install the distribution as a local site-package. If a
Pywikibot package is already there, it will be uninstalled
first. Clears old dist folders first.
-remote Upload the package to pypi. This cannot be done if the
Pywikibot version is a development release. Clears old dist
folders first.
-clear Clear old dist folders and leave. Does not create a
distribution.
-upgrade Upgrade pip first; upgrade or install distribution packages
build and twine first.
Usage::
python -B -m [pwb] make_dist [repo] [options]
.. version-added:: 7.3
.. version-changed:: 7.4
- updates pip, setuptools, wheel and twine packages first
- installs pre-releases over stable versions
- also creates built distribution together with source distribution
- *-upgrade* option was added
.. version-changed:: 7.5
- *clear* option was added
- *nodist* option was added
.. version-changed:: 8.1
*nodist* option was removed, *clear* option does not create a
distribution. *local* and *remote* option clears old distributions
first.
.. version-changed:: 8.2
Build frontend was changed from setuptools to build. ``-upgrade``
option also installs packages if necessary.
.. version-changed:: 9.4
The pywikibot-scripts distribution can be created.
"""
from __future__ import annotations
import abc
import os
import shutil
import sys
from contextlib import suppress
from dataclasses import dataclass, field
from importlib.util import find_spec
from pathlib import Path
from subprocess import check_call, run
from pywikibot import __version__, error, info, input_yn, warning
MODULE, COMMAND = range(2)
pip = f'{sys.executable} -m pip'
[docs]
@dataclass
class SetupBase(abc.ABC):
"""Setup distribution base class.
.. version-added:: 8.0
.. version-changed:: 8.1
*dataclass* is used.
.. version-changed:: 11.1
Use ``sys.executable`` to determine the Python Python interpreter
executing this script.
"""
local: bool
remote: bool
clear: bool
upgrade: bool
build_opt: str = field(init=False)
folder: Path = field(init=False)
def __post_init__(self) -> None:
"""Post-init initializer."""
self.folder = Path.cwd()
[docs]
def clear_old_dist(self) -> None: # pragma: no cover
"""Delete old dist folders.
.. version-added:: 7.5
.. version-changed:: 11.5
Use pyclean for cleanup.
"""
info('<<lightyellow>>Removing old dist folders... ', newline=False)
check_call('pyclean . -v --debris')
info('<<lightyellow>>done')
[docs]
@abc.abstractmethod
def copy_files(self) -> None:
"""Copy files."""
[docs]
@abc.abstractmethod
def cleanup(self) -> None:
"""Cleanup copied files."""
@staticmethod
def _check_module(module: str, module_type: int) -> bool:
"""Return whether a module or CLI command is available.
.. version-added: 11.5
:param module: Module or command name.
:param module_type: Type of module, either MODULE or COMMAND.
:return: Whether the module or command is available.
:raises ValueError: Invalid module type.
"""
if module_type == MODULE:
return find_spec(module) is not None
if module_type == COMMAND:
return shutil.which(module) is not None
raise ValueError(f'Invalid module type: {module_type}')
[docs]
def run(self) -> bool:
"""Run the installer script.
:return: True if no error occurs, else False
"""
tools = (
('build', MODULE),
('pyclean', COMMAND),
('twine', MODULE),
)
for tool, tool_type in tools:
if not self._check_module(tool, tool_type):
if not self.upgrade:
error(f'<<lightred>>{tool} not found')
info('<<lightblue>>You may use -upgrade option to install')
return False
info(f'<<lightyellow>>Install or upgrade {tool}')
check_call(f'{pip} install {tool}', shell=True)
elif self.upgrade:
check_call(f'{pip} install --upgrade {tool}', shell=True)
if self.local or self.remote or self.clear:
self.clear_old_dist()
if self.clear:
return True
return self.build() # pragma: no cover
[docs]
def build(self) -> bool: # pragma: no cover
"""Build the packages.
.. version-added:: 9.3
.. version-changed:: 11.1
Use pure-Python implementation of tokenizer for
mwparserfromhell with Windows.
"""
self.copy_files()
info('<<lightyellow>>Build package')
try:
check_call(f'python -m build {self.build_opt}')
except Exception as e:
error(e)
return False
finally:
self.cleanup()
info('<<lightyellow>>Check package and description')
if run('twine check dist/*', shell=True).returncode:
return False
if self.local:
info('<<lightyellow>>Install locally')
check_call(f'{pip} uninstall {self.package} -y', shell=True)
env = os.environ.copy()
if sys.platform.startswith('win32'):
# set the WITH_EXTENSION ennvironment variable for mwpfh;
# refer the mwpfh documentation
env['WITH_EXTENSION'] = '0'
check_call(f'{pip} install --no-cache-dir --pre '
f'--find-links=dist {self.package}',
shell=True, env=env)
if self.remote and input_yn(
'<<lightblue>>Upload dist to pypi', automatic_quit=False):
check_call('twine upload dist/*', shell=True)
return True
[docs]
class SetupPywikibot(SetupBase):
"""Setup for Pywikibot distribution.
.. version-added:: 8.0
"""
build_opt = '' # defaults to current directory
package = 'pywikibot'
def __init__(self, *args) -> None:
"""Set source and target directories."""
super().__init__(*args)
source = self.folder / 'scripts' / 'i18n' / 'pywikibot'
target = self.folder / 'pywikibot' / 'scripts' / 'i18n' / 'pywikibot'
self.target = target
self.source = source
[docs]
def copy_files(self) -> None: # pragma: no cover
"""Copy i18n files to pywikibot.scripts folder.
Pywikibot i18n files are used for some translations. They are
copied to the pywikibot scripts folder.
"""
info('<<lightyellow>>Copy files')
info(f'directory is {self.folder}')
info(f'clear {self.target} directory')
shutil.rmtree(self.target, ignore_errors=True)
info('copy i18n files ... ', newline=False)
shutil.copytree(self.source, self.target)
info('done')
[docs]
def cleanup(self) -> None: # pragma: no cover
"""Remove all copied files from pywikibot scripts folder."""
info('<<lightyellow>>Remove copied files... ', newline=False)
shutil.rmtree(self.target)
# restore pywikibot en.json file
filename = 'en.json'
self.target.mkdir()
shutil.copy(self.source / filename, self.target / filename)
info('<<lightyellow>>done')
[docs]
class SetupScripts(SetupBase):
"""Setup pywikibot-scripts distribution.
.. version-added:: 9.4
"""
build_opt = '-w' # only wheel (yet)
package = 'pywikibot_scripts'
replace = 'MANIFEST.in', 'pyproject.toml', 'setup.py'
[docs]
def copy_files(self) -> None: # pragma: no cover
"""Ignore copy files yet."""
info('<<lightyellow>>Copy files ...', newline=False)
for filename in self.replace:
file = self.folder / filename
file.rename(self.folder / (filename + '.saved'))
with suppress(FileNotFoundError):
shutil.copy(self.folder / 'scripts' / filename, self.folder)
info('<<lightyellow>>done')
[docs]
def cleanup(self) -> None: # pragma: no cover
"""Ignore cleanup yet."""
info('<<lightyellow>>Copy files ...', newline=False)
for filename in self.replace:
file = self.folder / (filename + '.saved')
file.replace(self.folder / filename)
info('<<lightyellow>>done')
[docs]
def handle_args() -> tuple[bool, bool, bool, bool, bool]:
"""Handle arguments and print documentation if requested.
:return: Return whether dist is to be installed locally or to be
uploaded
"""
if '-help' in sys.argv: # pragma: no cover
import re
import setup
help_text = re.sub(r'^\.\. version(added|changed)::.+', '',
__doc__, flags=re.MULTILINE | re.DOTALL)
info(help_text)
info(setup.__doc__)
sys.exit()
local = '-local' in sys.argv
remote = '-remote' in sys.argv
clear = '-clear' in sys.argv
upgrade = '-upgrade' in sys.argv
scripts = 'scripts' in sys.argv
msg = ''
if not scripts and remote and 'dev' in __version__: # pragma: no cover
msg = 'Distribution must not be a developmental release to upload.'
remote = False
sys.argv = [sys.argv[0]]
return local, remote, clear, upgrade, scripts, msg
[docs]
def main() -> None:
"""Script entry point."""
*args, scripts, msg = handle_args()
installer = SetupScripts if scripts else SetupPywikibot
done = installer(*args).run()
if msg:
warning(f'<<lightred>>{msg}<<default>>')
return done
if __name__ == '__main__':
if not main():
sys.exit(1) # pragma: no cover