Source code for pywikibot.scripts.generate_user_files

#!/usr/bin/env python3
"""Script to create user-config.py. Other file names are not supported.

.. versionchanged:: 7.0
   moved to pywikibot.scripts folder.
.. versionchanged:: 8.0
   let user the choice which section to be copied.
   Also EXTERNAL EDITOR SETTINGS section can be copied.
"""
#
# (C) Pywikibot team, 2010-2023
#
# Distributed under the terms of the MIT license.
#
import codecs
import os
import re
import sys
from collections import namedtuple
from pathlib import Path
from textwrap import fill
from typing import Optional

from pywikibot.backports import Callable, List, Tuple
from pywikibot.scripts import _import_with_no_user_config


PYTHON_VERSION = sys.version_info[:2]


# DISABLED_SECTIONS cannot be copied; variables must be set manually
DISABLED_SECTIONS = {
    'USER INTERFACE SETTINGS',  # uses sys
}
OBSOLETE_SECTIONS = {
    'ACCOUNT SETTINGS',  # already set
}
SCRIPT_SECTIONS = {
    'INTERWIKI SETTINGS',
    'SOLVE_DISAMBIGUATION SETTINGS',
    'WEBLINK CHECKER SETTINGS',
    'REPLICATION BOT SETTINGS',
}

# Disable user-config usage as we are creating it here
pywikibot = _import_with_no_user_config('pywikibot')
config, __url__ = pywikibot.config, pywikibot.__url__
base_dir = pywikibot.config.base_dir

try:
    console_encoding = sys.stdout.encoding
# unittests fails with "StringIO instance has no attribute 'encoding'"
except AttributeError:
    console_encoding = None

if console_encoding is None or sys.platform == 'cygwin':
    console_encoding = 'iso-8859-1'

USER_BASENAME = 'user-config.py'
PASS_BASENAME = 'user-password.py'


