Source code for pwb

#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""Wrapper script to invoke pywikibot-based scripts.

Run scripts with pywikibot in directory mode using::

    python pwb.py <pwb options> <name_of_script> <options>

This wrapper script uses the package directory to store all user files,
will fix up search paths so the package does not need to be installed, etc.

Currently `<pwb options>` are :ref:`global options`. This can be used
for tests to set the default site (see T216825)::

    python pwb.py -lang:de bot_tests -v
"""
# (C) Pywikibot team, 2012-2021
#
# Distributed under the terms of the MIT license.
#
# ## KEEP PYTHON 2 SUPPORT FOR THIS SCRIPT ## #
from __future__ import print_function

import os
import sys
import types

from difflib import get_close_matches
from importlib import import_module
from time import sleep
from warnings import warn


pwb = None


[docs]def check_pwb_versions(package): """Validate package version and scripts version. Rules: - Pywikibot version must not be older than scrips version - Scripts version must not be older than previous Pyvikibot version due to deprecation policy """ from pywikibot.tools import Version scripts_version = Version(getattr(package, '__version__', pwb.__version__)) wikibot_version = Version(pwb.__version__) if scripts_version.release > wikibot_version.release: print('WARNING: Pywikibot version {} is behind scripts package ' 'version {}.\nYour Pywikibot may need an update or be ' 'misconfigured.\n'.format(wikibot_version, scripts_version)) # calculate previous minor release if wikibot_version.minor > 0: prev_wikibot = Version('{v.major}.{}.{v.micro}' .format(wikibot_version.minor - 1, v=wikibot_version)) if scripts_version.release < prev_wikibot.release: print('WARNING: Scripts package version {} is behind legacy ' 'Pywikibot version {} and current version {}\nYour scripts ' 'may need an update or be misconfigured.\n' .format(scripts_version, prev_wikibot, wikibot_version)) elif scripts_version.release < wikibot_version.release: print('WARNING: Scripts package version {} is behind current version ' '{}\nYour scripts may need an update or be misconfigured.\n' .format(scripts_version, wikibot_version)) del Version
# The following snippet was developed by Ned Batchelder (and others) # for coverage [1], with Python 3 support [2] added later, # and is available under the BSD license (see [3]) # [1] # https://bitbucket.org/ned/coveragepy/src/b5abcee50dbe/coverage/execfile.py # [2] # https://bitbucket.org/ned/coveragepy/src/fd5363090034/coverage/execfile.py # [3] # https://bitbucket.org/ned/coveragepy/src/2c5fb3a8b81c/setup.py?at=default#cl-31
[docs]def run_python_file(filename, argv, argvu, package=None): """Run a python file as if it were the main program on the command line. `filename` is the path to the file to execute, it need not be a .py file. `args` is the argument array to present as sys.argv, as unicode strings. """ # Create a module to serve as __main__ old_main_mod = sys.modules['__main__'] main_mod = types.ModuleType('__main__') sys.modules['__main__'] = main_mod main_mod.__file__ = filename main_mod.__builtins__ = sys.modules['builtins'] if package: main_mod.__package__ = package.__name__ check_pwb_versions(package) # Set sys.argv and the first path element properly. old_argv = sys.argv old_argvu = pwb.argvu sys.argv = argv pwb.argvu = argvu sys.path.insert(0, os.path.dirname(filename)) try: with open(filename, 'rb') as f: source = f.read() exec(compile(source, filename, 'exec', dont_inherit=True), main_mod.__dict__) finally: # Restore the old __main__ sys.modules['__main__'] = old_main_mod # Restore the old argv and path sys.argv = old_argv sys.path.pop(0) pwb.argvu = old_argvu
# end of snippet from coverage
[docs]def abspath(path): """Convert path to absolute path, with uppercase drive letter on win32.""" path = os.path.abspath(path) if path[0] != '/': # normalise Windows drive letter path = path[0].upper() + path[1:] return path
[docs]def handle_args(pwb_py, *args): """Handle args and get filename. :return: filename, script args, local args for pwb.py :rtype: tuple """ fname = None index = 0 for arg in args: if arg.startswith('-'): index += 1 else: fname = arg if not fname.endswith('.py'): fname += '.py' break return fname, list(args[index + int(bool(fname)):]), args[:index]
def _print_requirements(requirements, script, variant): """Print pip command to install requirements.""" if not requirements: return if len(requirements) > 1: format_string = '\nPackages necessary for {} are {}.' else: format_string = '\nA package necessary for {} is {}.' print(format_string.format(script or 'pywikibot', variant)) print('Please update required module{} with:\n\n' .format('s' if len(requirements) > 1 else '')) for requirement in requirements: print(' pip install "{}"\n' .format(str(requirement).partition(';')[0]))
[docs]def check_modules(script=None): """Check whether mandatory modules are present. This also checks Python version when importing deptendencies from setup.py :param script: The script name to be checked for dependencies :type script: str or None :return: True if all dependencies are installed :rtype: bool :raise RuntimeError: wrong Python version found in setup.py """ import pkg_resources from setup import dependencies, script_deps missing_requirements = [] version_conflicts = [] try: requirement = next(pkg_resources.parse_requirements(dependencies)) except ValueError as e: # T286980: setuptools is too old and requirement parsing fails import setuptools setupversion = tuple(int(num) for num in setuptools.__version__.split('.')) if setupversion < (20, 8, 1): # print the minimal requirement _print_requirements(['setuptools==20.8.1'], None, 'outdated ({})'.format(setuptools.__version__)) return False raise e if script: dependencies = script_deps.get(Path(script).name, []) for requirement in pkg_resources.parse_requirements(dependencies): if requirement.marker is None \ or pkg_resources.evaluate_marker(str(requirement.marker)): try: pkg_resources.resource_exists(requirement, requirement.name) except pkg_resources.DistributionNotFound as e: missing_requirements.append(requirement) print(e) except pkg_resources.VersionConflict as e: version_conflicts.append(requirement) print(e) del pkg_resources del dependencies del script_deps _print_requirements(missing_requirements, script, 'missing') _print_requirements(version_conflicts, script, 'outdated') if version_conflicts and not missing_requirements: print('\nYou may continue on your own risk; type CTRL-C to stop.') try: sleep(5) except KeyboardInterrupt: return False return not missing_requirements
try: if not check_modules(): raise RuntimeError('') # no further output needed except RuntimeError as e: # setup.py may also raise RuntimeError sys.exit(e) from pathlib import Path # noqa: E402 filename, script_args, global_args = handle_args(*sys.argv) # Search for user-config.py before creating one. # If successful, user-config.py already exists in one of the candidate # directories. See config.py for details on search order. # Use env var to communicate to config.py pwb.py location (bug T74918). _pwb_dir = os.path.split(__file__)[0] os.environ['PYWIKIBOT_DIR_PWB'] = _pwb_dir try: import pywikibot as pwb except RuntimeError: os.environ['PYWIKIBOT_NO_USER_CONFIG'] = '2' import pywikibot as pwb # user-config.py to be created if filename is not None and not (filename.startswith('generate_') or filename == 'version.py'): print("NOTE: 'user-config.py' was not found!") print('Please follow the prompts to create it:') run_python_file(os.path.join(_pwb_dir, 'generate_user_files.py'), ['generate_user_files.py'], ['generate_user_files.py']) # because we have loaded pywikibot without user-config.py loaded, # we need to re-start the entire process. Ask the user to do so. print('Now, you have to re-execute the command to start your script.') sys.exit(1) except ImportError as e: # raised in textlib sys.exit(e)
[docs]def find_alternates(filename, script_paths): """Search for similar filenames in the given script paths.""" from pywikibot import config, input_choice, output from pywikibot.bot import QuitKeyboardInterrupt, ShowingListOption from pywikibot.tools.formatter import color_format assert config.pwb_close_matches > 0, \ 'config.pwb_close_matches must be greater than 0' assert 0.0 < config.pwb_cut_off < 1.0, \ 'config.pwb_cut_off must be a float in range [0, 1]' print('ERROR: {} not found! Misspelling?'.format(filename), file=sys.stderr) scripts = {} script_paths = [['.']] + script_paths # add current directory for path in script_paths: for script_name in os.listdir(os.path.join(*path)): # remove .py for better matching name, _, suffix = script_name.rpartition('.') if suffix == 'py' and not name.startswith('__'): scripts[name] = os.path.join(*(path + [script_name])) filename = filename[:-3] similar_scripts = get_close_matches(filename, scripts, config.pwb_close_matches, config.pwb_cut_off) if not similar_scripts: return None if len(similar_scripts) == 1: script = similar_scripts[0] wait_time = config.pwb_autostart_waittime output(color_format( 'NOTE: Starting the most similar script ' '{lightyellow}{0}.py{default}\n' ' in {1} seconds; type CTRL-C to stop.', script, wait_time)) try: sleep(wait_time) # Wait a bit to let it be cancelled except KeyboardInterrupt: return None else: msg = '\nThe most similar scripts are:' alternatives = ShowingListOption(similar_scripts, pre=msg, post='') try: prefix, script = input_choice('Which script to be run:', alternatives, default='1') except QuitKeyboardInterrupt: return None print() return scripts[script]
[docs]def find_filename(filename): """Search for the filename in the given script paths.""" from pywikibot import config script_paths = ['scripts.userscripts', 'scripts', 'scripts.maintenance'] if config.user_script_paths: if isinstance(config.user_script_paths, list): script_paths = config.user_script_paths + script_paths else: warn("'user_script_paths' must be a list,\n" 'found: {}. Ignoring this setting.' .format(type(config.user_script_paths))) path_list = [] for file_package in script_paths: package = file_package.split('.') paths = package + [filename] testpath = os.path.join(_pwb_dir, *paths) if os.path.exists(testpath): filename = testpath break path_list.append(package) else: filename = find_alternates(filename, path_list) return filename
[docs]def main(): """Command line entry point.""" global filename if global_args: # don't use sys.argv unknown_args = pwb.handle_args(global_args) if unknown_args: print('ERROR: unknown pwb.py argument{}: {}\n' .format('' if len(unknown_args) == 1 else 's', ', '.join(unknown_args))) return False if not filename: return False file_package = None argvu = pwb.argvu[1:] if not os.path.exists(filename): filename = find_filename(filename) if filename is None: return True # When both pwb.py and the filename to run are within the current # working directory: # a) set __package__ as if called using python -m scripts.blah.foo # b) set __file__ to be relative, so it can be relative in backtraces, # and __file__ *appears* to be an unstable path to load data from. # This is a rough (and quick!) emulation of 'package name' detection. # a much more detailed implementation is in coverage's find_module. # https://bitbucket.org/ned/coveragepy/src/default/coverage/execfile.py cwd = abspath(os.getcwd()) absolute_path = abspath(os.path.dirname(sys.argv[0])) if absolute_path == cwd: absolute_filename = abspath(filename)[:len(cwd)] if absolute_filename == cwd: relative_filename = os.path.relpath(filename) # remove the filename, and use '.' instead of path separator. file_package = os.path.dirname( relative_filename).replace(os.sep, '.') filename = os.path.join(os.curdir, relative_filename) module = None if file_package and file_package not in sys.modules: try: module = import_module(file_package) except ImportError as e: warn('Parent module {} not found: {}' .format(file_package, e), ImportWarning) help_option = any(arg.startswith('-help:') or arg == '-help' for arg in script_args) if check_modules(filename) or help_option: run_python_file(filename, [filename] + script_args, [Path(filename).stem] + argvu[1:], module) return True
if __name__ == '__main__': if not main(): print(__doc__)