forked from wikimedia/pywikibot
-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathbot.py
2491 lines (2045 loc) · 91.3 KB
/
bot.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
User-interface related functions for building bots.
This module supports several different bot classes which could be used in
conjunction. Each bot should subclass at least one of these four classes:
* :py:obj:`BaseBot`: Basic bot class in case where the site is handled
differently, like working on multiple sites in parallel. No site
attribute is provided. Instead site of the current page should be used.
This class should normally not be used directly.
* :py:obj:`SingleSiteBot`: Bot class which should only be run on a
single site. They usually store site specific content and thus can't
be easily run when the generator returns a page on another site. It
has a property ``site`` which can also be changed. If the generator
returns a page of a different site it'll skip that page.
* :py:obj:`MultipleSitesBot`: An alias of :py:obj:`BaseBot`. Should not
be used if any other bot class is used.
* :py:obj:`ConfigParserBot`: Bot class which supports reading options from a
scripts.ini configuration file. That file consists of sections, led by a
``[section]`` header and followed by ``option: value`` or ``option=value``
entries. The section is the script name without .py suffix. All options
identified must be predefined in available_options dictionary.
* :py:obj:`Bot`: The previous base class which should be avoided. This
class is mainly used for bots which work with Wikibase or together
with an image repository.
Additionally there is the :py:obj:`CurrentPageBot` class which
automatically sets the current page to the page treated. It is
recommended to use this class and to use ``treat_page`` instead of
``treat`` and ``put_current`` instead of ``userPut``. It by default
subclasses the ``BaseBot`` class.
With :py:obj:`CurrentPageBot` it's possible to subclass one of the
following classes to filter the pages which are ultimately handled by
``treat_page``:
* :py:obj:`ExistingPageBot`: Only handle pages which do exist.
* :py:obj:`CreatingPageBot`: Only handle pages which do not exist.
* :py:obj:`RedirectPageBot`: Only handle pages which are redirect pages.
* :py:obj:`NoRedirectPageBot`: Only handle pages which are not redirect pages.
* :py:obj:`FollowRedirectPageBot`: If the generator returns a redirect
page it'll follow the redirect and instead work on the redirected class.
It is possible to combine filters by subclassing multiple of them. They are
new-style classes so when a class is first subclassing
:py:obj:`ExistingPageBot` and then :py:obj:`FollowRedirectPageBot` it
will also work on pages which do not exist when a redirect pointed to
that. If the order is inversed it'll first follow them and then check
whether they exist.
Additionally there is the :py:obj:`AutomaticTWSummaryBot` which subclasses
:py:obj:`CurrentPageBot` and automatically defines the summary when
``put_current`` is used.
"""
#
# (C) Pywikibot team, 2008-2022
#
# Distributed under the terms of the MIT license.
#
__all__ = (
'CRITICAL', 'ERROR', 'INFO', 'WARNING', 'DEBUG', 'INPUT', 'STDOUT',
'VERBOSE', 'critical', 'debug', 'error', 'exception', 'log', 'warning',
'output', 'stdout', 'LoggingFormatter',
'set_interface', 'init_handlers', 'writelogheader',
'input', 'input_choice', 'input_yn', 'input_list_choice', 'ui',
'Option', 'StandardOption', 'NestedOption', 'IntegerOption',
'ContextOption', 'ListOption', 'ShowingListOption', 'MultipleChoiceList',
'ShowingMultipleChoiceList', 'OutputProxyOption',
'HighlightContextOption', 'ChoiceException', 'UnhandledAnswer',
'Choice', 'StaticChoice', 'LinkChoice', 'AlwaysChoice',
'QuitKeyboardInterrupt',
'InteractiveReplace',
'calledModuleName', 'handle_args',
'show_help', 'suggest_help',
'writeToCommandLogFile', 'open_webbrowser',
'OptionHandler',
'BaseBot', 'Bot', 'ConfigParserBot', 'SingleSiteBot', 'MultipleSitesBot',
'CurrentPageBot', 'AutomaticTWSummaryBot',
'ExistingPageBot', 'FollowRedirectPageBot', 'CreatingPageBot',
'RedirectPageBot', 'NoRedirectPageBot',
'WikidataBot',
)
import atexit
import codecs
import configparser
import datetime
import json
import logging
import logging.handlers
import os
import re
import sys
import time
import warnings
import webbrowser
from collections import Counter
from collections.abc import Container, Generator
from contextlib import closing
from functools import wraps
from importlib import import_module
from pathlib import Path
from textwrap import fill
from typing import TYPE_CHECKING, Any, Optional, Union
from warnings import warn
import pywikibot
from pywikibot import config, daemonize, i18n, version
from pywikibot.backports import (
Callable,
Dict,
Iterable,
List,
Mapping,
Sequence,
Tuple,
)
from pywikibot.bot_choice import (
AlwaysChoice,
Choice,
ChoiceException,
ContextOption,
HighlightContextOption,
IntegerOption,
LinkChoice,
ListOption,
MultipleChoiceList,
NestedOption,
Option,
OutputProxyOption,
QuitKeyboardInterrupt,
ShowingListOption,
ShowingMultipleChoiceList,
StandardOption,
StaticChoice,
UnhandledAnswer,
)
from pywikibot.exceptions import (
ArgumentDeprecationWarning,
EditConflictError,
Error,
LockedPageError,
NoPageError,
PageSaveRelatedError,
ServerError,
SpamblacklistError,
UnknownFamilyError,
UnknownSiteError,
VersionParseError,
WikiBaseError,
)
from pywikibot.logging import (
CRITICAL,
DEBUG,
ERROR,
INFO,
INPUT,
STDOUT,
VERBOSE,
WARNING,
add_init_routine,
critical,
debug,
error,
exception,
log,
output,
stdout,
warning,
)
from pywikibot.throttle import Throttle
from pywikibot.tools import (
PYTHON_VERSION,
deprecated,
issue_deprecation_warning,
strtobool,
)
from pywikibot.tools._logging import LoggingFormatter
if TYPE_CHECKING:
from pywikibot.site import BaseSite
AnswerType = Union[
Iterable[Union[Tuple[str, str], 'pywikibot.bot_choice.Option']],
'pywikibot.bot_choice.Option',
]
PageLinkType = Union['pywikibot.page.Link', 'pywikibot.page.Page']
_GLOBAL_HELP = """
GLOBAL OPTIONS
==============
(Global arguments available for all bots)
-dir:PATH Read the bot's configuration data from directory given
by PATH, instead of from the default directory.
-config:xyn The user config filename. Default is user-config.py.
-lang:xx Set the language of the wiki you want to work on,
overriding the configuration in user config file.
xx should be the site code.
-family:xyz Set the family of the wiki you want to work on, e.g.
wikipedia, wiktionary, wikivoyage, ... This will
override the configuration in user config file.
-site:xyz:xx Set the wiki site you want to work on, e.g.
wikipedia:test, wiktionary:de, wikivoyage:en, ... This
will override the configuration in user config file.
-user:xyz Log in as user 'xyz' instead of the default username.
-daemonize:xyz Immediately return control to the terminal and redirect
stdout and stderr to file xyz.
(only use for bots that require no input from stdin).
-help Show this help text.
-log Enable the log file, using the default filename
'{}-bot.log'
Logs will be stored in the logs subdirectory.
-log:xyz Enable the log file, using 'xyz' as the filename.
-nolog Disable the log file (if it is enabled by default).
-maxlag Sets a new maxlag parameter to a number of seconds.
Defer bot edits during periods of database server lag.
Default is set by config.py
-putthrottle:n Set the minimum time (in seconds) the bot will wait
-pt:n between saving pages.
-put_throttle:n
-debug:item Enable the log file and include extensive debugging
-debug data for component "item" (for all components if the
second form is used).
-verbose Have the bot provide additional console output that may be
-v useful in debugging.
-cosmeticchanges Toggles the cosmetic_changes setting made in config.py
-cc or user config file to its inverse and overrules it.
All other settings and restrictions are untouched. The
setting may also be given directly like `-cc:True`;
accepted values for the option are `1`, `yes`, `true`,
`on`, `y`, `t` for True and `0`, `no`, `false`, `off`,
`n`, `f` for False. Values are case-insensitive.
-simulate Disables writing to the server. Useful for testing and
debugging of new code (if given, doesn't do any real
changes, but only shows what would have been changed).
An integer or float value may be given to simulate a
processing time; the bot just waits for given seconds.
-<config var>:n You may use all given numeric config variables as
option and modify it with command line.
"""
_GLOBAL_HELP_NOTE = """
GLOBAL OPTIONS
==============
For global options use -help:global or run pwb.py -help
"""
ui: Optional[pywikibot.userinterfaces._interface_base.ABUIC] = None
"""Holds a user interface object defined in
:mod:`pywikibot.userinterfaces` subpackage.
"""
def set_interface(module_name: str) -> None:
"""Configures any bots to use the given interface module.
Search for user interface module in the
:mod:`pywikibot.userinterfaces` subdirectory and initialize UI.
Calls :func:`init_handlers` to re-initialize if we were already
initialized with another UI.
.. versionadded:: 6.4
"""
global ui
ui_module = __import__('pywikibot.userinterfaces.{}_interface'
.format(module_name), fromlist=['UI'])
ui = ui_module.UI()
assert ui is not None
atexit.register(ui.flush)
pywikibot.argvu = ui.argvu()
# re-initialize
if _handlers_initialized:
_handlers_initialized.clear()
init_handlers()
_handlers_initialized = [] # we can have a script and the script wrapper
def handler_namer(name: str) -> str:
"""Modify the filename of a log file when rotating.
RotatingFileHandler will save old log files by appending the
extensions ``.1``, ``.2`` etc., to the filename. To keep the
original extension, which is usually ``.log``, this function
swaps the appended counter with the log extension:
>>> handler_namer('add_text.log.1')
'add_text.1.log'
.. versionadded:: 6.5
"""
path, qualifier = name.rsplit('.', 1)
root, ext = os.path.splitext(path)
return f'{root}.{qualifier}{ext}'
def init_handlers() -> None:
"""Initialize the handlers and formatters for the logging system.
This relies on the global variable :attr:`ui` which is a UI object.
.. seealso:: :mod:`pywikibot.userinterfaces`
Calls :func:`writelogheader` after handlers are initialized.
This function must be called before using any input/output methods;
and must be called again if ui handler is changed. Use
:func:`set_interface` to set the new interface which initializes it.
.. note:: this function is called by any user input and output
function, so it should normally not need to be called explicitly.
All user output is routed through the logging module.
Each type of output is handled by an appropriate handler object.
This structure is used to permit eventual development of other
user interfaces (GUIs) without modifying the core bot code.
The following output levels are defined:
- DEBUG: only for file logging; debugging messages.
- STDOUT: output that must be sent to sys.stdout (for bots that may
have their output redirected to a file or other destination).
- VERBOSE: optional progress information for display to user.
- INFO: normal (non-optional) progress information for display to user.
- INPUT: prompts requiring user response.
- WARN: user warning messages.
- ERROR: user error messages.
- CRITICAL: fatal error messages.
.. seealso::
* :mod:`pywikibot.logging`
* :python:`Python Logging Levels<library/logging.html#logging-levels>`
Accordingly, do **not** use print statements in bot code; instead,
use :func:`pywikibot.output` function and other functions from
:mod:`pywikibot.logging` module.
.. versionchanged:: 6.2
Different logfiles are used if multiple processes of the same
script are running.
"""
module_name = calledModuleName()
if not module_name:
module_name = 'terminal-interface'
logging.addLevelName(VERBOSE, 'VERBOSE')
# for messages to be displayed on terminal at "verbose" setting
# use INFO for messages to be displayed even on non-verbose setting
logging.addLevelName(STDOUT, 'STDOUT')
# for messages to be displayed to stdout
logging.addLevelName(INPUT, 'INPUT')
# for prompts requiring user response
root_logger = logging.getLogger('pywiki')
if root_logger.hasHandlers() and module_name in _handlers_initialized:
return
root_logger.setLevel(DEBUG + 1) # all records except DEBUG go to logger
warnings_logger = logging.getLogger('py.warnings')
warnings_logger.setLevel(DEBUG)
# If there are command line warnings options, do not override them
if not sys.warnoptions:
logging.captureWarnings(True)
if config.debug_log or 'deprecation' in config.log:
warnings.filterwarnings('always')
elif config.verbose_output:
warnings.filterwarnings('module')
warnings.filterwarnings('once', category=FutureWarning)
for handler in root_logger.handlers:
handler.close()
root_logger.handlers.clear() # remove any old handlers
root_logger.propagate = False # T281643
# configure handler(s) for display to user interface
assert ui is not None
ui.init_handlers(root_logger, **config.userinterface_init_kwargs)
# if user has enabled file logging, configure file handler
if module_name in config.log or '*' in config.log:
# Use a dummy Throttle to get a PID.
# This is necessary because tests may have site disabled.
throttle = Throttle('')
pid_int = throttle.get_pid(module_name) # get the global PID
pid = str(pid_int) + '-' if pid_int > 1 else ''
if config.logfilename:
# keep config.logfilename unchanged
logfile = config.datafilepath('logs', config.logfilename)
else:
# add PID to logfle name
logfile = config.datafilepath('logs', '{}-{}bot.log'
.format(module_name, pid))
# give up infinite rotating file handler with logfilecount of -1;
# set it to 999 and use the standard implementation
max_count = config.logfilecount
if max_count == -1: # pragma: no cover
max_count = 999
issue_deprecation_warning('config.logfilecount with value -1',
'any positive number',
warning_class=ArgumentDeprecationWarning,
since='6.5.0')
file_handler = logging.handlers.RotatingFileHandler(
filename=logfile,
maxBytes=config.logfilesize << 10,
backupCount=max_count,
encoding='utf-8'
)
file_handler.namer = handler_namer
file_handler.setLevel(DEBUG)
form = LoggingFormatter(
fmt='%(asctime)s %(caller_file)18s, %(caller_line)4s '
'in %(caller_name)18s: %(levelname)-8s %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
file_handler.setFormatter(form)
root_logger.addHandler(file_handler)
# Turn on debugging for each component requested by user
# or for all components if nothing was specified
for component in config.debug_log:
if component:
debuglogger = logging.getLogger('pywiki.' + component)
else:
debuglogger = logging.getLogger('pywiki')
debuglogger.setLevel(DEBUG)
debuglogger.addHandler(file_handler)
warnings_logger.addHandler(file_handler)
_handlers_initialized.append(module_name)
writelogheader()
def writelogheader() -> None:
"""
Save additional version, system and status info to the log file in use.
This may help the user to track errors or report bugs.
"""
log('')
log('=== Pywikibot framework v{} -- Logging header ==='
.format(pywikibot.__version__))
# script call
log(f'COMMAND: {sys.argv}')
# script call time stamp
log(f'DATE: {datetime.datetime.utcnow()} UTC')
# new framework release/revision? (handle_args needs to be called first)
try:
log('VERSION: {}'.format(
version.getversion(online=config.log_pywiki_repo_version).strip()))
except VersionParseError:
exception()
# system
if hasattr(os, 'uname'):
log(f'SYSTEM: {os.uname()}')
# config file dir
log(f'CONFIG FILE DIR: {pywikibot.config.base_dir}')
# These are the main dependencies of pywikibot.
check_package_list = [
'requests',
'mwparserfromhell',
]
# report all imported packages
if config.verbose_output:
check_package_list += sys.modules
log('PACKAGES:')
packages = version.package_versions(check_package_list)
for name in sorted(packages.keys()):
info = packages[name]
info.setdefault('path',
'[{}]'.format(info.get('type', 'path unknown')))
info.setdefault('ver', '??')
if 'err' in info:
log(' {name}: {err}'.format_map(info))
else:
log(' {name} ({path}) = {ver}'.format_map(info))
# imported modules
log('MODULES:')
for module in sys.modules.copy().values():
filename = version.get_module_filename(module)
if not filename:
continue
param = {'sep': ' '}
if PYTHON_VERSION >= (3, 6, 0):
param['timespec'] = 'seconds'
mtime = version.get_module_mtime(module).isoformat(**param)
log(f' {mtime} {filename}')
if config.log_pywiki_repo_version:
log(f'PYWIKI REPO VERSION: {version.getversion_onlinerepo()}')
log('=' * 57)
add_init_routine(init_handlers)
# User input functions
def initialize_handlers(function):
"""Make sure logging system has been initialized.
.. versionadded:: 7.0
"""
@wraps(function)
def wrapper(*args, **kwargs):
init_handlers()
return function(*args, **kwargs)
return wrapper
@initialize_handlers
def input(question: str,
password: bool = False,
default: Optional[str] = '',
force: bool = False) -> str:
"""Ask the user a question, return the user's answer.
:param question: a string that will be shown to the user. Don't add a
space after the question mark/colon, this method will do this for you.
:param password: if True, hides the user's input (for password entry).
:param default: The default answer if none was entered. None to require
an answer.
:param force: Automatically use the default
"""
assert ui is not None
return ui.input(question, password=password, default=default, force=force)
@initialize_handlers
def input_choice(question: str,
answers: AnswerType,
default: Optional[str] = None,
return_shortcut: bool = True,
automatic_quit: bool = True,
force: bool = False) -> Any:
"""
Ask the user the question and return one of the valid answers.
:param question: The question asked without trailing spaces.
:param answers: The valid answers each containing a full length answer and
a shortcut. Each value must be unique.
:param default: The result if no answer was entered. It must not be in the
valid answers and can be disabled by setting it to None. If it should
be linked with the valid answers it must be its shortcut.
:param return_shortcut: Whether the shortcut or the index of the answer is
returned.
:param automatic_quit: Adds the option 'Quit' ('q') and throw a
:py:obj:`QuitKeyboardInterrupt` if selected.
:param force: Automatically use the default
:return: The selected answer shortcut or index. Is -1 if the default is
selected, it does not return the shortcut and the default is not a
valid shortcut.
"""
assert ui is not None
return ui.input_choice(question, answers, default, return_shortcut,
automatic_quit=automatic_quit, force=force)
def input_yn(question: str,
default: Union[bool, str, None] = None,
automatic_quit: bool = True,
force: bool = False) -> bool:
"""
Ask the user a yes/no question and return the answer as a bool.
:param question: The question asked without trailing spaces.
:param default: The result if no answer was entered. It must be a bool or
'y' or 'n' and can be disabled by setting it to None.
:param automatic_quit: Adds the option 'Quit' ('q') and throw a
:py:obj:`QuitKeyboardInterrupt` if selected.
:param force: Automatically use the default
:return: Return True if the user selected yes and False if the user
selected no. If the default is not None it'll return True if default
is True or 'y' and False if default is False or 'n'.
"""
if default not in ['y', 'Y', 'n', 'N']:
if default:
default = 'y'
elif default is not None:
default = 'n'
assert default in ['y', 'Y', 'n', 'N', None], \
'Default choice must be one of YyNn or default'
assert not isinstance(default, bool)
return input_choice(question, [('Yes', 'y'), ('No', 'n')],
default,
automatic_quit=automatic_quit, force=force) == 'y'
@initialize_handlers
def input_list_choice(question: str,
answers: AnswerType,
default: Union[int, str, None] = None,
force: bool = False) -> str:
"""
Ask the user the question and return one of the valid answers.
:param question: The question asked without trailing spaces.
:param answers: The valid answers each containing a full length answer.
:param default: The result if no answer was entered. It must not be in the
valid answers and can be disabled by setting it to None.
:param force: Automatically use the default
:return: The selected answer.
"""
assert ui is not None
return ui.input_list_choice(question, answers, default=default,
force=force)
class InteractiveReplace:
"""
A callback class for textlib's replace_links.
It shows various options which can be switched on and off:
* allow_skip_link = True (skip the current link)
* allow_unlink = True (unlink)
* allow_replace = False (just replace target, keep section and label)
* allow_replace_section = False (replace target and section, keep label)
* allow_replace_label = False (replace target and label, keep section)
* allow_replace_all = False (replace target, section and label)
(The boolean values are the default values)
It has also a ``context`` attribute which must be a non-negative
integer. If it is greater 0 it shows that many characters before and
after the link in question. The ``context_delta`` attribute can be
defined too and adds an option to increase ``context`` by the given
amount each time the option is selected.
Additional choices can be defined using the 'additional_choices' and will
be amended to the choices defined by this class. This list is mutable and
the Choice instance returned and created by this class are too.
"""
def __init__(self,
old_link: PageLinkType,
new_link: Union[PageLinkType, bool],
default: Optional[str] = None,
automatic_quit: bool = True) -> None:
"""
Initializer.
:param old_link: The old link which is searched. The label and section
are ignored.
:param new_link: The new link with which it should be replaced.
Depending on the replacement mode it'll use this link's label and
section. If False it'll unlink all and the attributes beginning
with allow_replace are ignored.
:param default: The default answer as the shortcut
:param automatic_quit: Add an option to quit and raise a
QuitKeyboardException.
"""
if isinstance(old_link, pywikibot.Page):
self._old = old_link._link
else:
self._old = old_link
if isinstance(new_link, pywikibot.Page):
self._new = new_link._link
else:
self._new = new_link
self._default = default
self._quit = automatic_quit
current_match_type = Optional[Tuple[ # skipcq: PYL-W0612
PageLinkType,
str,
Mapping[str, str],
Tuple[int, int]
]]
self._current_match: current_match_type = None
self.context = 30
self.context_delta = 0
self.allow_skip_link = True
self.allow_unlink = True
self.allow_replace = False
self.allow_replace_section = False
self.allow_replace_label = False
self.allow_replace_all = False
# Use list to preserve order
self._own_choices: List[Tuple[str, StandardOption]] = [
('skip_link', StaticChoice('Do not change', 'n', None)),
('unlink', StaticChoice('Unlink', 'u', False)),
]
if self._new:
self._own_choices += [
('replace', LinkChoice('Change link target', 't', self,
False, False)),
('replace_section', LinkChoice(
'Change link target and section', 's', self, True, False)),
('replace_label', LinkChoice('Change link target and label',
'l', self, False, True)),
('replace_all', LinkChoice('Change complete link', 'c', self,
True, True)),
]
self.additional_choices: List[StandardOption] = []
def handle_answer(self, choice: str) -> Any:
"""Return the result for replace_links."""
for c in self.choices:
if isinstance(c, Choice) and c.shortcut == choice:
return c.handle()
raise ValueError(f'Invalid choice "{choice}"')
def __call__(self, link: PageLinkType,
text: str, groups: Mapping[str, str],
rng: Tuple[int, int]) -> Any:
"""Ask user how the selected link should be replaced."""
if self._old == link:
self._current_match = (link, text, groups, rng)
while True:
try:
answer = self.handle_link()
except UnhandledAnswer as e:
if e.stop:
raise
else:
break
self._current_match = None # don't reset in case of an exception
return answer
return None
@property
def choices(self) -> Tuple[StandardOption, ...]:
"""Return the tuple of choices."""
choices = []
for name, choice in self._own_choices:
if getattr(self, 'allow_' + name):
choices += [choice]
if self.context_delta > 0:
choices += [HighlightContextOption(
'more context', 'm', self.current_text, self.context,
self.context_delta, *self.current_range)]
choices += self.additional_choices
return tuple(choices)
def handle_link(self) -> Any:
"""Handle the currently given replacement."""
choices = self.choices
for c in choices:
if isinstance(c, AlwaysChoice) and c.handle_link():
return c.answer
question = 'Should the link '
if self.context > 0:
rng = self.current_range
text = self.current_text
# at the beginning of the link, start red color.
# at the end of the link, reset the color to default
pywikibot.info(text[max(0, rng[0] - self.context): rng[0]]
+ '<<lightred>>{}<<default>>'.format(
text[rng[0]: rng[1]])
+ text[rng[1]: rng[1] + self.context])
else:
question += '<<lightred>>{}<<default>> '.format(
self._old.canonical_title())
if self._new is False:
question += 'be unlinked?'
else:
question += 'target to <<lightpurple>>{}<<default>>?'.format(
self._new.canonical_title())
choice = pywikibot.input_choice(question, choices,
default=self._default,
automatic_quit=self._quit)
assert isinstance(choice, str)
return self.handle_answer(choice)
@property
def current_link(self) -> PageLinkType:
"""Get the current link when it's handling one currently."""
if self._current_match is None:
raise ValueError('No current link')
return self._current_match[0]
@property
def current_text(self) -> str:
"""Get the current text when it's handling one currently."""
if self._current_match is None:
raise ValueError('No current text')
return self._current_match[1]
@property
def current_groups(self) -> Mapping[str, str]:
"""Get the current groups when it's handling one currently."""
if self._current_match is None:
raise ValueError('No current groups')
return self._current_match[2]
@property
def current_range(self) -> Tuple[int, int]:
"""Get the current range when it's handling one currently."""
if self._current_match is None:
raise ValueError('No current range')
return self._current_match[3]
# Command line parsing and help
def calledModuleName() -> str:
"""Return the name of the module calling this function.
This is required because the -help option loads the module's docstring
and because the module name will be used for the filename of the log.
"""
return Path(pywikibot.argvu[0]).stem
def handle_args(args: Optional[Iterable[str]] = None,
do_help: bool = True) -> List[str]:
"""
Handle global command line arguments and return the rest as a list.
Takes the command line arguments as strings, processes all
:ref:`global parameters<global options>` such as ``-lang`` or
``-log``, initialises the logging layer, which emits startup
information into log at level 'verbose'. This function makes sure
that global arguments are applied first, regardless of the order in
which the arguments were given. ``args`` may be passed as an
argument, thereby overriding ``sys.argv``.
>>> local_args = pywikibot.handle_args() # sys.argv is used
>>> local_args # doctest: +SKIP
[]
>>> local_args = pywikibot.handle_args(['-simulate', '-myoption'])
>>> local_args # global optons are handled, show the remaining
['-myoption']
>>> for arg in local_args: pass # do whatever is wanted with local_args
.. versionchanged:: 5.2
*-site* global option was added
.. versionchanged:: 7.1
*-cosmetic_changes* and *-cc* may be set directly instead of
toggling the value. Refer :func:`tools.strtobool` for valid values.
.. versionchanged:: 7.7
*-config* global option was added.
.. versionchanged:: 8.0
Short site value can be given if site code is equal to family
like ``-site:meta``.
:param args: Command line arguments. If None,
:meth:`pywikibot.argvu<userinterfaces._interface_base.ABUIC.argvu>`
is used which is a copy of ``sys.argv``
:param do_help: Handle parameter '-help' to show help and invoke sys.exit
:return: list of arguments not recognised globally
"""
if pywikibot._sites:
warn('Site objects have been created before arguments were handled',
UserWarning)
# get commandline arguments if necessary
if not args:
# it's the version in pywikibot.__init__ that is changed by scripts,
# not the one in pywikibot.bot.
args = pywikibot.argvu[1:]
# get the name of the module calling this function. This is
# required because the -help option loads the module's docstring and
# because the module name will be used for the filename of the log.
module_name = calledModuleName() or 'terminal-interface'
non_global_args = []
username = None
do_help_val: Union[bool, str, None] = None if do_help else False
assert args is not None
for arg in args:
option, _, value = arg.partition(':')
if do_help_val is not False and option == '-help':
do_help_val = value or True
elif option == '-tags':
config.tags = value
# these are handled by config.py
elif option in ('-config', '-dir'):
pass
elif option == '-site':
if ':' in value:
config.family, config.mylang = value.split(':')
else:
config.family = config.mylang = value
elif option == '-family':
config.family = value
elif option == '-lang':
config.mylang = value
elif option == '-user':
username = value
elif option in ('-putthrottle', '-pt'):
config.put_throttle = float(value)
elif option == '-log':
if module_name not in config.log:
config.log.append(module_name)
if value:
config.logfilename = value
elif option == '-nolog':
config.log = []
elif option in ('-cosmeticchanges', '-cc'):
config.cosmetic_changes = (strtobool(value) if value
else not config.cosmetic_changes)
output('NOTE: option cosmetic_changes is {}\n'
.format(config.cosmetic_changes))
elif option == '-simulate':
config.simulate = value or True
#
# DEBUG control:
#
# The framework has four layers (by default, others can be added),
# each designated by a string --
#
# 1. "comm": the communication layer (http requests, etc.)
# 2. "data": the raw data layer (API requests, XML dump parsing)
# 3. "wiki": the wiki content representation layer (Page and Site
# objects)
# 4. "bot": the application layer (user scripts should always
# send any debug() messages to this layer)
#
# The "-debug:layer" flag sets the logger for any specified
# layer to the DEBUG level, causing it to output extensive debugging
# information. Otherwise, the default logging setting is the INFO
# level. "-debug" with no layer specified sets _all_ loggers to
# DEBUG level.
#
# This method does not check the 'layer' part of the flag for
# validity.
#
# If used, "-debug" turns on file logging, regardless of any
# other settings.
#
elif option == '-debug':
if module_name not in config.log:
config.log.append(module_name)
if value not in config.debug_log:
config.debug_log.append(value) # may be empty string
elif option in ('-verbose', '-v'):
config.verbose_output += 1
elif option == '-daemonize':
redirect_std = value or None
daemonize.daemonize(redirect_std=redirect_std)
else:
# the argument depends on numerical config settings
# e.g. -maxlag and -step:
try:
_arg = option[1:]
# explicitly check for int (so bool doesn't match)
if not isinstance(getattr(config, _arg), int):
raise TypeError
setattr(config, _arg, int(value))
except (ValueError, TypeError, AttributeError):
# argument not global -> specific bot script will take care
non_global_args.append(arg)
if calledModuleName() != 'generate_user_files': # T261771