[docs]def change_base_dir(): """Create a new user directory.""" while True: new_base = pywikibot.input('New user directory? ') new_base = os.path.abspath(new_base) if os.path.exists(new_base): if os.path.isfile(new_base): pywikibot.error('there is an existing file with that name.') continue # make sure user can read and write this directory if not os.access(new_base, os.R_OK | os.W_OK): pywikibot.error('directory access restricted') continue pywikibot.info('Using existing directory') else: try: os.mkdir(new_base, pywikibot.config.private_files_permission) except Exception as e: pywikibot.error(f'directory creation failed: {e}') continue pywikibot.info('Created new directory.') break if new_base == pywikibot.config.get_base_dir(new_base): # config would find that file return new_base msg = fill("""WARNING: Your user files will be created in the directory '{new_base}' you have chosen. To access these files, you will either have to use the argument "-dir:{new_base}" every time you run the bot, or set the environment variable "PYWIKIBOT_DIR" equal to this directory name in your operating system. See your operating system documentation for how to set environment variables.""".format(new_base=new_base), width=76) pywikibot.info(msg) if pywikibot.input_yn('Is this OK?', default=False, automatic_quit=False): return new_base pywikibot.info('Aborting changes.') return False
[docs]def file_exists(filename) -> bool: """Return whether the file exists and print a message if it exists.""" if os.path.exists(filename): pywikibot.info('{1} already exists in the target directory "{0}".' .format(*os.path.split(filename))) return True return False
[docs]def get_site_and_lang( default_family: Optional[str] = 'wikipedia', default_lang: Optional[str] = 'en', default_username: Optional[str] = None, force: bool = False ) -> Tuple[str, str, str]: """ Ask the user for the family, site code and username. :param default_family: The default family which should be chosen. :param default_lang: The default site code which should be chosen, if the family supports it. :param default_username: The default username which should be chosen. :return: The family, site code and username """ known_families = sorted(pywikibot.config.family_files.keys()) if default_family not in known_families: default_family = None fam = pywikibot.bot.input_list_choice( 'Select family of sites we are working on, ' 'just enter the number or name', known_families, force=force, default=default_family) fam = pywikibot.family.Family.load(fam) if hasattr(fam, 'langs'): if hasattr(fam, 'languages_by_size'): by_size = [code for code in fam.languages_by_size if code in fam.langs] else: by_size = [] known_langs = by_size + sorted( set(fam.langs.keys()).difference(by_size)) else: known_langs = [] if not known_langs: pywikibot.info(f'There were no known site codes found in {fam.name}.') default_lang = None elif len(known_langs) == 1: pywikibot.info(f'The only known site code: {known_langs[0]}') default_lang = known_langs[0] else: if not force: pywikibot.info('This is the list of known site codes:') pywikibot.info(', '.join(known_langs)) if default_lang not in known_langs: if default_lang != 'en' and 'en' in known_langs: default_lang = 'en' else: default_lang = None message = "The site code of the site we're working on" mycode = None while not mycode: mycode = pywikibot.input(message, default=default_lang, force=force) if known_langs and mycode and mycode not in known_langs \ and not pywikibot.input_yn( fill('The site code {!r} is not in the list of known sites. ' 'Do you want to continue?'.format(mycode)), default=False, automatic_quit=False): mycode = None message = f'Username on {mycode}:{fam.name}' username = pywikibot.input(message, default=default_username, force=force) # Escape ''s if username: username = username.replace("'", "\\'") return fam.name, mycode, username
EXTENDED_CONFIG = """\ # This is an automatically generated file. You can find more # configuration parameters in 'config.py' file or refer # https://doc.wikimedia.org/pywikibot/master/api_ref/pywikibot.config.html from typing import Optional, Union from pywikibot.backports import Dict, List, Tuple # The family of sites to be working on. # Pywikibot will import families/xxx_family.py so if you want to change # this variable, you have to ensure that such a file exists. You may use # generate_family_file to create one. family = '{main_family}' # The site code (language) of the site to be working on. mylang = '{main_code}' # The dictionary usernames should contain a username for each site where you # have a bot account. If you have a unique username for all sites of a # family , you can use '*' {usernames} # The list of BotPasswords is saved in another file. Import it if needed. # See https://www.mediawiki.org/wiki/Manual:Pywikibot/BotPasswords to know how # use them. {botpasswords} {config_text}""" SMALL_CONFIG = """\ family = '{main_family}' mylang = '{main_code}' {usernames} {botpasswords} """ PASSFILE_CONFIG = """\ # This is an automatically generated file used to store # BotPasswords. # # As a simpler (but less secure) alternative to OAuth, MediaWiki allows bot # users to uses BotPasswords to limit the permissions given to a bot. # When using BotPasswords, each instance gets keys. This combination can only # access the API, not the normal web interface. # # See https://www.mediawiki.org/wiki/Manual:Pywikibot/BotPasswords for more # information. {botpasswords}""" ConfigSection = namedtuple('ConfigSection', 'head, info, section')
[docs]def parse_sections() -> list: """Parse sections from config.py file. config.py will be in the pywikibot/ directory whereas generate_user_files script is in pywikibot/scripts. :return: a list of ConfigSection named tuples. """ data = [] config_path = Path(__file__).resolve().parents[1].joinpath('config.py') with codecs.open(config_path, 'r', 'utf-8') as config_f: config_file = config_f.read() result = re.findall( '^(?P<section># #{5,} (?P<head>[A-Z][A-Z_ ]+[A-Z]) #{5,}\r?\n' '(?:^#?\r?\n)?' # There may be an empty or short line after header '(?P<comment>(?:^# .+?)+)' # first comment is used as help string '^.*?)' # catch the remaining text '^(?=# #{5,}|# ={5,})', # until section end marker config_file, re.MULTILINE | re.DOTALL) for section, head, comment in result: info = ' '.join(text.strip('# ') for text in comment.splitlines()) data.append(ConfigSection(head, info, section)) return data
[docs]def copy_sections(force: bool = False, default: str = 'n') -> str: """Take config sections and copy them to user-config.py. .. versionchanged:: 8.0 *force* and *default* options were added. :param force: Copy all sections if force is True :param default: Default answer for input_sections. Should be 'a' for all or 'n' for none to copy. :return: config text of all selected sections. """ sections = parse_sections() if not sections: # Something is wrong with the regex return None # copy framework settings copies = input_sections( 'framework', sections, force=force, default=default, skip=lambda x: x.head not in (DISABLED_SECTIONS | OBSOLETE_SECTIONS | SCRIPT_SECTIONS)) # copy scripts settings copies += input_sections( 'scripts', sections, force=force, default=default, skip=lambda x: x.head in SCRIPT_SECTIONS) return ''.join(copies)
[docs]def input_sections(variant: str, sections: List['ConfigSection'], skip: Optional[Callable] = None, force: bool = False, default: str = 'n') -> None: """Ask for settings to copy. .. versionadded:: 8.0 :param variant: Variant of the setting section. Either 'framework' or 'scripts' :param sections: A sections list previously read from the config file :param skip: a filter function :param force: Force input if True """ # First ask what to do which the whole section type variant select = 'h' answers = [('Yes', 'y'), ('All', 'a'), ('None', 'n'), ('Help', 'h')] while select == 'h': select = pywikibot.input_choice( f'Do you want to select {variant} setting sections?', answers, default=default, force=force, automatic_quit=False) if select == 'h': # pragma: no cover answers.pop(-1) pywikibot.info( f'The following {variant} setting sections are provided:') for item in filter(skip, sections): pywikibot.info(item.head) copies = [] # Now ask for a single section of the given variant # or use all or none of them choice = {'a': 'all', 'n': 'none', 'y': 'h'}[select] # mapping for item in filter(skip, sections): answers = [('Yes', 'y'), ('No', 'n'), ('Help', 'h')] while choice == 'h': # pragma: no cover choice = pywikibot.input_choice( f'Do you want to add {item.head} section?', answers, default='n', force=force, automatic_quit=False) if choice == 'h': answers.pop(-1) pywikibot.info(fill(item.info)) if choice in ('all', 'y'): copies.append(item.section) # Fill the mutable directly if choice not in ('all', 'none'): choice = 'h' return copies
[docs]def create_user_config( main_family, main_code, main_username, force: bool = False ): """ Create a user-config.py in base_dir. Create a user-password.py if necessary. """ _fnc = os.path.join(base_dir, USER_BASENAME) _fncpass = os.path.join(base_dir, PASS_BASENAME) useritem = namedtuple('useritem', 'family, code, name') userlist = [] if force and not config.verbose_output: if main_username: userlist = [useritem(main_family, main_code, main_username)] else: while True: userlist += [useritem(*get_site_and_lang( main_family, main_code, main_username, force=force))] if not pywikibot.input_yn('Do you want to add any other projects?', force=force, default=False, automatic_quit=False): break # For each different username entered, ask if user wants to save a # BotPassword (username, BotPassword name, BotPassword pass) msg = fill('See {}/BotPasswords to know how to get codes.' 'Please note that plain text in {} and anyone with read ' 'access to that directory will be able read the file.' .format(__url__, _fncpass)) botpasswords = [] userset = {user.name for user in userlist} for username in userset: if pywikibot.input_yn('Do you want to add a BotPassword for {}?' .format(username), force=force, default=False): if msg: pywikibot.info(msg) msg = None message = f'BotPassword\'s "bot name" for {username}' botpasswordname = pywikibot.input(message, force=force) message = 'BotPassword\'s "password" for "{}" ' \ '(no characters will be shown)' \ .format(botpasswordname) botpasswordpass = pywikibot.input(message, force=force, password=True) if botpasswordname and botpasswordpass: botpasswords.append((username, botpasswordname, botpasswordpass)) if not userlist: # Show a sample usernames = ( f"# usernames['{main_family}']['{main_code}'] = 'MyUsername'") else: usernames = '\n'.join( "usernames['{user.family}']['{user.code}'] = '{user.name}'" .format(user=user) for user in userlist) # Arbitrarily use the first key as default settings main_family, main_code = userlist[0].family, userlist[0].code botpasswords = '\n'.join( "('{}', BotPassword('{}', {!r}))".format(*botpassword) for botpassword in botpasswords) config_text = copy_sections(force=force) if config_text: config_content = EXTENDED_CONFIG else: pywikibot.info('Creating a small variant of user-config.py') config_content = SMALL_CONFIG try: # Finally save user-config.py with codecs.open(_fnc, 'w', 'utf-8') as f: f.write(config_content.format( main_family=main_family, main_code=main_code, usernames=usernames, config_text=config_text, botpasswords='password_file = ' + (f'"{PASS_BASENAME}"' if botpasswords else 'None'))) pywikibot.info(f"'{_fnc}' written.") except BaseException: if os.path.exists(_fnc): os.remove(_fnc) raise save_botpasswords(botpasswords, _fncpass)
[docs]def save_botpasswords(botpasswords, _fncpass): """Write botpasswords to file.""" if botpasswords: # Save user-password.py if necessary # user-config.py is already created at this point # therefore pywikibot.tools can be imported safely from pywikibot.tools import file_mode_checker try: # First create an empty file with good permissions, before writing # in it with codecs.open(_fncpass, 'w', 'utf-8') as f: f.write('') file_mode_checker(_fncpass, mode=config.private_files_permission, quiet=True) with codecs.open(_fncpass, 'w', 'utf-8') as f: f.write(PASSFILE_CONFIG.format(botpasswords=botpasswords)) file_mode_checker(_fncpass, mode=config.private_files_permission) pywikibot.info(f"'{_fncpass}' written.") except OSError: os.remove(_fncpass) raise
[docs]def ask_for_dir_change(force) -> Tuple[bool, bool]: """Ask whether the base directory is has to be changed. Only give option for directory change if user-config.py or user-password already exists in the directory. This will repeat if user-config.py also exists in the requested directory. :param force: Skip asking for directory change :type force: bool :return: whether user file or password file exists already """ global base_dir pywikibot.info(f'\nYour default user directory is "{base_dir}"') while True: # Show whether file exists userfile = file_exists(os.path.join(base_dir, USER_BASENAME)) passfile = file_exists(os.path.join(base_dir, PASS_BASENAME)) if force and not config.verbose_output or not (userfile or passfile): break if pywikibot.input_yn( 'Would you like to change the directory?', default=True, automatic_quit=False, force=force): new_base = change_base_dir() if new_base: base_dir = new_base else: break return userfile, passfile
[docs]def main(*args: str) -> None: """ Process command line arguments and generate user-config. If args is an empty list, sys.argv is used. :param args: command line arguments """ # set the config family and mylang values to an invalid state so that # the script can detect that the command line arguments -family & -lang # or -site were used and handle_args has updated these config values, # and 'force' mode can be activated below. config.family, config.mylang = 'wikipedia', None local_args = pywikibot.handle_args(args) if local_args: pywikibot.info('Unknown argument{}: {}' .format('s' if len(local_args) > 1 else '', ', '.join(local_args))) return pywikibot.info('You can abort at any time by pressing ctrl-c') if config.mylang is not None: force = True pywikibot.info('Automatically generating user-config.py') else: force = False # Force default site of en.wikipedia config.family, config.mylang = 'wikipedia', 'en' username = config.usernames[config.family].get(config.mylang) try: has_userfile, has_passfile = ask_for_dir_change(force) if not (has_userfile or has_passfile): create_user_config(config.family, config.mylang, username, force=force) except KeyboardInterrupt: pywikibot.info('\nScript terminated by user.')
# Creation of user-fixes.py has been replaced by an example file. if __name__ == '__main__': main()