| @@ -0,0 +1,7 @@ | |||
| repos: | |||
| - repo: https://github.com/astral-sh/ruff-pre-commit | |||
| rev: v0.3.5 | |||
| hooks: | |||
| - id: ruff | |||
| args: [--fix] | |||
| - id: ruff-format | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # EarwigBot documentation build configuration file, created by | |||
| # sphinx-quickstart on Sun Apr 29 01:42:25 2012. | |||
| # | |||
| @@ -11,214 +9,209 @@ | |||
| # All configuration values have a default; values that are commented out | |||
| # serve to show the default. | |||
| import sys, os | |||
| import os | |||
| import sys | |||
| # If extensions (or modules to document with autodoc) are in another directory, | |||
| # add these directories to sys.path here. If the directory is relative to the | |||
| # documentation root, use os.path.abspath to make it absolute, like shown here. | |||
| sys.path.insert(0, os.path.abspath('..')) | |||
| sys.path.insert(0, os.path.abspath("..")) | |||
| # -- General configuration ----------------------------------------------------- | |||
| # If your documentation needs a minimal Sphinx version, state it here. | |||
| #needs_sphinx = '1.0' | |||
| # needs_sphinx = '1.0' | |||
| # Add any Sphinx extension module names here, as strings. They can be extensions | |||
| # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. | |||
| extensions = ['sphinx.ext.autodoc', 'sphinx.ext.coverage', 'sphinx.ext.viewcode'] | |||
| extensions = ["sphinx.ext.autodoc", "sphinx.ext.coverage", "sphinx.ext.viewcode"] | |||
| # Add any paths that contain templates here, relative to this directory. | |||
| templates_path = ['_templates'] | |||
| templates_path = ["_templates"] | |||
| # The suffix of source filenames. | |||
| source_suffix = '.rst' | |||
| source_suffix = ".rst" | |||
| # The encoding of source files. | |||
| #source_encoding = 'utf-8-sig' | |||
| # source_encoding = 'utf-8-sig' | |||
| # The master toctree document. | |||
| master_doc = 'index' | |||
| master_doc = "index" | |||
| # General information about the project. | |||
| project = u'EarwigBot' | |||
| copyright = u'2009-2016 Ben Kurtovic' | |||
| project = "EarwigBot" | |||
| copyright = "2009-2016 Ben Kurtovic" | |||
| # The version info for the project you're documenting, acts as replacement for | |||
| # |version| and |release|, also used in various other places throughout the | |||
| # built documents. | |||
| # | |||
| # The short X.Y version. | |||
| version = '0.4' | |||
| version = "0.4" | |||
| # The full version, including alpha/beta/rc tags. | |||
| release = '0.4.dev0' | |||
| release = "0.4.dev0" | |||
| # The language for content autogenerated by Sphinx. Refer to documentation | |||
| # for a list of supported languages. | |||
| #language = None | |||
| # language = None | |||
| # There are two options for replacing |today|: either, you set today to some | |||
| # non-false value, then it is used: | |||
| #today = '' | |||
| # today = '' | |||
| # Else, today_fmt is used as the format for a strftime call. | |||
| #today_fmt = '%B %d, %Y' | |||
| # today_fmt = '%B %d, %Y' | |||
| # List of patterns, relative to source directory, that match files and | |||
| # directories to ignore when looking for source files. | |||
| exclude_patterns = ['_build'] | |||
| exclude_patterns = ["_build"] | |||
| # The reST default role (used for this markup: `text`) to use for all documents. | |||
| #default_role = None | |||
| # default_role = None | |||
| # If true, '()' will be appended to :func: etc. cross-reference text. | |||
| #add_function_parentheses = True | |||
| # add_function_parentheses = True | |||
| # If true, the current module name will be prepended to all description | |||
| # unit titles (such as .. function::). | |||
| #add_module_names = True | |||
| # add_module_names = True | |||
| # If true, sectionauthor and moduleauthor directives will be shown in the | |||
| # output. They are ignored by default. | |||
| #show_authors = False | |||
| # show_authors = False | |||
| # The name of the Pygments (syntax highlighting) style to use. | |||
| pygments_style = 'sphinx' | |||
| pygments_style = "sphinx" | |||
| # A list of ignored prefixes for module index sorting. | |||
| #modindex_common_prefix = [] | |||
| # modindex_common_prefix = [] | |||
| # -- Options for HTML output --------------------------------------------------- | |||
| # The theme to use for HTML and HTML Help pages. See the documentation for | |||
| # a list of builtin themes. | |||
| html_theme = 'nature' | |||
| html_theme = "nature" | |||
| # Theme options are theme-specific and customize the look and feel of a theme | |||
| # further. For a list of options available for each theme, see the | |||
| # documentation. | |||
| #html_theme_options = {} | |||
| # html_theme_options = {} | |||
| # Add any paths that contain custom themes here, relative to this directory. | |||
| #html_theme_path = [] | |||
| # html_theme_path = [] | |||
| # The name for this set of Sphinx documents. If None, it defaults to | |||
| # "<project> v<release> documentation". | |||
| #html_title = None | |||
| # html_title = None | |||
| # A shorter title for the navigation bar. Default is the same as html_title. | |||
| #html_short_title = None | |||
| # html_short_title = None | |||
| # The name of an image file (relative to this directory) to place at the top | |||
| # of the sidebar. | |||
| #html_logo = None | |||
| # html_logo = None | |||
| # The name of an image file (within the static path) to use as favicon of the | |||
| # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 | |||
| # pixels large. | |||
| #html_favicon = None | |||
| # html_favicon = None | |||
| # Add any paths that contain custom static files (such as style sheets) here, | |||
| # relative to this directory. They are copied after the builtin static files, | |||
| # so a file named "default.css" will overwrite the builtin "default.css". | |||
| html_static_path = ['_static'] | |||
| html_static_path = ["_static"] | |||
| # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, | |||
| # using the given strftime format. | |||
| #html_last_updated_fmt = '%b %d, %Y' | |||
| # html_last_updated_fmt = '%b %d, %Y' | |||
| # If true, SmartyPants will be used to convert quotes and dashes to | |||
| # typographically correct entities. | |||
| #html_use_smartypants = True | |||
| # html_use_smartypants = True | |||
| # Custom sidebar templates, maps document names to template names. | |||
| #html_sidebars = {} | |||
| # html_sidebars = {} | |||
| # Additional templates that should be rendered to pages, maps page names to | |||
| # template names. | |||
| #html_additional_pages = {} | |||
| # html_additional_pages = {} | |||
| # If false, no module index is generated. | |||
| #html_domain_indices = True | |||
| # html_domain_indices = True | |||
| # If false, no index is generated. | |||
| #html_use_index = True | |||
| # html_use_index = True | |||
| # If true, the index is split into individual pages for each letter. | |||
| #html_split_index = False | |||
| # html_split_index = False | |||
| # If true, links to the reST sources are added to the pages. | |||
| #html_show_sourcelink = True | |||
| # html_show_sourcelink = True | |||
| # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. | |||
| #html_show_sphinx = True | |||
| # html_show_sphinx = True | |||
| # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. | |||
| #html_show_copyright = True | |||
| # html_show_copyright = True | |||
| # If true, an OpenSearch description file will be output, and all pages will | |||
| # contain a <link> tag referring to it. The value of this option must be the | |||
| # base URL from which the finished HTML is served. | |||
| #html_use_opensearch = '' | |||
| # html_use_opensearch = '' | |||
| # This is the file name suffix for HTML files (e.g. ".xhtml"). | |||
| #html_file_suffix = None | |||
| # html_file_suffix = None | |||
| # Output file base name for HTML help builder. | |||
| htmlhelp_basename = 'EarwigBotdoc' | |||
| htmlhelp_basename = "EarwigBotdoc" | |||
| # -- Options for LaTeX output -------------------------------------------------- | |||
| latex_elements = { | |||
| # The paper size ('letterpaper' or 'a4paper'). | |||
| #'papersize': 'letterpaper', | |||
| # The font size ('10pt', '11pt' or '12pt'). | |||
| #'pointsize': '10pt', | |||
| # Additional stuff for the LaTeX preamble. | |||
| #'preamble': '', | |||
| # The paper size ('letterpaper' or 'a4paper'). | |||
| #'papersize': 'letterpaper', | |||
| # The font size ('10pt', '11pt' or '12pt'). | |||
| #'pointsize': '10pt', | |||
| # Additional stuff for the LaTeX preamble. | |||
| #'preamble': '', | |||
| } | |||
| # Grouping the document tree into LaTeX files. List of tuples | |||
| # (source start file, target name, title, author, documentclass [howto/manual]). | |||
| latex_documents = [ | |||
| ('index', 'EarwigBot.tex', u'EarwigBot Documentation', | |||
| u'Ben Kurtovic', 'manual'), | |||
| ("index", "EarwigBot.tex", "EarwigBot Documentation", "Ben Kurtovic", "manual"), | |||
| ] | |||
| # The name of an image file (relative to this directory) to place at the top of | |||
| # the title page. | |||
| #latex_logo = None | |||
| # latex_logo = None | |||
| # For "manual" documents, if this is true, then toplevel headings are parts, | |||
| # not chapters. | |||
| #latex_use_parts = False | |||
| # latex_use_parts = False | |||
| # If true, show page references after internal links. | |||
| #latex_show_pagerefs = False | |||
| # latex_show_pagerefs = False | |||
| # If true, show URL addresses after external links. | |||
| #latex_show_urls = False | |||
| # latex_show_urls = False | |||
| # Documents to append as an appendix to all manuals. | |||
| #latex_appendices = [] | |||
| # latex_appendices = [] | |||
| # If false, no module index is generated. | |||
| #latex_domain_indices = True | |||
| # latex_domain_indices = True | |||
| # -- Options for manual page output -------------------------------------------- | |||
| # One entry per manual page. List of tuples | |||
| # (source start file, name, description, authors, manual section). | |||
| man_pages = [ | |||
| ('index', 'earwigbot', u'EarwigBot Documentation', | |||
| [u'Ben Kurtovic'], 1) | |||
| ] | |||
| man_pages = [("index", "earwigbot", "EarwigBot Documentation", ["Ben Kurtovic"], 1)] | |||
| # If true, show URL addresses after external links. | |||
| #man_show_urls = False | |||
| # man_show_urls = False | |||
| # -- Options for Texinfo output ------------------------------------------------ | |||
| @@ -227,16 +220,22 @@ man_pages = [ | |||
| # (source start file, target name, title, author, | |||
| # dir menu entry, description, category) | |||
| texinfo_documents = [ | |||
| ('index', 'EarwigBot', u'EarwigBot Documentation', | |||
| u'Ben Kurtovic', 'EarwigBot', 'One line description of project.', | |||
| 'Miscellaneous'), | |||
| ( | |||
| "index", | |||
| "EarwigBot", | |||
| "EarwigBot Documentation", | |||
| "Ben Kurtovic", | |||
| "EarwigBot", | |||
| "EarwigBot is a Python robot that edits Wikipedia and interacts with people over IRC.", | |||
| "Miscellaneous", | |||
| ), | |||
| ] | |||
| # Documents to append as an appendix to all manuals. | |||
| #texinfo_appendices = [] | |||
| # texinfo_appendices = [] | |||
| # If false, no module index is generated. | |||
| #texinfo_domain_indices = True | |||
| # texinfo_domain_indices = True | |||
| # How to display URL addresses: 'footnote', 'no', or 'inline'. | |||
| #texinfo_show_urls = 'footnote' | |||
| # texinfo_show_urls = 'footnote' | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2019 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -37,13 +35,17 @@ __email__ = "ben.kurtovic@gmail.com" | |||
| __release__ = False | |||
| if not __release__: | |||
| def _get_git_commit_id(): | |||
| """Return the ID of the git HEAD commit.""" | |||
| from os.path import dirname, split | |||
| from git import Repo | |||
| from os.path import split, dirname | |||
| path = split(dirname(__file__))[0] | |||
| commit_id = Repo(path).head.object.hexsha | |||
| return commit_id[:8] | |||
| try: | |||
| __version__ += "+" + _get_git_commit_id() | |||
| except Exception: | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -21,7 +19,8 @@ | |||
| # SOFTWARE. | |||
| import logging | |||
| from threading import Lock, Thread, enumerate as enumerate_threads | |||
| from threading import Lock, Thread | |||
| from threading import enumerate as enumerate_threads | |||
| from time import gmtime, sleep | |||
| from earwigbot import __version__ | |||
| @@ -32,6 +31,7 @@ from earwigbot.wiki import SitesDB | |||
| __all__ = ["Bot"] | |||
| class Bot: | |||
| """ | |||
| **EarwigBot: Main Bot Class** | |||
| @@ -77,11 +77,11 @@ class Bot: | |||
| def __repr__(self): | |||
| """Return the canonical string representation of the Bot.""" | |||
| return "Bot(config={0!r})".format(self.config) | |||
| return f"Bot(config={self.config!r})" | |||
| def __str__(self): | |||
| """Return a nice string representation of the Bot.""" | |||
| return "<Bot at {0}>".format(self.config.root_dir) | |||
| return f"<Bot at {self.config.root_dir}>" | |||
| def _dispatch_irc_component(self, name, klass): | |||
| """Create a new IRC component, record it internally, and start it.""" | |||
| @@ -100,6 +100,7 @@ class Bot: | |||
| def _start_wiki_scheduler(self): | |||
| """Start the wiki scheduler in a separate thread if enabled.""" | |||
| def wiki_scheduler(): | |||
| run_at = 15 | |||
| while self._keep_looping: | |||
| @@ -118,7 +119,7 @@ class Bot: | |||
| if component: | |||
| component.keep_alive() | |||
| if component.is_stopped(): | |||
| log = "IRC {0} has stopped; restarting".format(name) | |||
| log = f"IRC {name} has stopped; restarting" | |||
| self.logger.warn(log) | |||
| self._dispatch_irc_component(name, klass) | |||
| @@ -151,7 +152,8 @@ class Bot: | |||
| skips = component_names + ["MainThread", "reminder", "irc:quit"] | |||
| for thread in enumerate_threads(): | |||
| if thread.is_alive() and not any( | |||
| thread.name.startswith(skip) for skip in skips): | |||
| thread.name.startswith(skip) for skip in skips | |||
| ): | |||
| tasks.append(thread.name) | |||
| if tasks: | |||
| log = "The following commands or tasks will be killed: {0}" | |||
| @@ -173,7 +175,7 @@ class Bot: | |||
| ensuring that all components remain online and restarting components | |||
| that get disconnected from their servers. | |||
| """ | |||
| self.logger.info("Starting bot (EarwigBot {0})".format(__version__)) | |||
| self.logger.info(f"Starting bot (EarwigBot {__version__})") | |||
| self._start_irc_components() | |||
| self._start_wiki_scheduler() | |||
| while self._keep_looping: | |||
| @@ -195,7 +197,7 @@ class Bot: | |||
| If given, *msg* will be used as our quit message. | |||
| """ | |||
| if msg: | |||
| self.logger.info('Restarting bot ("{0}")'.format(msg)) | |||
| self.logger.info(f'Restarting bot ("{msg}")') | |||
| else: | |||
| self.logger.info("Restarting bot") | |||
| with self.component_lock: | |||
| @@ -211,7 +213,7 @@ class Bot: | |||
| If given, *msg* will be used as our quit message. | |||
| """ | |||
| if msg: | |||
| self.logger.info('Stopping bot ("{0}")'.format(msg)) | |||
| self.logger.info(f'Stopping bot ("{msg}")') | |||
| else: | |||
| self.logger.info("Stopping bot") | |||
| with self.component_lock: | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -22,6 +20,7 @@ | |||
| __all__ = ["Command"] | |||
| class Command: | |||
| """ | |||
| **EarwigBot: Base IRC Command** | |||
| @@ -36,6 +35,7 @@ class Command: | |||
| This docstring is reported to the user when they type ``"!help | |||
| <command>"``. | |||
| """ | |||
| # The command's name, as reported to the user when they use !help: | |||
| name = None | |||
| @@ -62,15 +62,31 @@ class Command: | |||
| self.logger = bot.commands.logger.getChild(self.name) | |||
| # Convenience functions: | |||
| self.say = lambda target, msg, hidelog=False: self.bot.frontend.say(target, msg, hidelog) | |||
| self.reply = lambda data, msg, hidelog=False: self.bot.frontend.reply(data, msg, hidelog) | |||
| self.action = lambda target, msg, hidelog=False: self.bot.frontend.action(target, msg, hidelog) | |||
| self.notice = lambda target, msg, hidelog=False: self.bot.frontend.notice(target, msg, hidelog) | |||
| self.say = lambda target, msg, hidelog=False: self.bot.frontend.say( | |||
| target, msg, hidelog | |||
| ) | |||
| self.reply = lambda data, msg, hidelog=False: self.bot.frontend.reply( | |||
| data, msg, hidelog | |||
| ) | |||
| self.action = lambda target, msg, hidelog=False: self.bot.frontend.action( | |||
| target, msg, hidelog | |||
| ) | |||
| self.notice = lambda target, msg, hidelog=False: self.bot.frontend.notice( | |||
| target, msg, hidelog | |||
| ) | |||
| self.join = lambda chan, hidelog=False: self.bot.frontend.join(chan, hidelog) | |||
| self.part = lambda chan, msg=None, hidelog=False: self.bot.frontend.part(chan, msg, hidelog) | |||
| self.mode = lambda t, level, msg, hidelog=False: self.bot.frontend.mode(t, level, msg, hidelog) | |||
| self.ping = lambda target, hidelog=False: self.bot.frontend.ping(target, hidelog) | |||
| self.pong = lambda target, hidelog=False: self.bot.frontend.pong(target, hidelog) | |||
| self.part = lambda chan, msg=None, hidelog=False: self.bot.frontend.part( | |||
| chan, msg, hidelog | |||
| ) | |||
| self.mode = lambda t, level, msg, hidelog=False: self.bot.frontend.mode( | |||
| t, level, msg, hidelog | |||
| ) | |||
| self.ping = lambda target, hidelog=False: self.bot.frontend.ping( | |||
| target, hidelog | |||
| ) | |||
| self.pong = lambda target, hidelog=False: self.bot.frontend.pong( | |||
| target, hidelog | |||
| ) | |||
| self.setup() | |||
| @@ -81,7 +97,7 @@ class Command: | |||
| def __str__(self): | |||
| """Return a nice string representation of the Command.""" | |||
| return "<Command {0} of {1}>".format(self.name, self.bot) | |||
| return f"<Command {self.name} of {self.bot}>" | |||
| def setup(self): | |||
| """Hook called immediately after the command is loaded. | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -24,8 +22,10 @@ import re | |||
| from earwigbot.commands import Command | |||
| class Access(Command): | |||
| """Control and get info on who can access the bot.""" | |||
| name = "access" | |||
| commands = ["access", "permission", "permissions", "perm", "perms"] | |||
| @@ -42,15 +42,15 @@ class Access(Command): | |||
| elif data.args[0] == "help": | |||
| self.reply(data, "Subcommands are self, list, add, and remove.") | |||
| else: | |||
| msg = "Unknown subcommand \x0303{0}\x0F. Subcommands are self, list, add, remove." | |||
| msg = "Unknown subcommand \x0303{0}\x0f. Subcommands are self, list, add, remove." | |||
| self.reply(data, msg.format(data.args[0])) | |||
| def do_self(self, data, permdb): | |||
| if permdb.is_owner(data): | |||
| msg = "You are a bot owner (matching rule \x0302{0}\x0F)." | |||
| msg = "You are a bot owner (matching rule \x0302{0}\x0f)." | |||
| self.reply(data, msg.format(permdb.is_owner(data))) | |||
| elif permdb.is_admin(data): | |||
| msg = "You are a bot admin (matching rule \x0302{0}\x0F)." | |||
| msg = "You are a bot admin (matching rule \x0302{0}\x0f)." | |||
| self.reply(data, msg.format(permdb.is_admin(data))) | |||
| else: | |||
| self.reply(data, "You do not match any bot access rules.") | |||
| @@ -62,18 +62,18 @@ class Access(Command): | |||
| elif data.args[1] in ["admin", "admins"]: | |||
| name, rules = "admins", permdb.users.get(permdb.ADMIN) | |||
| else: | |||
| msg = "Unknown access level \x0302{0}\x0F." | |||
| msg = "Unknown access level \x0302{0}\x0f." | |||
| self.reply(data, msg.format(data.args[1])) | |||
| return | |||
| if rules: | |||
| msg = "Bot {0}: {1}.".format(name, ", ".join(map(str, rules))) | |||
| msg = "Bot {}: {}.".format(name, ", ".join(map(str, rules))) | |||
| else: | |||
| msg = "No bot {0}.".format(name) | |||
| msg = f"No bot {name}." | |||
| self.reply(data, msg) | |||
| else: | |||
| owners = len(permdb.users.get(permdb.OWNER, [])) | |||
| admins = len(permdb.users.get(permdb.ADMIN, [])) | |||
| msg = "There are \x02{0}\x0F bot owners and \x02{1}\x0F bot admins. Use '!{2} list owners' or '!{2} list admins' for details." | |||
| msg = "There are \x02{0}\x0f bot owners and \x02{1}\x0f bot admins. Use '!{2} list owners' or '!{2} list admins' for details." | |||
| self.reply(data, msg.format(owners, admins, data.command)) | |||
| def do_add(self, data, permdb): | |||
| @@ -85,12 +85,12 @@ class Access(Command): | |||
| else: | |||
| name, level, adder = "admin", permdb.ADMIN, permdb.add_admin | |||
| if permdb.has_exact(level, nick, ident, host): | |||
| rule = "{0}!{1}@{2}".format(nick, ident, host) | |||
| msg = "\x0302{0}\x0F is already a bot {1}.".format(rule, name) | |||
| rule = f"{nick}!{ident}@{host}" | |||
| msg = f"\x0302{rule}\x0f is already a bot {name}." | |||
| self.reply(data, msg) | |||
| else: | |||
| rule = adder(nick, ident, host) | |||
| msg = "Added bot {0} \x0302{1}\x0F.".format(name, rule) | |||
| msg = f"Added bot {name} \x0302{rule}\x0f." | |||
| self.reply(data, msg) | |||
| def do_remove(self, data, permdb): | |||
| @@ -103,11 +103,11 @@ class Access(Command): | |||
| name, rmver = "admin", permdb.remove_admin | |||
| rule = rmver(nick, ident, host) | |||
| if rule: | |||
| msg = "Removed bot {0} \x0302{1}\x0F.".format(name, rule) | |||
| msg = f"Removed bot {name} \x0302{rule}\x0f." | |||
| self.reply(data, msg) | |||
| else: | |||
| rule = "{0}!{1}@{2}".format(nick, ident, host) | |||
| msg = "No bot {0} matching \x0302{1}\x0F.".format(name, rule) | |||
| rule = f"{nick}!{ident}@{host}" | |||
| msg = f"No bot {name} matching \x0302{rule}\x0f." | |||
| self.reply(data, msg) | |||
| def get_user_from_args(self, data, permdb): | |||
| @@ -136,6 +136,6 @@ class Access(Command): | |||
| return user.group(1), user.group(2), user.group(3) | |||
| def no_arg_error(self, data): | |||
| msg = 'Please specify a user, either as "\x0302nick\x0F!\x0302ident\x0F@\x0302host\x0F"' | |||
| msg += ' or "nick=\x0302nick\x0F, ident=\x0302ident\x0F, host=\x0302host\x0F".' | |||
| msg = 'Please specify a user, either as "\x0302nick\x0f!\x0302ident\x0f@\x0302host\x0f"' | |||
| msg += ' or "nick=\x0302nick\x0f, ident=\x0302ident\x0f, host=\x0302host\x0f".' | |||
| self.reply(data, msg) | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -21,14 +19,16 @@ | |||
| # SOFTWARE. | |||
| import re | |||
| import urllib.request | |||
| import urllib.parse | |||
| import urllib.request | |||
| from earwigbot.commands import Command | |||
| class Calc(Command): | |||
| """A somewhat advanced calculator: see https://futureboy.us/fsp/frink.fsp | |||
| for details.""" | |||
| name = "calc" | |||
| def process(self, data): | |||
| @@ -36,15 +36,15 @@ class Calc(Command): | |||
| self.reply(data, "What do you want me to calculate?") | |||
| return | |||
| query = ' '.join(data.args) | |||
| query = " ".join(data.args) | |||
| query = self.cleanup(query) | |||
| url = "https://futureboy.us/fsp/frink.fsp?fromVal={0}" | |||
| url = url.format(urllib.parse.quote(query)) | |||
| result = urllib.request.urlopen(url).read().decode() | |||
| r_result = re.compile(r'(?i)<A NAME=results>(.*?)</A>') | |||
| r_tag = re.compile(r'<\S+.*?>') | |||
| r_result = re.compile(r"(?i)<A NAME=results>(.*?)</A>") | |||
| r_tag = re.compile(r"<\S+.*?>") | |||
| match = r_result.search(result) | |||
| if not match: | |||
| @@ -52,32 +52,32 @@ class Calc(Command): | |||
| return | |||
| result = match.group(1) | |||
| result = r_tag.sub("", result) # strip span.warning tags | |||
| result = r_tag.sub("", result) # strip span.warning tags | |||
| result = result.replace(">", ">") | |||
| result = result.replace("(undefined symbol)", "(?) ") | |||
| result = result.strip() | |||
| if not result: | |||
| result = '?' | |||
| result = "?" | |||
| elif " in " in query: | |||
| result += " " + query.split(" in ", 1)[1] | |||
| res = "%s = %s" % (query, result) | |||
| res = f"{query} = {result}" | |||
| self.reply(data, res) | |||
| @staticmethod | |||
| def cleanup(query): | |||
| fixes = [ | |||
| (' in ', ' -> '), | |||
| (' over ', ' / '), | |||
| ('£', 'GBP '), | |||
| ('€', 'EUR '), | |||
| (r'\$', 'USD '), | |||
| (r'\bKB\b', 'kilobytes'), | |||
| (r'\bMB\b', 'megabytes'), | |||
| (r'\bGB\b', 'gigabytes'), | |||
| ('kbps', '(kilobits / second)'), | |||
| ('mbps', '(megabits / second)') | |||
| (" in ", " -> "), | |||
| (" over ", " / "), | |||
| ("£", "GBP "), | |||
| ("€", "EUR "), | |||
| (r"\$", "USD "), | |||
| (r"\bKB\b", "kilobytes"), | |||
| (r"\bMB\b", "megabytes"), | |||
| (r"\bGB\b", "gigabytes"), | |||
| ("kbps", "(kilobits / second)"), | |||
| ("mbps", "(megabits / second)"), | |||
| ] | |||
| for original, fix in fixes: | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2021 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -22,17 +20,32 @@ | |||
| from earwigbot.commands import Command | |||
| class ChanOps(Command): | |||
| """Voice, devoice, op, or deop users in the channel, or join or part from | |||
| other channels.""" | |||
| name = "chanops" | |||
| commands = ["chanops", "voice", "devoice", "op", "deop", "join", "part", "listchans"] | |||
| commands = [ | |||
| "chanops", | |||
| "voice", | |||
| "devoice", | |||
| "op", | |||
| "deop", | |||
| "join", | |||
| "part", | |||
| "listchans", | |||
| ] | |||
| def process(self, data): | |||
| if data.command == "chanops": | |||
| msg = "Available commands are {0}." | |||
| self.reply(data, msg.format(", ".join( | |||
| "!" + cmd for cmd in self.commands if cmd != data.command))) | |||
| self.reply( | |||
| data, | |||
| msg.format( | |||
| ", ".join("!" + cmd for cmd in self.commands if cmd != data.command) | |||
| ), | |||
| ) | |||
| return | |||
| de_escalate = data.command in ["devoice", "deop"] | |||
| if de_escalate and (not data.args or data.args[0] == data.nick): | |||
| @@ -70,7 +83,7 @@ class ChanOps(Command): | |||
| return | |||
| self.join(channel) | |||
| log = "{0} requested JOIN to {1}".format(data.nick, channel) | |||
| log = f"{data.nick} requested JOIN to {channel}" | |||
| self.logger.info(log) | |||
| def do_part(self, data): | |||
| @@ -85,11 +98,11 @@ class ChanOps(Command): | |||
| else: # "!part reason for parting"; assume current channel | |||
| reason = " ".join(data.args) | |||
| msg = "Requested by {0}".format(data.nick) | |||
| log = "{0} requested PART from {1}".format(data.nick, channel) | |||
| msg = f"Requested by {data.nick}" | |||
| log = f"{data.nick} requested PART from {channel}" | |||
| if reason: | |||
| msg += ": {0}".format(reason) | |||
| log += ' ("{0}")'.format(reason) | |||
| msg += f": {reason}" | |||
| log += f' ("{reason}")' | |||
| self.part(channel, msg) | |||
| self.logger.info(log) | |||
| @@ -98,5 +111,9 @@ class ChanOps(Command): | |||
| if not chans: | |||
| self.reply(data, "I am currently in no channels.") | |||
| return | |||
| self.reply(data, "I am currently in \x02{0}\x0F channel{1}: {2}.".format( | |||
| len(chans), "" if len(chans) == 1 else "s", ", ".join(sorted(chans)))) | |||
| self.reply( | |||
| data, | |||
| "I am currently in \x02{}\x0f channel{}: {}.".format( | |||
| len(chans), "" if len(chans) == 1 else "s", ", ".join(sorted(chans)) | |||
| ), | |||
| ) | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2016 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -20,23 +18,31 @@ | |||
| # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |||
| # SOFTWARE. | |||
| from collections import namedtuple | |||
| import re | |||
| import socket | |||
| from collections import namedtuple | |||
| from socket import AF_INET, AF_INET6 | |||
| from earwigbot.commands import Command | |||
| _IP = namedtuple("_IP", ["family", "ip", "size"]) | |||
| _Range = namedtuple("_Range", [ | |||
| "family", "range", "low", "high", "size", "addresses"]) | |||
| _Range = namedtuple("_Range", ["family", "range", "low", "high", "size", "addresses"]) | |||
| class CIDR(Command): | |||
| """Calculates the smallest CIDR range that encompasses a list of IP | |||
| addresses. Used to make range blocks.""" | |||
| name = "cidr" | |||
| commands = ["cidr", "range", "rangeblock", "rangecalc", "blockcalc", | |||
| "iprange", "cdir"] | |||
| commands = [ | |||
| "cidr", | |||
| "range", | |||
| "rangeblock", | |||
| "rangecalc", | |||
| "blockcalc", | |||
| "iprange", | |||
| "cdir", | |||
| ] | |||
| # https://www.mediawiki.org/wiki/Manual:$wgBlockCIDRLimit | |||
| LIMIT_IPv4 = 16 | |||
| @@ -44,22 +50,25 @@ class CIDR(Command): | |||
| def process(self, data): | |||
| if not data.args: | |||
| msg = ("Specify a list of IP addresses to calculate a CIDR range " | |||
| "for. For example, \x0306!{0} 192.168.0.3 192.168.0.15 " | |||
| "192.168.1.4\x0F or \x0306!{0} 2500:1:2:3:: " | |||
| "2500:1:2:3:dead:beef::\x0F.") | |||
| msg = ( | |||
| "Specify a list of IP addresses to calculate a CIDR range " | |||
| "for. For example, \x0306!{0} 192.168.0.3 192.168.0.15 " | |||
| "192.168.1.4\x0f or \x0306!{0} 2500:1:2:3:: " | |||
| "2500:1:2:3:dead:beef::\x0f." | |||
| ) | |||
| self.reply(data, msg.format(data.command)) | |||
| return | |||
| try: | |||
| ips = [self._parse_ip(arg) for arg in data.args] | |||
| except ValueError as exc: | |||
| msg = "Can't parse IP address \x0302{0}\x0F." | |||
| msg = "Can't parse IP address \x0302{0}\x0f." | |||
| self.reply(data, msg.format(exc.message)) | |||
| return | |||
| if any(ip.family == AF_INET for ip in ips) and any( | |||
| ip.family == AF_INET6 for ip in ips): | |||
| ip.family == AF_INET6 for ip in ips | |||
| ): | |||
| msg = "Can't calculate a range for both IPv4 and IPv6 addresses." | |||
| self.reply(data, msg) | |||
| return | |||
| @@ -67,11 +76,20 @@ class CIDR(Command): | |||
| cidr = self._calculate_range(ips[0].family, ips) | |||
| descr = self._describe(cidr.family, cidr.size) | |||
| msg = ("Smallest CIDR range is \x02{0}\x0F, covering {1} from " | |||
| "\x0305{2}\x0F to \x0305{3}\x0F{4}.") | |||
| self.reply(data, msg.format( | |||
| cidr.range, cidr.addresses, cidr.low, cidr.high, | |||
| " (\x0304{0}\x0F)".format(descr) if descr else "")) | |||
| msg = ( | |||
| "Smallest CIDR range is \x02{0}\x0f, covering {1} from " | |||
| "\x0305{2}\x0f to \x0305{3}\x0f{4}." | |||
| ) | |||
| self.reply( | |||
| data, | |||
| msg.format( | |||
| cidr.range, | |||
| cidr.addresses, | |||
| cidr.low, | |||
| cidr.high, | |||
| f" (\x0304{descr}\x0f)" if descr else "", | |||
| ), | |||
| ) | |||
| def _parse_ip(self, arg): | |||
| """Converts an argument into an IP address object.""" | |||
| @@ -89,10 +107,10 @@ class CIDR(Command): | |||
| try: | |||
| ip = _IP(AF_INET, socket.inet_pton(AF_INET, arg), size) | |||
| except socket.error: | |||
| except OSError: | |||
| try: | |||
| return _IP(AF_INET6, socket.inet_pton(AF_INET6, arg), size) | |||
| except socket.error: | |||
| except OSError: | |||
| raise ValueError(oldarg) | |||
| if size > 32: | |||
| raise ValueError(oldarg) | |||
| @@ -126,18 +144,20 @@ class CIDR(Command): | |||
| def _calculate_range(self, family, ips): | |||
| """Calculate the smallest CIDR range encompassing a list of IPs.""" | |||
| bin_ips = ["".join( | |||
| bin(ord(octet))[2:].zfill(8) for octet in ip.ip) for ip in ips] | |||
| bin_ips = [ | |||
| "".join(bin(ord(octet))[2:].zfill(8) for octet in ip.ip) for ip in ips | |||
| ] | |||
| for i, ip in enumerate(ips): | |||
| if ip.size is not None: | |||
| suffix = "X" * (len(bin_ips[i]) - ip.size) | |||
| bin_ips[i] = bin_ips[i][:ip.size] + suffix | |||
| bin_ips[i] = bin_ips[i][: ip.size] + suffix | |||
| size = len(bin_ips[0]) | |||
| for i in range(len(bin_ips[0])): | |||
| if any(ip[i] == "X" for ip in bin_ips) or ( | |||
| any(ip[i] == "0" for ip in bin_ips) and | |||
| any(ip[i] == "1" for ip in bin_ips)): | |||
| any(ip[i] == "0" for ip in bin_ips) | |||
| and any(ip[i] == "1" for ip in bin_ips) | |||
| ): | |||
| size = i | |||
| break | |||
| @@ -147,33 +167,41 @@ class CIDR(Command): | |||
| high = self._format_bin(family, bin_high) | |||
| return _Range( | |||
| family, low + "/" + str(size), low, high, size, | |||
| self._format_count(2 ** (len(bin_ips[0]) - size))) | |||
| family, | |||
| low + "/" + str(size), | |||
| low, | |||
| high, | |||
| size, | |||
| self._format_count(2 ** (len(bin_ips[0]) - size)), | |||
| ) | |||
| @staticmethod | |||
| def _format_bin(family, binary): | |||
| """Convert an IP's binary representation to presentation format.""" | |||
| return socket.inet_ntop(family, "".join( | |||
| chr(int(binary[i:i + 8], 2)) for i in range(0, len(binary), 8))) | |||
| return socket.inet_ntop( | |||
| family, | |||
| "".join(chr(int(binary[i : i + 8], 2)) for i in range(0, len(binary), 8)), | |||
| ) | |||
| @staticmethod | |||
| def _format_count(count): | |||
| """Nicely format a number of addresses affected by a range block.""" | |||
| if count == 1: | |||
| return "1 address" | |||
| if count > 2 ** 32: | |||
| base = "{0:.2E} addresses".format(count) | |||
| if count == 2 ** 64: | |||
| if count > 2**32: | |||
| base = f"{count:.2E} addresses" | |||
| if count == 2**64: | |||
| return base + " (1 /64 subnet)" | |||
| if count > 2 ** 96: | |||
| return base + " ({0:.2E} /64 subnets)".format(count >> 64) | |||
| if count > 2 ** 63: | |||
| return base + " ({0:,} /64 subnets)".format(count >> 64) | |||
| if count > 2**96: | |||
| return base + f" ({count >> 64:.2E} /64 subnets)" | |||
| if count > 2**63: | |||
| return base + f" ({count >> 64:,} /64 subnets)" | |||
| return base | |||
| return "{0:,} addresses".format(count) | |||
| return f"{count:,} addresses" | |||
| def _describe(self, family, size): | |||
| """Return an optional English description of a range.""" | |||
| if (family == AF_INET and size < self.LIMIT_IPv4) or ( | |||
| family == AF_INET6 and size < self.LIMIT_IPv6): | |||
| family == AF_INET6 and size < self.LIMIT_IPv6 | |||
| ): | |||
| return "too large to block" | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -31,9 +29,11 @@ fernet = importer.new("cryptography.fernet") | |||
| hashes = importer.new("cryptography.hazmat.primitives.hashes") | |||
| pbkdf2 = importer.new("cryptography.hazmat.primitives.kdf.pbkdf2") | |||
| class Crypt(Command): | |||
| """Provides hash functions with !hash (!hash list for supported algorithms) | |||
| and basic encryption with !encrypt and !decrypt.""" | |||
| name = "crypt" | |||
| commands = ["crypt", "hash", "encrypt", "decrypt"] | |||
| @@ -44,22 +44,22 @@ class Crypt(Command): | |||
| return | |||
| if not data.args: | |||
| msg = "What do you want me to {0}?".format(data.command) | |||
| msg = f"What do you want me to {data.command}?" | |||
| self.reply(data, msg) | |||
| return | |||
| if data.command == "hash": | |||
| algo = data.args[0] | |||
| if algo == "list": | |||
| algos = ', '.join(hashlib.algorithms_available) | |||
| algos = ", ".join(hashlib.algorithms_available) | |||
| msg = algos.join(("Supported algorithms: ", ".")) | |||
| self.reply(data, msg) | |||
| elif algo in hashlib.algorithms_available: | |||
| string = ' '.join(data.args[1:]) | |||
| string = " ".join(data.args[1:]) | |||
| result = getattr(hashlib, algo)(string.encode()).hexdigest() | |||
| self.reply(data, result) | |||
| else: | |||
| msg = "Unknown algorithm: '{0}'.".format(algo) | |||
| msg = f"Unknown algorithm: '{algo}'." | |||
| self.reply(data, msg) | |||
| else: | |||
| @@ -81,7 +81,9 @@ class Crypt(Command): | |||
| salt=salt, | |||
| iterations=100000, | |||
| ) | |||
| f = fernet.Fernet(base64.urlsafe_b64encode(kdf.derive(key.encode()))) | |||
| f = fernet.Fernet( | |||
| base64.urlsafe_b64encode(kdf.derive(key.encode())) | |||
| ) | |||
| ciphertext = f.encrypt(text.encode()) | |||
| self.reply(data, base64.b64encode(salt + ciphertext).decode()) | |||
| else: | |||
| @@ -95,9 +97,14 @@ class Crypt(Command): | |||
| salt=salt, | |||
| iterations=100000, | |||
| ) | |||
| f = fernet.Fernet(base64.urlsafe_b64encode(kdf.derive(key.encode()))) | |||
| f = fernet.Fernet( | |||
| base64.urlsafe_b64encode(kdf.derive(key.encode())) | |||
| ) | |||
| self.reply(data, f.decrypt(ciphertext).decode()) | |||
| except ImportError: | |||
| self.reply(data, "This command requires the 'cryptography' package: https://cryptography.io/") | |||
| self.reply( | |||
| data, | |||
| "This command requires the 'cryptography' package: https://cryptography.io/", | |||
| ) | |||
| except Exception as error: | |||
| self.reply(data, "{}: {}".format(type(error).__name__, str(error))) | |||
| self.reply(data, f"{type(error).__name__}: {str(error)}") | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -26,9 +24,11 @@ import time | |||
| from earwigbot import __version__ | |||
| from earwigbot.commands import Command | |||
| class CTCP(Command): | |||
| """Not an actual command; this module implements responses to the CTCP | |||
| requests PING, TIME, and VERSION.""" | |||
| name = "ctcp" | |||
| hooks = ["msg_private"] | |||
| @@ -52,17 +52,17 @@ class CTCP(Command): | |||
| if command == "PING": | |||
| msg = " ".join(data.line[4:]) | |||
| if msg: | |||
| self.notice(target, "\x01PING {0}\x01".format(msg)) | |||
| self.notice(target, f"\x01PING {msg}\x01") | |||
| else: | |||
| self.notice(target, "\x01PING\x01") | |||
| elif command == "TIME": | |||
| ts = time.strftime("%a, %d %b %Y %H:%M:%S %Z", time.localtime()) | |||
| self.notice(target, "\x01TIME {0}\x01".format(ts)) | |||
| self.notice(target, f"\x01TIME {ts}\x01") | |||
| elif command == "VERSION": | |||
| default = "EarwigBot - $1 - Python/$2 https://github.com/earwig/earwigbot" | |||
| vers = self.config.irc.get("version", default) | |||
| vers = vers.replace("$1", __version__) | |||
| vers = vers.replace("$2", platform.python_version()) | |||
| self.notice(target, "\x01VERSION {0}\x01".format(vers)) | |||
| self.notice(target, f"\x01VERSION {vers}\x01") | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -25,8 +23,10 @@ import re | |||
| from earwigbot import exceptions | |||
| from earwigbot.commands import Command | |||
| class Dictionary(Command): | |||
| """Define words and stuff.""" | |||
| name = "dictionary" | |||
| commands = ["dict", "dictionary", "define", "def"] | |||
| @@ -63,7 +63,7 @@ class Dictionary(Command): | |||
| level, languages = self.get_languages(entry) | |||
| if not languages: | |||
| return "Couldn't parse {0}!".format(page.url) | |||
| return f"Couldn't parse {page.url}!" | |||
| if "#" in term: # Requesting a specific language | |||
| lcase_langs = {lang.lower(): lang for lang in languages} | |||
| @@ -73,12 +73,12 @@ class Dictionary(Command): | |||
| resp = "Language {0} not found in definition." | |||
| return resp.format(request) | |||
| definition = self.get_definition(languages[lang], level) | |||
| return "({0}) {1}".format(lang, definition) | |||
| return f"({lang}) {definition}" | |||
| result = [] | |||
| for lang, section in sorted(languages.items()): | |||
| definition = self.get_definition(section, level) | |||
| result.append("({0}) {1}".format(lang, definition)) | |||
| result.append(f"({lang}) {definition}") | |||
| return "; ".join(result) | |||
| def get_languages(self, entry, level=2): | |||
| @@ -119,19 +119,22 @@ class Dictionary(Command): | |||
| blocks = "=" * (level + 1) | |||
| defs = [] | |||
| for part, basename in parts_of_speech.items(): | |||
| fullnames = [basename, r"\{\{" + basename + r"\}\}", | |||
| r"\{\{" + basename.lower() + r"\}\}"] | |||
| fullnames = [ | |||
| basename, | |||
| r"\{\{" + basename + r"\}\}", | |||
| r"\{\{" + basename.lower() + r"\}\}", | |||
| ] | |||
| for fullname in fullnames: | |||
| regex = blocks + r"\s*" + fullname + r"\s*" + blocks | |||
| if re.search(regex, section): | |||
| regex = blocks + r"\s*" + fullname | |||
| regex += r"\s*{0}(.*?)(?:(?:{0})|\Z)".format(blocks) | |||
| regex += rf"\s*{blocks}(.*?)(?:(?:{blocks})|\Z)" | |||
| bodies = re.findall(regex, section, re.DOTALL) | |||
| if bodies: | |||
| for body in bodies: | |||
| definition = self.parse_body(body) | |||
| if definition: | |||
| msg = "\x02{0}\x0F {1}" | |||
| msg = "\x02{0}\x0f {1}" | |||
| defs.append(msg.format(part, definition)) | |||
| return "; ".join(defs) | |||
| @@ -167,7 +170,7 @@ class Dictionary(Command): | |||
| result = [] # Number the senses incrementally | |||
| for i, sense in enumerate(senses): | |||
| result.append("{0}. {1}".format(i + 1, sense)) | |||
| result.append(f"{i + 1}. {sense}") | |||
| return " ".join(result) | |||
| def strip_templates(self, line): | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -25,8 +23,10 @@ from urllib.parse import quote_plus | |||
| from earwigbot import exceptions | |||
| from earwigbot.commands import Command | |||
| class Editcount(Command): | |||
| """Return a user's edit count.""" | |||
| name = "editcount" | |||
| commands = ["ec", "editcount"] | |||
| @@ -34,7 +34,7 @@ class Editcount(Command): | |||
| if not data.args: | |||
| name = data.nick | |||
| else: | |||
| name = ' '.join(data.args) | |||
| name = " ".join(data.args) | |||
| site = self.bot.wiki.get_site() | |||
| user = site.get_user(name) | |||
| @@ -42,12 +42,12 @@ class Editcount(Command): | |||
| try: | |||
| count = user.editcount | |||
| except exceptions.UserNotFoundError: | |||
| msg = "The user \x0302{0}\x0F does not exist." | |||
| msg = "The user \x0302{0}\x0f does not exist." | |||
| self.reply(data, msg.format(name)) | |||
| return | |||
| safe = quote_plus(user.name.encode("utf8")) | |||
| url = "https://xtools.wmflabs.org/ec/{}/{}".format(site.domain, safe) | |||
| url = f"https://xtools.wmflabs.org/ec/{site.domain}/{safe}" | |||
| fullurl = url.format(safe, site.domain) | |||
| msg = "\x0302{0}\x0F has {1} edits ({2})." | |||
| msg = "\x0302{0}\x0f has {1} edits ({2})." | |||
| self.reply(data, msg.format(name, count, fullurl)) | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -20,14 +18,16 @@ | |||
| # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |||
| # SOFTWARE. | |||
| from platform import python_version | |||
| import re | |||
| from platform import python_version | |||
| from earwigbot import __version__ | |||
| from earwigbot.commands import Command | |||
| class Help(Command): | |||
| """Displays information about the bot.""" | |||
| name = "help" | |||
| commands = ["help", "version"] | |||
| @@ -53,7 +53,7 @@ class Help(Command): | |||
| """Give the user a general help message with a list of all commands.""" | |||
| msg = "Hi, I'm a bot! I have {0} commands loaded: {1}. You can get help for any command with '!help <command>'." | |||
| cmnds = sorted([cmnd.name for cmnd in self.bot.commands]) | |||
| msg = msg.format(len(cmnds), ', '.join(cmnds)) | |||
| msg = msg.format(len(cmnds), ", ".join(cmnds)) | |||
| self.reply(data, msg) | |||
| def do_command_help(self, data): | |||
| @@ -65,16 +65,18 @@ class Help(Command): | |||
| if command.__doc__: | |||
| doc = command.__doc__.replace("\n", "") | |||
| doc = re.sub(r"\s\s+", " ", doc) | |||
| msg = 'Help for command \x0303{0}\x0F: "{1}"' | |||
| msg = 'Help for command \x0303{0}\x0f: "{1}"' | |||
| self.reply(data, msg.format(target, doc)) | |||
| return | |||
| msg = "Sorry, no help for \x0303{0}\x0F.".format(target) | |||
| msg = f"Sorry, no help for \x0303{target}\x0f." | |||
| self.reply(data, msg) | |||
| def do_hello(self, data): | |||
| self.say(data.chan, "Yes, {0}?".format(data.nick)) | |||
| self.say(data.chan, f"Yes, {data.nick}?") | |||
| def do_version(self, data): | |||
| vers = "EarwigBot v{bot} on Python {python}: https://github.com/earwig/earwigbot" | |||
| vers = ( | |||
| "EarwigBot v{bot} on Python {python}: https://github.com/earwig/earwigbot" | |||
| ) | |||
| self.reply(data, vers.format(bot=__version__, python=python_version())) | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -23,8 +21,10 @@ | |||
| from earwigbot import exceptions | |||
| from earwigbot.commands import Command | |||
| class Lag(Command): | |||
| """Return replag or maxlag information on specific databases.""" | |||
| name = "lag" | |||
| commands = ["lag", "replag", "maxlag"] | |||
| @@ -33,22 +33,21 @@ class Lag(Command): | |||
| if not site: | |||
| return | |||
| if data.command == "replag": | |||
| base = "\x0302{0}\x0F: {1}." | |||
| base = "\x0302{0}\x0f: {1}." | |||
| msg = base.format(site.name, self.get_replag(site)) | |||
| elif data.command == "maxlag": | |||
| base = "\x0302{0}\x0F: {1}." | |||
| base = "\x0302{0}\x0f: {1}." | |||
| msg = base.format(site.name, self.get_maxlag(site)) | |||
| else: | |||
| base = "\x0302{0}\x0F: {1}; {2}." | |||
| msg = base.format(site.name, self.get_replag(site), | |||
| self.get_maxlag(site)) | |||
| base = "\x0302{0}\x0f: {1}; {2}." | |||
| msg = base.format(site.name, self.get_replag(site), self.get_maxlag(site)) | |||
| self.reply(data, msg) | |||
| def get_replag(self, site): | |||
| return "SQL replag is {0}".format(self.time(site.get_replag())) | |||
| return f"SQL replag is {self.time(site.get_replag())}" | |||
| def get_maxlag(self, site): | |||
| return "API maxlag is {0}".format(self.time(site.get_maxlag())) | |||
| return f"API maxlag is {self.time(site.get_maxlag())}" | |||
| def get_site(self, data): | |||
| if data.kwargs and "project" in data.kwargs and "lang" in data.kwargs: | |||
| @@ -60,7 +59,7 @@ class Lag(Command): | |||
| if len(data.args) > 1: | |||
| name = " ".join(data.args) | |||
| self.reply(data, "Unknown site: \x0302{0}\x0F.".format(name)) | |||
| self.reply(data, f"Unknown site: \x0302{name}\x0f.") | |||
| return | |||
| name = data.args[0] | |||
| if "." in name: | |||
| @@ -71,7 +70,7 @@ class Lag(Command): | |||
| try: | |||
| return self.bot.wiki.get_site(name) | |||
| except exceptions.SiteNotFoundError: | |||
| msg = "Unknown site: \x0302{0}\x0F.".format(name) | |||
| msg = f"Unknown site: \x0302{name}\x0f." | |||
| self.reply(data, msg) | |||
| return | |||
| return self.get_site_from_proj_and_lang(data, project, lang) | |||
| @@ -83,19 +82,24 @@ class Lag(Command): | |||
| try: | |||
| site = self.bot.wiki.add_site(project=project, lang=lang) | |||
| except exceptions.APIError: | |||
| msg = "Site \x0302{0}:{1}\x0F not found." | |||
| msg = "Site \x0302{0}:{1}\x0f not found." | |||
| self.reply(data, msg.format(project, lang)) | |||
| return | |||
| return site | |||
| def time(self, seconds): | |||
| parts = [("year", 31536000), ("day", 86400), ("hour", 3600), | |||
| ("minute", 60), ("second", 1)] | |||
| parts = [ | |||
| ("year", 31536000), | |||
| ("day", 86400), | |||
| ("hour", 3600), | |||
| ("minute", 60), | |||
| ("second", 1), | |||
| ] | |||
| msg = [] | |||
| for name, size in parts: | |||
| num = seconds / size | |||
| seconds -= num * size | |||
| if num: | |||
| chunk = "{0} {1}".format(num, name if num == 1 else name + "s") | |||
| chunk = "{} {}".format(num, name if num == 1 else name + "s") | |||
| msg.append(chunk) | |||
| return ", ".join(msg) if msg else "0 seconds" | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -22,9 +20,11 @@ | |||
| from earwigbot.commands import Command | |||
| class Langcode(Command): | |||
| """Convert a language code into its name (or vice versa), and give a list | |||
| of WMF sites in that language.""" | |||
| name = "langcode" | |||
| commands = ["langcode", "lang", "language"] | |||
| @@ -46,17 +46,17 @@ class Langcode(Command): | |||
| localname = site["localname"].encode("utf8") | |||
| if site["code"] == lcase: | |||
| if name != localname: | |||
| name += " ({0})".format(localname) | |||
| name += f" ({localname})" | |||
| sites = ", ".join([s["url"] for s in site["site"]]) | |||
| msg = "\x0302{0}\x0F is {1} ({2})".format(code, name, sites) | |||
| msg = f"\x0302{code}\x0f is {name} ({sites})" | |||
| self.reply(data, msg) | |||
| return | |||
| elif name.lower() == lcase or localname.lower() == lcase: | |||
| if name != localname: | |||
| name += " ({0})".format(localname) | |||
| name += f" ({localname})" | |||
| sites = ", ".join([s["url"] for s in site["site"]]) | |||
| msg = "{0} is \x0302{1}\x0F ({2})" | |||
| msg = "{0} is \x0302{1}\x0f ({2})" | |||
| self.reply(data, msg.format(name, site["code"], sites)) | |||
| return | |||
| self.reply(data, "Language \x0302{0}\x0F not found.".format(code)) | |||
| self.reply(data, f"Language \x0302{code}\x0f not found.") | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -24,8 +22,10 @@ import re | |||
| from earwigbot.commands import Command | |||
| class Link(Command): | |||
| """Convert a Wikipedia page name into a URL.""" | |||
| name = "link" | |||
| def setup(self): | |||
| @@ -72,7 +72,10 @@ class Link(Command): | |||
| # Find all {{templates}} | |||
| templates = re.findall(r"(\{\{(.*?)(\||\}\}))", line) | |||
| if templates: | |||
| p_tmpl = lambda name: self.site.get_page("Template:" + name).url | |||
| def p_tmpl(name): | |||
| return self.site.get_page("Template:" + name).url | |||
| templates = [p_tmpl(i[1]) for i in templates] | |||
| results += templates | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -20,16 +18,18 @@ | |||
| # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |||
| # SOFTWARE. | |||
| from datetime import datetime | |||
| from os import path | |||
| import re | |||
| import sqlite3 as sqlite | |||
| from datetime import datetime | |||
| from os import path | |||
| from threading import Lock | |||
| from earwigbot.commands import Command | |||
| class Notes(Command): | |||
| """A mini IRC-based wiki for storing notes, tips, and reminders.""" | |||
| name = "notes" | |||
| commands = ["notes", "note", "about"] | |||
| version = "2.1" | |||
| @@ -43,7 +43,7 @@ class Notes(Command): | |||
| "change": "edit", | |||
| "modify": "edit", | |||
| "move": "rename", | |||
| "remove": "delete" | |||
| "remove": "delete", | |||
| } | |||
| def setup(self): | |||
| @@ -70,7 +70,7 @@ class Notes(Command): | |||
| elif command in self.aliases: | |||
| commands[self.aliases[command]](data) | |||
| else: | |||
| msg = "Unknown subcommand: \x0303{0}\x0F.".format(command) | |||
| msg = f"Unknown subcommand: \x0303{command}\x0f." | |||
| self.reply(data, msg) | |||
| def do_help(self, data): | |||
| @@ -94,8 +94,10 @@ class Notes(Command): | |||
| try: | |||
| command = data.args[1] | |||
| except IndexError: | |||
| msg = ("\x0302The Earwig Mini-Wiki\x0F: running v{0}. Subcommands " | |||
| "are: {1}. You can get help on any with '!{2} help subcommand'.") | |||
| msg = ( | |||
| "\x0302The Earwig Mini-Wiki\x0f: running v{0}. Subcommands " | |||
| "are: {1}. You can get help on any with '!{2} help subcommand'." | |||
| ) | |||
| cmnds = ", ".join(info.keys()) | |||
| self.reply(data, msg.format(self.version, cmnds, data.command)) | |||
| return | |||
| @@ -103,9 +105,9 @@ class Notes(Command): | |||
| command = self.aliases[command] | |||
| try: | |||
| help_ = re.sub(r"\s\s+", " ", info[command].replace("\n", "")) | |||
| self.reply(data, "\x0303{0}\x0F: ".format(command) + help_) | |||
| self.reply(data, f"\x0303{command}\x0f: " + help_) | |||
| except KeyError: | |||
| msg = "Unknown subcommand: \x0303{0}\x0F.".format(command) | |||
| msg = f"Unknown subcommand: \x0303{command}\x0f." | |||
| self.reply(data, msg) | |||
| def do_list(self, data): | |||
| @@ -119,7 +121,7 @@ class Notes(Command): | |||
| if entries: | |||
| entries = [entry[0].encode("utf8") for entry in entries] | |||
| self.reply(data, "Entries: {0}".format(", ".join(entries))) | |||
| self.reply(data, "Entries: {}".format(", ".join(entries))) | |||
| else: | |||
| self.reply(data, "No entries in the database.") | |||
| @@ -142,10 +144,10 @@ class Notes(Command): | |||
| title = title.encode("utf8") | |||
| if content: | |||
| msg = "\x0302{0}\x0F: {1}" | |||
| msg = "\x0302{0}\x0f: {1}" | |||
| self.reply(data, msg.format(title, content.encode("utf8"))) | |||
| else: | |||
| self.reply(data, "Entry \x0302{0}\x0F not found.".format(title)) | |||
| self.reply(data, f"Entry \x0302{title}\x0f not found.") | |||
| def do_edit(self, data): | |||
| """Edit an entry in the notes database.""" | |||
| @@ -191,7 +193,7 @@ class Notes(Command): | |||
| else: | |||
| conn.execute(query4, (revid, id_)) | |||
| msg = "Entry \x0302{0}\x0F updated." | |||
| msg = "Entry \x0302{0}\x0f updated." | |||
| self.reply(data, msg.format(title.encode("utf8"))) | |||
| def do_info(self, data): | |||
| @@ -216,17 +218,17 @@ class Notes(Command): | |||
| title = info[0][0] | |||
| times = [datum[1] for datum in info] | |||
| earliest = min(times) | |||
| msg = "\x0302{0}\x0F: {1} edits since {2}" | |||
| msg = "\x0302{0}\x0f: {1} edits since {2}" | |||
| msg = msg.format(title.encode("utf8"), len(info), earliest) | |||
| if len(times) > 1: | |||
| latest = max(times) | |||
| msg += "; last edit on {0}".format(latest) | |||
| msg += f"; last edit on {latest}" | |||
| names = [datum[2] for datum in info] | |||
| msg += "; authors: {0}.".format(", ".join(list(set(names)))) | |||
| msg += "; authors: {}.".format(", ".join(list(set(names)))) | |||
| self.reply(data, msg) | |||
| else: | |||
| title = data.args[1] | |||
| self.reply(data, "Entry \x0302{0}\x0F not found.".format(title)) | |||
| self.reply(data, f"Entry \x0302{title}\x0f not found.") | |||
| def do_rename(self, data): | |||
| """Rename an entry in the notes database.""" | |||
| @@ -254,7 +256,7 @@ class Notes(Command): | |||
| try: | |||
| id_, author = conn.execute(query1, (slug,)).fetchone() | |||
| except (sqlite.OperationalError, TypeError): | |||
| msg = "Entry \x0302{0}\x0F not found.".format(data.args[1]) | |||
| msg = f"Entry \x0302{data.args[1]}\x0f not found." | |||
| self.reply(data, msg) | |||
| return | |||
| permdb = self.config.irc["permissions"] | |||
| @@ -265,7 +267,7 @@ class Notes(Command): | |||
| args = (self._slugify(newtitle), newtitle.decode("utf8"), id_) | |||
| conn.execute(query2, args) | |||
| msg = "Entry \x0302{0}\x0F renamed to \x0302{1}\x0F." | |||
| msg = "Entry \x0302{0}\x0f renamed to \x0302{1}\x0f." | |||
| self.reply(data, msg.format(data.args[1], newtitle)) | |||
| def do_delete(self, data): | |||
| @@ -286,7 +288,7 @@ class Notes(Command): | |||
| try: | |||
| id_, author = conn.execute(query1, (slug,)).fetchone() | |||
| except (sqlite.OperationalError, TypeError): | |||
| msg = "Entry \x0302{0}\x0F not found.".format(data.args[1]) | |||
| msg = f"Entry \x0302{data.args[1]}\x0f not found." | |||
| self.reply(data, msg) | |||
| return | |||
| permdb = self.config.irc["permissions"] | |||
| @@ -297,7 +299,7 @@ class Notes(Command): | |||
| conn.execute(query2, (id_,)) | |||
| conn.execute(query3, (id_,)) | |||
| self.reply(data, "Entry \x0302{0}\x0F deleted.".format(data.args[1])) | |||
| self.reply(data, f"Entry \x0302{data.args[1]}\x0f deleted.") | |||
| def _slugify(self, name): | |||
| """Convert *name* into an identifier for storing in the database.""" | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -22,9 +20,11 @@ | |||
| from earwigbot.commands import Command | |||
| class Quit(Command): | |||
| """Quit, restart, or reload components from the bot. Only the owners can | |||
| run this command.""" | |||
| name = "quit" | |||
| commands = ["quit", "restart", "reload"] | |||
| @@ -45,24 +45,26 @@ class Quit(Command): | |||
| reason = " ".join(args) | |||
| else: | |||
| if not args or args[0].lower() != data.my_nick: | |||
| self.reply(data, "To confirm this action, the first argument must be my name.") | |||
| self.reply( | |||
| data, "To confirm this action, the first argument must be my name." | |||
| ) | |||
| return | |||
| reason = " ".join(args[1:]) | |||
| if reason: | |||
| self.bot.stop("Stopped by {0}: {1}".format(data.nick, reason)) | |||
| self.bot.stop(f"Stopped by {data.nick}: {reason}") | |||
| else: | |||
| self.bot.stop("Stopped by {0}".format(data.nick)) | |||
| self.bot.stop(f"Stopped by {data.nick}") | |||
| def do_restart(self, data): | |||
| if data.args: | |||
| msg = " ".join(data.args) | |||
| self.bot.restart("Restarted by {0}: {1}".format(data.nick, msg)) | |||
| self.bot.restart(f"Restarted by {data.nick}: {msg}") | |||
| else: | |||
| self.bot.restart("Restarted by {0}".format(data.nick)) | |||
| self.bot.restart(f"Restarted by {data.nick}") | |||
| def do_reload(self, data): | |||
| self.logger.info("{0} requested command/task reload".format(data.nick)) | |||
| self.logger.info(f"{data.nick} requested command/task reload") | |||
| self.bot.commands.load() | |||
| self.bot.tasks.load() | |||
| self.reply(data, "IRC commands and bot tasks reloaded.") | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -26,8 +24,10 @@ from time import mktime | |||
| from earwigbot import exceptions | |||
| from earwigbot.commands import Command | |||
| class Registration(Command): | |||
| """Return when a user registered.""" | |||
| name = "registration" | |||
| commands = ["registration", "reg", "age"] | |||
| @@ -35,7 +35,7 @@ class Registration(Command): | |||
| if not data.args: | |||
| name = data.nick | |||
| else: | |||
| name = ' '.join(data.args) | |||
| name = " ".join(data.args) | |||
| site = self.bot.wiki.get_site() | |||
| user = site.get_user(name) | |||
| @@ -43,7 +43,7 @@ class Registration(Command): | |||
| try: | |||
| reg = user.registration | |||
| except exceptions.UserNotFoundError: | |||
| msg = "The user \x0302{0}\x0F does not exist." | |||
| msg = "The user \x0302{0}\x0f does not exist." | |||
| self.reply(data, msg.format(name)) | |||
| return | |||
| @@ -58,15 +58,16 @@ class Registration(Command): | |||
| else: | |||
| gender = "They're" # Singular they? | |||
| msg = "\x0302{0}\x0F registered on {1}. {2} {3} old." | |||
| msg = "\x0302{0}\x0f registered on {1}. {2} {3} old." | |||
| self.reply(data, msg.format(name, date, gender, age)) | |||
| def get_age(self, birth): | |||
| msg = [] | |||
| def insert(unit, num): | |||
| if not num: | |||
| return | |||
| msg.append("{0} {1}".format(num, unit if num == 1 else unit + "s")) | |||
| msg.append("{} {}".format(num, unit if num == 1 else unit + "s")) | |||
| now = datetime.utcnow() | |||
| bd_passed = now.timetuple()[1:-3] < birth.timetuple()[1:-3] | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2016 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -21,21 +19,21 @@ | |||
| # SOFTWARE. | |||
| import ast | |||
| from itertools import chain | |||
| import operator | |||
| import random | |||
| from threading import RLock, Thread | |||
| import time | |||
| from itertools import chain | |||
| from threading import RLock, Thread | |||
| from earwigbot.commands import Command | |||
| from earwigbot.irc import Data | |||
| DISPLAY = ["display", "show", "info", "details"] | |||
| CANCEL = ["cancel", "stop", "delete", "del", "stop", "unremind", "forget", | |||
| "disregard"] | |||
| CANCEL = ["cancel", "stop", "delete", "del", "stop", "unremind", "forget", "disregard"] | |||
| SNOOZE = ["snooze", "delay", "reset", "adjust", "modify", "change"] | |||
| SNOOZE_ONLY = ["snooze", "delay", "reset"] | |||
| def _format_time(epoch): | |||
| """Format a UNIX timestamp nicely.""" | |||
| lctime = time.localtime(epoch) | |||
| @@ -48,9 +46,17 @@ def _format_time(epoch): | |||
| class Remind(Command): | |||
| """Set a message to be repeated to you in a certain amount of time. See | |||
| usage with !remind help.""" | |||
| name = "remind" | |||
| commands = ["remind", "reminder", "reminders", "snooze", "cancel", | |||
| "unremind", "forget"] | |||
| commands = [ | |||
| "remind", | |||
| "reminder", | |||
| "reminders", | |||
| "snooze", | |||
| "cancel", | |||
| "unremind", | |||
| "forget", | |||
| ] | |||
| @staticmethod | |||
| def _normalize(command): | |||
| @@ -68,19 +74,27 @@ class Remind(Command): | |||
| def _parse_time(arg): | |||
| """Parse the wait time for a reminder.""" | |||
| ast_to_op = { | |||
| ast.Add: operator.add, ast.Sub: operator.sub, | |||
| ast.Mult: operator.mul, ast.Div: operator.truediv, | |||
| ast.FloorDiv: operator.floordiv, ast.Mod: operator.mod, | |||
| ast.Pow: operator.pow | |||
| ast.Add: operator.add, | |||
| ast.Sub: operator.sub, | |||
| ast.Mult: operator.mul, | |||
| ast.Div: operator.truediv, | |||
| ast.FloorDiv: operator.floordiv, | |||
| ast.Mod: operator.mod, | |||
| ast.Pow: operator.pow, | |||
| } | |||
| time_units = { | |||
| "s": 1, "m": 60, "h": 3600, "d": 86400, "w": 604800, "y": 31536000 | |||
| "s": 1, | |||
| "m": 60, | |||
| "h": 3600, | |||
| "d": 86400, | |||
| "w": 604800, | |||
| "y": 31536000, | |||
| } | |||
| def _evaluate(node): | |||
| """Convert an AST node into a real number or raise an exception.""" | |||
| if isinstance(node, ast.Num): | |||
| if not isinstance(node.n, (int, float)): | |||
| if not isinstance(node.n, int | float): | |||
| raise ValueError(node.n) | |||
| return node.n | |||
| elif isinstance(node, ast.BinOp): | |||
| @@ -114,7 +128,7 @@ class Remind(Command): | |||
| """Get a free ID for a new reminder.""" | |||
| taken = set(robj.id for robj in chain(*list(self.reminders.values()))) | |||
| num = random.choice(list(set(range(4096)) - taken)) | |||
| return "R{0:03X}".format(num) | |||
| return f"R{num:03X}" | |||
| def _start_reminder(self, reminder, user): | |||
| """Start the given reminder object for the given user.""" | |||
| @@ -129,12 +143,14 @@ class Remind(Command): | |||
| try: | |||
| wait = self._parse_time(data.args[0]) | |||
| except ValueError: | |||
| msg = "Invalid time \x02{0}\x0F. Time must be a positive integer, in seconds." | |||
| msg = ( | |||
| "Invalid time \x02{0}\x0f. Time must be a positive integer, in seconds." | |||
| ) | |||
| return self.reply(data, msg.format(data.args[0])) | |||
| if wait > 1000 * 365 * 24 * 60 * 60: | |||
| # Hard to think of a good upper limit, but 1000 years works. | |||
| msg = "Given time \x02{0}\x0F is too large. Keep it reasonable." | |||
| msg = "Given time \x02{0}\x0f is too large. Keep it reasonable." | |||
| return self.reply(data, msg.format(data.args[0])) | |||
| message = " ".join(data.args[1:]) | |||
| @@ -146,14 +162,15 @@ class Remind(Command): | |||
| reminder = _Reminder(rid, data.host, wait, message, data, self) | |||
| self._start_reminder(reminder, data.host) | |||
| msg = "Set reminder \x0303{0}\x0F ({1})." | |||
| msg = "Set reminder \x0303{0}\x0f ({1})." | |||
| self.reply(data, msg.format(rid, reminder.end_time)) | |||
| def _display_reminder(self, data, reminder): | |||
| """Display a particular reminder's information.""" | |||
| msg = 'Reminder \x0303{0}\x0F: {1} seconds ({2}): "{3}".' | |||
| msg = msg.format(reminder.id, reminder.wait, reminder.end_time, | |||
| reminder.message) | |||
| msg = 'Reminder \x0303{0}\x0f: {1} seconds ({2}): "{3}".' | |||
| msg = msg.format( | |||
| reminder.id, reminder.wait, reminder.end_time, reminder.message | |||
| ) | |||
| self.reply(data, msg) | |||
| def _cancel_reminder(self, data, reminder): | |||
| @@ -163,7 +180,7 @@ class Remind(Command): | |||
| self.reminders[data.host].remove(reminder) | |||
| if not self.reminders[data.host]: | |||
| del self.reminders[data.host] | |||
| msg = "Reminder \x0303{0}\x0F canceled." | |||
| msg = "Reminder \x0303{0}\x0f canceled." | |||
| self.reply(data, msg.format(reminder.id)) | |||
| def _snooze_reminder(self, data, reminder, arg=None): | |||
| @@ -176,7 +193,7 @@ class Remind(Command): | |||
| reminder.reset(duration) | |||
| end = _format_time(reminder.end) | |||
| msg = "Reminder \x0303{0}\x0F {1} until {2}." | |||
| msg = "Reminder \x0303{0}\x0f {1} until {2}." | |||
| self.reply(data, msg.format(reminder.id, verb, end)) | |||
| def _load_reminders(self): | |||
| @@ -202,39 +219,52 @@ class Remind(Command): | |||
| def _show_reminders(self, data): | |||
| """Show all of a user's current reminders.""" | |||
| if data.host not in self.reminders: | |||
| self.reply(data, "You have no reminders. Set one with " | |||
| "\x0306!remind [time] [message]\x0F. See also: " | |||
| "\x0306!remind help\x0F.") | |||
| self.reply( | |||
| data, | |||
| "You have no reminders. Set one with " | |||
| "\x0306!remind [time] [message]\x0f. See also: " | |||
| "\x0306!remind help\x0f.", | |||
| ) | |||
| return | |||
| shorten = lambda s: (s[:37] + "..." if len(s) > 40 else s) | |||
| dest = lambda data: ( | |||
| "privately" if data.is_private else "in {0}".format(data.chan)) | |||
| fmt = lambda robj: '\x0303{0}\x0F ("{1}" {2}, {3})'.format( | |||
| robj.id, shorten(robj.message), dest(robj.data), robj.end_time) | |||
| def shorten(s): | |||
| return s[:37] + "..." if len(s) > 40 else s | |||
| def dest(data): | |||
| return "privately" if data.is_private else f"in {data.chan}" | |||
| def fmt(robj): | |||
| return f'\x0303{robj.id}\x0f ("{shorten(robj.message)}" {dest(robj.data)}, {robj.end_time})' | |||
| rlist = ", ".join(fmt(robj) for robj in self.reminders[data.host]) | |||
| self.reply(data, "Your reminders: {0}.".format(rlist)) | |||
| self.reply(data, f"Your reminders: {rlist}.") | |||
| def _show_all_reminders(self, data): | |||
| """Show all reminders to bot admins.""" | |||
| if not self.config.irc["permissions"].is_admin(data): | |||
| self.reply(data, "You must be a bot admin to view other users' " | |||
| "reminders. View your own with " | |||
| "\x0306!reminders\x0F.") | |||
| self.reply( | |||
| data, | |||
| "You must be a bot admin to view other users' " | |||
| "reminders. View your own with " | |||
| "\x0306!reminders\x0f.", | |||
| ) | |||
| return | |||
| if not self.reminders: | |||
| self.reply(data, "There are no active reminders.") | |||
| return | |||
| dest = lambda data: ( | |||
| "privately" if data.is_private else "in {0}".format(data.chan)) | |||
| fmt = lambda robj, user: '\x0303{0}\x0F (for {1} {2}, {3})'.format( | |||
| robj.id, user, dest(robj.data), robj.end_time) | |||
| def dest(data): | |||
| return "privately" if data.is_private else f"in {data.chan}" | |||
| rlist = (fmt(rem, user) for user, rems in self.reminders.items() | |||
| for rem in rems) | |||
| self.reply(data, "All reminders: {0}.".format(", ".join(rlist))) | |||
| def fmt(robj, user): | |||
| return ( | |||
| f"\x0303{robj.id}\x0f (for {user} {dest(robj.data)}, {robj.end_time})" | |||
| ) | |||
| rlist = ( | |||
| fmt(rem, user) for user, rems in self.reminders.items() for rem in rems | |||
| ) | |||
| self.reply(data, "All reminders: {}.".format(", ".join(rlist))) | |||
| def _show_help(self, data): | |||
| """Reply to the user with help for all major subcommands.""" | |||
| @@ -245,10 +275,10 @@ class Remind(Command): | |||
| ("Cancel", "!remind cancel [id]"), | |||
| ("Adjust", "!remind adjust [id] [time]"), | |||
| ("Restart", "!snooze [id] [time]"), | |||
| ("Admin", "!remind all") | |||
| ("Admin", "!remind all"), | |||
| ] | |||
| extra = "The \x0306[id]\x0F can be omitted if you have only one reminder." | |||
| joined = " ".join("{0}: \x0306{1}\x0F.".format(k, v) for k, v in parts) | |||
| extra = "The \x0306[id]\x0f can be omitted if you have only one reminder." | |||
| joined = " ".join(f"{k}: \x0306{v}\x0f." for k, v in parts) | |||
| self.reply(data, joined + " " + extra) | |||
| def _dispatch_command(self, data, command, args): | |||
| @@ -259,7 +289,9 @@ class Remind(Command): | |||
| try: | |||
| reminder = self._get_reminder_by_id(user, args[0]) | |||
| except IndexError: | |||
| msg = "Couldn't find a reminder for \x0302{0}\x0F with ID \x0303{1}\x0F." | |||
| msg = ( | |||
| "Couldn't find a reminder for \x0302{0}\x0f with ID \x0303{1}\x0f." | |||
| ) | |||
| self.reply(data, msg.format(user, args[0])) | |||
| return | |||
| args.pop(0) | |||
| @@ -292,7 +324,7 @@ class Remind(Command): | |||
| elif command in SNOOZE: | |||
| self._snooze_reminder(data, reminder, args[0] if args else None) | |||
| else: | |||
| msg = "Unknown action \x02{0}\x0F for reminder \x0303{1}\x0F." | |||
| msg = "Unknown action \x02{0}\x0f for reminder \x0303{1}\x0f." | |||
| self.reply(data, msg.format(command, reminder.id)) | |||
| def _process(self, data): | |||
| @@ -317,8 +349,7 @@ class Remind(Command): | |||
| return self._create_reminder(data) | |||
| if len(data.args) == 1: | |||
| return self._dispatch_command(data, "display", data.args) | |||
| self._dispatch_command( | |||
| data, data.args[1], [data.args[0]] + data.args[2:]) | |||
| self._dispatch_command(data, data.args[1], [data.args[0]] + data.args[2:]) | |||
| @property | |||
| def lock(self): | |||
| @@ -431,6 +462,7 @@ class _ReminderThread: | |||
| class _Reminder: | |||
| """Represents a single reminder.""" | |||
| def __init__(self, rid, user, wait, message, data, cmdobj, end=None): | |||
| self.id = rid | |||
| self.wait = wait | |||
| @@ -476,7 +508,7 @@ class _Reminder: | |||
| """Return a string representing the end time of a reminder.""" | |||
| if self._expired or self.end < time.time(): | |||
| return "expired" | |||
| return "ends {0}".format(_format_time(self.end)) | |||
| return f"ends {_format_time(self.end)}" | |||
| @property | |||
| def expired(self): | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -23,8 +21,10 @@ | |||
| from earwigbot import exceptions | |||
| from earwigbot.commands import Command | |||
| class Rights(Command): | |||
| """Retrieve a list of rights for a given username.""" | |||
| name = "rights" | |||
| commands = ["rights", "groups", "permissions", "privileges"] | |||
| @@ -32,7 +32,7 @@ class Rights(Command): | |||
| if not data.args: | |||
| name = data.nick | |||
| else: | |||
| name = ' '.join(data.args) | |||
| name = " ".join(data.args) | |||
| site = self.bot.wiki.get_site() | |||
| user = site.get_user(name) | |||
| @@ -40,7 +40,7 @@ class Rights(Command): | |||
| try: | |||
| rights = user.groups | |||
| except exceptions.UserNotFoundError: | |||
| msg = "The user \x0302{0}\x0F does not exist." | |||
| msg = "The user \x0302{0}\x0f does not exist." | |||
| self.reply(data, msg.format(name)) | |||
| return | |||
| @@ -48,5 +48,5 @@ class Rights(Command): | |||
| rights.remove("*") # Remove the '*' group given to everyone | |||
| except ValueError: | |||
| pass | |||
| msg = "The rights for \x0302{0}\x0F are {1}." | |||
| self.reply(data, msg.format(name, ', '.join(rights))) | |||
| msg = "The rights for \x0302{0}\x0f are {1}." | |||
| self.reply(data, msg.format(name, ", ".join(rights))) | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2021 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -20,18 +18,30 @@ | |||
| # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |||
| # SOFTWARE. | |||
| from ast import literal_eval | |||
| import re | |||
| from ast import literal_eval | |||
| from earwigbot.commands import Command | |||
| from earwigbot.irc import RC | |||
| class Stalk(Command): | |||
| """Stalk a particular user (!stalk/!unstalk) or page (!watch/!unwatch) for | |||
| edits. Prefix regular expressions with "re:" (uses re.match).""" | |||
| name = "stalk" | |||
| commands = ["stalk", "watch", "unstalk", "unwatch", "stalks", "watches", | |||
| "allstalks", "allwatches", "unstalkall", "unwatchall"] | |||
| commands = [ | |||
| "stalk", | |||
| "watch", | |||
| "unstalk", | |||
| "unwatch", | |||
| "stalks", | |||
| "watches", | |||
| "allstalks", | |||
| "allwatches", | |||
| "unstalkall", | |||
| "unwatchall", | |||
| ] | |||
| hooks = ["msg", "rc"] | |||
| MAX_STALKS_PER_USER = 5 | |||
| @@ -58,20 +68,29 @@ class Stalk(Command): | |||
| if data.is_admin: | |||
| self.reply(data, self._all_stalks()) | |||
| else: | |||
| self.reply(data, "You must be a bot admin to view all stalked " | |||
| "users or watched pages. View your own with " | |||
| "\x0306!stalks\x0F.") | |||
| self.reply( | |||
| data, | |||
| "You must be a bot admin to view all stalked " | |||
| "users or watched pages. View your own with " | |||
| "\x0306!stalks\x0f.", | |||
| ) | |||
| return | |||
| if data.command.endswith("all"): | |||
| if not data.is_admin: | |||
| self.reply(data, "You must be a bot admin to unstalk a user " | |||
| "or unwatch a page for all users.") | |||
| self.reply( | |||
| data, | |||
| "You must be a bot admin to unstalk a user " | |||
| "or unwatch a page for all users.", | |||
| ) | |||
| return | |||
| if not data.args: | |||
| self.reply(data, "You must give a user to unstalk or a page " | |||
| "to unwatch. View all active with " | |||
| "\x0306!allstalks\x0F.") | |||
| self.reply( | |||
| data, | |||
| "You must give a user to unstalk or a page " | |||
| "to unwatch. View all active with " | |||
| "\x0306!allstalks\x0f.", | |||
| ) | |||
| return | |||
| if not data.args or data.command in ["stalks", "watches"]: | |||
| @@ -98,9 +117,12 @@ class Stalk(Command): | |||
| if data.is_private: | |||
| stalkinfo = (data.nick, None, modifiers) | |||
| elif not data.is_admin: | |||
| self.reply(data, "You must be a bot admin to stalk users or " | |||
| "watch pages publicly. Retry this command in " | |||
| "a private message.") | |||
| self.reply( | |||
| data, | |||
| "You must be a bot admin to stalk users or " | |||
| "watch pages publicly. Retry this command in " | |||
| "a private message.", | |||
| ) | |||
| return | |||
| else: | |||
| stalkinfo = (data.nick, data.chan, modifiers) | |||
| @@ -120,6 +142,7 @@ class Stalk(Command): | |||
| def _process_rc(self, rc): | |||
| """Process a watcher event.""" | |||
| def _update_chans(items, flags): | |||
| for item in items: | |||
| modifiers = item[2] if len(item) > 2 else {} | |||
| @@ -167,7 +190,7 @@ class Stalk(Command): | |||
| pretty = rc.prettify(color=chan not in nocolor) | |||
| if users: | |||
| nicks = ", ".join(sorted(users)) | |||
| msg = "\x02{0}\x0F: {1}".format(nicks, pretty) | |||
| msg = f"\x02{nicks}\x0f: {pretty}" | |||
| else: | |||
| msg = pretty | |||
| if len(msg) > 400: | |||
| @@ -199,8 +222,10 @@ class Stalk(Command): | |||
| if not data.is_admin: | |||
| nstalks = len(self._get_stalks_by_nick(data.nick, table)) | |||
| if nstalks >= self.MAX_STALKS_PER_USER: | |||
| msg = ("Already {0}ing {1} {2}s for you, which is the limit " | |||
| "for non-bot admins.") | |||
| msg = ( | |||
| "Already {0}ing {1} {2}s for you, which is the limit " | |||
| "for non-bot admins." | |||
| ) | |||
| self.reply(data, msg.format(verb, nstalks, stalktype)) | |||
| return | |||
| if stalkinfo[1] and not stalkinfo[1].startswith("##"): | |||
| @@ -218,7 +243,7 @@ class Stalk(Command): | |||
| else: | |||
| table[target] = [stalkinfo] | |||
| msg = "Now {0}ing {1} \x0302{2}\x0F. Remove with \x0306!un{0} {2}\x0F." | |||
| msg = "Now {0}ing {1} \x0302{2}\x0f. Remove with \x0306!un{0} {2}\x0f." | |||
| self.reply(data, msg.format(verb, stalktype, target)) | |||
| self._save_stalks() | |||
| @@ -240,11 +265,15 @@ class Stalk(Command): | |||
| to_remove.append(info) | |||
| if not to_remove: | |||
| msg = ("I haven't been {0}ing that {1} for you in the first " | |||
| "place. View your active {2} with \x0306!{2}\x0F.") | |||
| msg = ( | |||
| "I haven't been {0}ing that {1} for you in the first " | |||
| "place. View your active {2} with \x0306!{2}\x0f." | |||
| ) | |||
| if data.is_admin: | |||
| msg += (" As a bot admin, you can clear all active {2} on " | |||
| "that {1} with \x0306!un{0}all {3}\x0F.") | |||
| msg += ( | |||
| " As a bot admin, you can clear all active {2} on " | |||
| "that {1} with \x0306!un{0}all {3}\x0f." | |||
| ) | |||
| self.reply(data, msg.format(verb, stalktype, plural, target)) | |||
| return | |||
| @@ -252,7 +281,7 @@ class Stalk(Command): | |||
| table[target].remove(info) | |||
| if not table[target]: | |||
| del table[target] | |||
| msg = "No longer {0}ing {1} \x0302{2}\x0F for you." | |||
| msg = "No longer {0}ing {1} \x0302{2}\x0f for you." | |||
| self.reply(data, msg.format(verb, stalktype, target)) | |||
| self._save_stalks() | |||
| @@ -270,53 +299,63 @@ class Stalk(Command): | |||
| try: | |||
| del table[target] | |||
| except KeyError: | |||
| msg = ("I haven't been {0}ing that {1} for anyone in the first " | |||
| "place. View all active {2} with \x0306!all{2}\x0F.") | |||
| msg = ( | |||
| "I haven't been {0}ing that {1} for anyone in the first " | |||
| "place. View all active {2} with \x0306!all{2}\x0f." | |||
| ) | |||
| self.reply(data, msg.format(verb, stalktype, plural)) | |||
| else: | |||
| msg = "No longer {0}ing {1} \x0302{2}\x0F for anyone." | |||
| msg = "No longer {0}ing {1} \x0302{2}\x0f for anyone." | |||
| self.reply(data, msg.format(verb, stalktype, target)) | |||
| self._save_stalks() | |||
| def _current_stalks(self, nick): | |||
| """Return the given user's current stalks.""" | |||
| def _format_chans(chans): | |||
| if None in chans: | |||
| chans.remove(None) | |||
| if not chans: | |||
| return "privately" | |||
| if len(chans) == 1: | |||
| return "in {0} and privately".format(chans[0]) | |||
| return f"in {chans[0]} and privately" | |||
| return "in " + ", ".join(chans) + ", and privately" | |||
| return "in " + ", ".join(chans) | |||
| def _format_stalks(stalks): | |||
| return ", ".join( | |||
| "\x0302{0}\x0F ({1})".format(target, _format_chans(chans)) | |||
| for target, chans in stalks.items()) | |||
| f"\x0302{target}\x0f ({_format_chans(chans)})" | |||
| for target, chans in stalks.items() | |||
| ) | |||
| users = self._get_stalks_by_nick(nick, self._users) | |||
| pages = self._get_stalks_by_nick(nick, self._pages) | |||
| if users: | |||
| uinfo = " Users: {0}.".format(_format_stalks(users)) | |||
| uinfo = f" Users: {_format_stalks(users)}." | |||
| if pages: | |||
| pinfo = " Pages: {0}.".format(_format_stalks(pages)) | |||
| pinfo = f" Pages: {_format_stalks(pages)}." | |||
| msg = "Currently stalking {0} user{1} and watching {2} page{3} for you.{4}{5}" | |||
| return msg.format(len(users), "s" if len(users) != 1 else "", | |||
| len(pages), "s" if len(pages) != 1 else "", | |||
| uinfo if users else "", pinfo if pages else "") | |||
| return msg.format( | |||
| len(users), | |||
| "s" if len(users) != 1 else "", | |||
| len(pages), | |||
| "s" if len(pages) != 1 else "", | |||
| uinfo if users else "", | |||
| pinfo if pages else "", | |||
| ) | |||
| def _all_stalks(self): | |||
| """Return all existing stalks, for bot admins.""" | |||
| def _format_info(info): | |||
| if info[1]: | |||
| result = "for {0} in {1}".format(info[0], info[1]) | |||
| result = f"for {info[0]} in {info[1]}" | |||
| else: | |||
| result = "for {0} privately".format(info[0]) | |||
| result = f"for {info[0]} privately" | |||
| modifiers = ", ".join(info[2]) if len(info) > 2 else "" | |||
| if modifiers: | |||
| result += " ({0})".format(modifiers) | |||
| result += f" ({modifiers})" | |||
| return result | |||
| def _format_data(data): | |||
| @@ -324,19 +363,25 @@ class Stalk(Command): | |||
| def _format_stalks(stalks): | |||
| return ", ".join( | |||
| "\x0302{0}\x0F ({1})".format(target, _format_data(data)) | |||
| for target, data in stalks.items()) | |||
| f"\x0302{target}\x0f ({_format_data(data)})" | |||
| for target, data in stalks.items() | |||
| ) | |||
| users, pages = self._users, self._pages | |||
| if users: | |||
| uinfo = " Users: {0}.".format(_format_stalks(users)) | |||
| uinfo = f" Users: {_format_stalks(users)}." | |||
| if pages: | |||
| pinfo = " Pages: {0}.".format(_format_stalks(pages)) | |||
| pinfo = f" Pages: {_format_stalks(pages)}." | |||
| msg = "Currently stalking {0} user{1} and watching {2} page{3}.{4}{5}" | |||
| return msg.format(len(users), "s" if len(users) != 1 else "", | |||
| len(pages), "s" if len(pages) != 1 else "", | |||
| uinfo if users else "", pinfo if pages else "") | |||
| return msg.format( | |||
| len(users), | |||
| "s" if len(users) != 1 else "", | |||
| len(pages), | |||
| "s" if len(pages) != 1 else "", | |||
| uinfo if users else "", | |||
| pinfo if pages else "", | |||
| ) | |||
| def _load_stalks(self): | |||
| """Load saved stalks from the database.""" | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -24,14 +22,16 @@ import random | |||
| from earwigbot.commands import Command | |||
| class Test(Command): | |||
| """Test the bot!""" | |||
| name = "test" | |||
| def process(self, data): | |||
| user = "\x02" + data.nick + "\x0F" # Wrap nick in bold | |||
| user = "\x02" + data.nick + "\x0f" # Wrap nick in bold | |||
| hey = random.randint(0, 1) | |||
| if hey: | |||
| self.say(data.chan, "Hey {0}!".format(user)) | |||
| self.say(data.chan, f"Hey {user}!") | |||
| else: | |||
| self.say(data.chan, "'Sup {0}?".format(user)) | |||
| self.say(data.chan, f"'Sup {user}?") | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -20,13 +18,15 @@ | |||
| # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |||
| # SOFTWARE. | |||
| import threading | |||
| import re | |||
| import threading | |||
| from earwigbot.commands import Command | |||
| class Threads(Command): | |||
| """Manage wiki tasks from IRC, and check on thread status.""" | |||
| name = "threads" | |||
| commands = ["tasks", "task", "threads", "tasklist"] | |||
| @@ -55,7 +55,7 @@ class Threads(Command): | |||
| self.do_listall() | |||
| else: # They asked us to do something we don't know | |||
| msg = "Unknown argument: \x0303{0}\x0F.".format(data.args[0]) | |||
| msg = f"Unknown argument: \x0303{data.args[0]}\x0f." | |||
| self.reply(data, msg) | |||
| def do_list(self): | |||
| @@ -71,34 +71,38 @@ class Threads(Command): | |||
| tname = thread.name | |||
| ident = thread.ident % 10000 | |||
| if tname == "MainThread": | |||
| t = "\x0302main\x0F (id {0})" | |||
| t = "\x0302main\x0f (id {0})" | |||
| normal_threads.append(t.format(ident)) | |||
| elif tname in self.config.components: | |||
| t = "\x0302{0}\x0F (id {1})" | |||
| t = "\x0302{0}\x0f (id {1})" | |||
| normal_threads.append(t.format(tname, ident)) | |||
| elif tname.startswith("cvworker-"): | |||
| t = "\x0302copyvio worker\x0F (site {0})" | |||
| daemon_threads.append(t.format(tname[len("cvworker-"):])) | |||
| t = "\x0302copyvio worker\x0f (site {0})" | |||
| daemon_threads.append(t.format(tname[len("cvworker-") :])) | |||
| else: | |||
| match = re.findall(r"^(.*?) \((.*?)\)$", tname) | |||
| if match: | |||
| t = "\x0302{0}\x0F (id {1}, since {2})" | |||
| t = "\x0302{0}\x0f (id {1}, since {2})" | |||
| thread_info = t.format(match[0][0], ident, match[0][1]) | |||
| daemon_threads.append(thread_info) | |||
| else: | |||
| t = "\x0302{0}\x0F (id {1})" | |||
| t = "\x0302{0}\x0f (id {1})" | |||
| daemon_threads.append(t.format(tname, ident)) | |||
| if daemon_threads: | |||
| if len(daemon_threads) > 1: | |||
| msg = "\x02{0}\x0F threads active: {1}, and \x02{2}\x0F command/task threads: {3}." | |||
| msg = "\x02{0}\x0f threads active: {1}, and \x02{2}\x0f command/task threads: {3}." | |||
| else: | |||
| msg = "\x02{0}\x0F threads active: {1}, and \x02{2}\x0F command/task thread: {3}." | |||
| msg = msg.format(len(threads), ', '.join(normal_threads), | |||
| len(daemon_threads), ', '.join(daemon_threads)) | |||
| msg = "\x02{0}\x0f threads active: {1}, and \x02{2}\x0f command/task thread: {3}." | |||
| msg = msg.format( | |||
| len(threads), | |||
| ", ".join(normal_threads), | |||
| len(daemon_threads), | |||
| ", ".join(daemon_threads), | |||
| ) | |||
| else: | |||
| msg = "\x02{0}\x0F threads active: {1}, and \x020\x0F command/task threads." | |||
| msg = msg.format(len(threads), ', '.join(normal_threads)) | |||
| msg = "\x02{0}\x0f threads active: {1}, and \x020\x0f command/task threads." | |||
| msg = msg.format(len(threads), ", ".join(normal_threads)) | |||
| self.reply(self.data, msg) | |||
| @@ -111,17 +115,17 @@ class Threads(Command): | |||
| threadlist = [t for t in threads if t.name.startswith(task)] | |||
| ids = [str(t.ident) for t in threadlist] | |||
| if not ids: | |||
| tasklist.append("\x0302{0}\x0F (idle)".format(task)) | |||
| tasklist.append(f"\x0302{task}\x0f (idle)") | |||
| elif len(ids) == 1: | |||
| t = "\x0302{0}\x0F (\x02active\x0F as id {1})" | |||
| t = "\x0302{0}\x0f (\x02active\x0f as id {1})" | |||
| tasklist.append(t.format(task, ids[0])) | |||
| else: | |||
| t = "\x0302{0}\x0F (\x02active\x0F as ids {1})" | |||
| tasklist.append(t.format(task, ', '.join(ids))) | |||
| t = "\x0302{0}\x0f (\x02active\x0f as ids {1})" | |||
| tasklist.append(t.format(task, ", ".join(ids))) | |||
| tasks = ", ".join(tasklist) | |||
| msg = "\x02{0}\x0F tasks loaded: {1}.".format(len(tasklist), tasks) | |||
| msg = f"\x02{len(tasklist)}\x0f tasks loaded: {tasks}." | |||
| self.reply(self.data, msg) | |||
| def do_start(self): | |||
| @@ -143,8 +147,9 @@ class Threads(Command): | |||
| data.kwargs["fromIRC"] = True | |||
| data.kwargs["_IRCCallback"] = lambda: self.reply( | |||
| data, "Task \x0302{0}\x0F finished.".format(task_name)) | |||
| data, f"Task \x0302{task_name}\x0f finished." | |||
| ) | |||
| self.bot.tasks.start(task_name, **data.kwargs) | |||
| msg = "Task \x0302{0}\x0F started.".format(task_name) | |||
| msg = f"Task \x0302{task_name}\x0f started." | |||
| self.reply(data, msg) | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -29,9 +27,11 @@ from earwigbot.commands import Command | |||
| pytz = importer.new("pytz") | |||
| class Time(Command): | |||
| """Report the current time in any timezone (UTC default), UNIX epoch time, | |||
| or beat time.""" | |||
| name = "time" | |||
| commands = ["time", "beats", "swatch", "epoch", "date"] | |||
| @@ -54,7 +54,7 @@ class Time(Command): | |||
| def do_beats(self, data): | |||
| beats = ((time() + 3600) % 86400) / 86.4 | |||
| beats = int(floor(beats)) | |||
| self.reply(data, "@{0:0>3}".format(beats)) | |||
| self.reply(data, f"@{beats:0>3}") | |||
| def do_time(self, data, timezone): | |||
| try: | |||
| @@ -64,7 +64,7 @@ class Time(Command): | |||
| self.reply(data, msg) | |||
| return | |||
| except pytz.exceptions.UnknownTimeZoneError: | |||
| self.reply(data, "Unknown timezone: {0}.".format(timezone)) | |||
| self.reply(data, f"Unknown timezone: {timezone}.") | |||
| return | |||
| now = pytz.utc.localize(datetime.utcnow()).astimezone(tzinfo) | |||
| self.reply(data, now.strftime("%Y-%m-%d %H:%M:%S %Z")) | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -24,8 +22,10 @@ from unicodedata import normalize | |||
| from earwigbot.commands import Command | |||
| class Trout(Command): | |||
| """Slap someone with a trout, or related fish.""" | |||
| name = "trout" | |||
| commands = ["trout", "whale"] | |||
| @@ -44,5 +44,5 @@ class Trout(Command): | |||
| if normal in self.exceptions: | |||
| self.reply(data, self.exceptions[normal]) | |||
| else: | |||
| msg = "slaps \x02{0}\x0F around a bit with a large {1}." | |||
| msg = "slaps \x02{0}\x0f around a bit with a large {1}." | |||
| self.action(data.chan, msg.format(target, animal)) | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -22,8 +20,10 @@ | |||
| from earwigbot.commands import Command | |||
| class Watchers(Command): | |||
| """Get the number of users watching a given page.""" | |||
| name = "watchers" | |||
| def process(self, data): | |||
| @@ -33,13 +33,14 @@ class Watchers(Command): | |||
| return | |||
| site = self.bot.wiki.get_site() | |||
| query = site.api_query(action="query", prop="info", inprop="watchers", | |||
| titles=" ".join(data.args)) | |||
| query = site.api_query( | |||
| action="query", prop="info", inprop="watchers", titles=" ".join(data.args) | |||
| ) | |||
| page = list(query["query"]["pages"].values())[0] | |||
| title = page["title"].encode("utf8") | |||
| if "invalid" in page: | |||
| msg = "\x0302{0}\x0F is an invalid page title." | |||
| msg = "\x0302{0}\x0f is an invalid page title." | |||
| self.reply(data, msg.format(title)) | |||
| return | |||
| @@ -48,5 +49,5 @@ class Watchers(Command): | |||
| else: | |||
| watchers = "<30" | |||
| plural = "" if watchers == 1 else "s" | |||
| msg = "\x0302{0}\x0F has \x02{1}\x0F watcher{2}." | |||
| msg = "\x0302{0}\x0f has \x02{1}\x0f watcher{2}." | |||
| self.reply(data, msg.format(title, watchers, plural)) | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -21,12 +19,12 @@ | |||
| # SOFTWARE. | |||
| import base64 | |||
| from collections import OrderedDict | |||
| from getpass import getpass | |||
| import logging | |||
| import logging.handlers | |||
| from os import mkdir, path | |||
| import stat | |||
| from collections import OrderedDict | |||
| from getpass import getpass | |||
| from os import mkdir, path | |||
| import yaml | |||
| @@ -44,6 +42,7 @@ pbkdf2 = importer.new("cryptography.hazmat.primitives.kdf.pbkdf2") | |||
| __all__ = ["BotConfig"] | |||
| class BotConfig: | |||
| """ | |||
| **EarwigBot: YAML Config File Manager** | |||
| @@ -89,8 +88,14 @@ class BotConfig: | |||
| self._tasks = ConfigNode() | |||
| self._metadata = ConfigNode() | |||
| self._nodes = [self._components, self._wiki, self._irc, self._commands, | |||
| self._tasks, self._metadata] | |||
| self._nodes = [ | |||
| self._components, | |||
| self._wiki, | |||
| self._irc, | |||
| self._commands, | |||
| self._tasks, | |||
| self._metadata, | |||
| ] | |||
| self._decryptable_nodes = [ # Default nodes to decrypt | |||
| (self._wiki, ("password",)), | |||
| @@ -107,7 +112,7 @@ class BotConfig: | |||
| def __str__(self): | |||
| """Return a nice string representation of the BotConfig.""" | |||
| return "<BotConfig at {0}>".format(self.root_dir) | |||
| return f"<BotConfig at {self.root_dir}>" | |||
| def _handle_missing_config(self): | |||
| print("Config file missing or empty:", self._config_path) | |||
| @@ -124,11 +129,11 @@ class BotConfig: | |||
| def _load(self): | |||
| """Load data from our JSON config file (config.yml) into self._data.""" | |||
| filename = self._config_path | |||
| with open(filename, 'r') as fp: | |||
| with open(filename) as fp: | |||
| try: | |||
| self._data = yaml.load(fp, OrderedLoader) | |||
| except yaml.YAMLError: | |||
| print("Error parsing config file {0}:".format(filename)) | |||
| print(f"Error parsing config file {filename}:") | |||
| raise | |||
| def _setup_logging(self): | |||
| @@ -142,11 +147,13 @@ class BotConfig: | |||
| if self.metadata.get("enableLogging"): | |||
| hand = logging.handlers.TimedRotatingFileHandler | |||
| logfile = lambda f: path.join(log_dir, f) | |||
| def logfile(f): | |||
| return path.join(log_dir, f) | |||
| if not path.isdir(log_dir): | |||
| if not path.exists(log_dir): | |||
| mkdir(log_dir, stat.S_IWUSR|stat.S_IRUSR|stat.S_IXUSR) | |||
| mkdir(log_dir, stat.S_IWUSR | stat.S_IRUSR | stat.S_IXUSR) | |||
| else: | |||
| msg = "log_dir ({0}) exists but is not a directory!" | |||
| print(msg.format(log_dir)) | |||
| @@ -296,7 +303,8 @@ class BotConfig: | |||
| raise NoConfigError(e) | |||
| key = getpass("Enter key to decrypt bot passwords: ") | |||
| self._decryption_cipher = fernet.Fernet( | |||
| base64.urlsafe_b64encode(kdf.derive(key.encode()))) | |||
| base64.urlsafe_b64encode(kdf.derive(key.encode())) | |||
| ) | |||
| for node, nodes in self._decryptable_nodes: | |||
| self._decrypt(node, nodes) | |||
| @@ -336,8 +344,13 @@ class BotConfig: | |||
| # or just the task_name: | |||
| tasks = [] | |||
| now = {"minute": minute, "hour": hour, "month_day": month_day, | |||
| "month": month, "week_day": week_day} | |||
| now = { | |||
| "minute": minute, | |||
| "hour": hour, | |||
| "month_day": month_day, | |||
| "month": month, | |||
| "week_day": week_day, | |||
| } | |||
| data = self._data.get("schedule", []) | |||
| for event in data: | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -24,12 +22,13 @@ import logging | |||
| __all__ = ["BotFormatter"] | |||
| class BotFormatter(logging.Formatter): | |||
| def __init__(self, color=False): | |||
| self._format = super().format | |||
| if color: | |||
| fmt = "[%(asctime)s %(lvl)s] %(name)s: %(message)s" | |||
| self.format = lambda rec: self._format(self.format_color(rec)) | |||
| self.format = lambda record: self._format(self.format_color(record)) | |||
| else: | |||
| fmt = "[%(asctime)s %(levelname)-8s] %(name)s: %(message)s" | |||
| self.format = self._format | |||
| @@ -37,15 +36,15 @@ class BotFormatter(logging.Formatter): | |||
| super().__init__(fmt=fmt, datefmt=datefmt) | |||
| def format_color(self, record): | |||
| l = record.levelname.ljust(8) | |||
| lvl = record.levelname.ljust(8) | |||
| if record.levelno == logging.DEBUG: | |||
| record.lvl = l.join(("\x1b[34m", "\x1b[0m")) # Blue | |||
| record.lvl = lvl.join(("\x1b[34m", "\x1b[0m")) # Blue | |||
| if record.levelno == logging.INFO: | |||
| record.lvl = l.join(("\x1b[32m", "\x1b[0m")) # Green | |||
| record.lvl = lvl.join(("\x1b[32m", "\x1b[0m")) # Green | |||
| if record.levelno == logging.WARNING: | |||
| record.lvl = l.join(("\x1b[33m", "\x1b[0m")) # Yellow | |||
| record.lvl = lvl.join(("\x1b[33m", "\x1b[0m")) # Yellow | |||
| if record.levelno == logging.ERROR: | |||
| record.lvl = l.join(("\x1b[31m", "\x1b[0m")) # Red | |||
| record.lvl = lvl.join(("\x1b[31m", "\x1b[0m")) # Red | |||
| if record.levelno == logging.CRITICAL: | |||
| record.lvl = l.join(("\x1b[1m\x1b[31m", "\x1b[0m")) # Bold red | |||
| record.lvl = lvl.join(("\x1b[1m\x1b[31m", "\x1b[0m")) # Bold red | |||
| return record | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -25,6 +23,7 @@ from collections import OrderedDict | |||
| __all__ = ["ConfigNode"] | |||
| class ConfigNode: | |||
| def __init__(self): | |||
| self._data = OrderedDict() | |||
| @@ -56,8 +55,7 @@ class ConfigNode: | |||
| self._data[key] = item | |||
| def __iter__(self): | |||
| for key in self._data: | |||
| yield key | |||
| yield from self._data | |||
| def __contains__(self, item): | |||
| return item in self._data | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -35,6 +33,7 @@ import yaml | |||
| __all__ = ["OrderedLoader", "OrderedDumper"] | |||
| class OrderedLoader(yaml.Loader): | |||
| """A YAML loader that loads mappings into ordered dictionaries.""" | |||
| @@ -54,9 +53,12 @@ class OrderedLoader(yaml.Loader): | |||
| if isinstance(node, yaml.MappingNode): | |||
| self.flatten_mapping(node) | |||
| else: | |||
| raise yaml.constructor.ConstructorError(None, None, | |||
| "expected a mapping node, but found {0}".format(node.id), | |||
| node.start_mark) | |||
| raise yaml.constructor.ConstructorError( | |||
| None, | |||
| None, | |||
| f"expected a mapping node, but found {node.id}", | |||
| node.start_mark, | |||
| ) | |||
| mapping = OrderedDict() | |||
| for key_node, value_node in node.value: | |||
| @@ -65,9 +67,11 @@ class OrderedLoader(yaml.Loader): | |||
| hash(key) | |||
| except TypeError as exc: | |||
| raise yaml.constructor.ConstructorError( | |||
| "while constructing a mapping", node.start_mark, | |||
| "found unacceptable key ({0})".format(exc), | |||
| key_node.start_mark) | |||
| "while constructing a mapping", | |||
| node.start_mark, | |||
| f"found unacceptable key ({exc})", | |||
| key_node.start_mark, | |||
| ) | |||
| value = self.construct_object(value_node, deep=deep) | |||
| mapping[key] = value | |||
| return mapping | |||
| @@ -91,11 +95,9 @@ class OrderedDumper(yaml.SafeDumper): | |||
| for item_key, item_value in mapping: | |||
| node_key = self.represent_data(item_key) | |||
| node_value = self.represent_data(item_value) | |||
| if not (isinstance(node_key, yaml.ScalarNode) and not | |||
| node_key.style): | |||
| if not (isinstance(node_key, yaml.ScalarNode) and not node_key.style): | |||
| best_style = False | |||
| if not (isinstance(node_value, yaml.ScalarNode) and not | |||
| node_value.style): | |||
| if not (isinstance(node_value, yaml.ScalarNode) and not node_value.style): | |||
| best_style = False | |||
| value.append((node_key, node_value)) | |||
| if flow_style is None: | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -20,12 +18,13 @@ | |||
| # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |||
| # SOFTWARE. | |||
| from fnmatch import fnmatch | |||
| import sqlite3 as sqlite | |||
| from fnmatch import fnmatch | |||
| from threading import Lock | |||
| __all__ = ["PermissionsDB"] | |||
| class PermissionsDB: | |||
| """ | |||
| **EarwigBot: Permissions Database Manager** | |||
| @@ -33,6 +32,7 @@ class PermissionsDB: | |||
| Controls the :file:`permissions.db` file, which stores the bot's owners and | |||
| admins for the purposes of using certain dangerous IRC commands. | |||
| """ | |||
| ADMIN = 1 | |||
| OWNER = 2 | |||
| @@ -49,7 +49,7 @@ class PermissionsDB: | |||
| def __str__(self): | |||
| """Return a nice string representation of the PermissionsDB.""" | |||
| return "<PermissionsDB at {0}>".format(self._dbfile) | |||
| return f"<PermissionsDB at {self._dbfile}>" | |||
| def _create(self, conn): | |||
| """Initialize the permissions database with its necessary tables.""" | |||
| @@ -198,8 +198,10 @@ class PermissionsDB: | |||
| with self._db_access_lock, sqlite.connect(self._dbfile) as conn: | |||
| conn.execute(query, (user, key)) | |||
| class User: | |||
| """A class that represents an IRC user for the purpose of testing rules.""" | |||
| def __init__(self, nick, ident, host): | |||
| self.nick = nick | |||
| self.ident = ident | |||
| @@ -212,7 +214,7 @@ class User: | |||
| def __str__(self): | |||
| """Return a nice string representation of the User.""" | |||
| return "{0}!{1}@{2}".format(self.nick, self.ident, self.host) | |||
| return f"{self.nick}!{self.ident}@{self.host}" | |||
| def __contains__(self, user): | |||
| if fnmatch(user.nick, self.nick): | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2016 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -21,13 +19,13 @@ | |||
| # SOFTWARE. | |||
| import base64 | |||
| from collections import OrderedDict | |||
| from getpass import getpass | |||
| import os | |||
| from os import chmod, makedirs, mkdir, path | |||
| import re | |||
| import stat | |||
| import sys | |||
| from collections import OrderedDict | |||
| from getpass import getpass | |||
| from os import chmod, makedirs, mkdir, path | |||
| from textwrap import fill, wrap | |||
| import yaml | |||
| @@ -50,23 +48,27 @@ def process(bot, rc): | |||
| pass | |||
| """ | |||
| class ConfigScript: | |||
| """A script to guide a user through the creation of a new config file.""" | |||
| WIDTH = 79 | |||
| PROMPT = "\x1b[32m> \x1b[0m" | |||
| PBKDF_ROUNDS = 100000 | |||
| def __init__(self, config): | |||
| self.config = config | |||
| self.data = OrderedDict([ | |||
| ("metadata", OrderedDict()), | |||
| ("components", OrderedDict()), | |||
| ("wiki", OrderedDict()), | |||
| ("irc", OrderedDict()), | |||
| ("commands", OrderedDict()), | |||
| ("tasks", OrderedDict()), | |||
| ("schedule", []) | |||
| ]) | |||
| self.data = OrderedDict( | |||
| [ | |||
| ("metadata", OrderedDict()), | |||
| ("components", OrderedDict()), | |||
| ("wiki", OrderedDict()), | |||
| ("irc", OrderedDict()), | |||
| ("commands", OrderedDict()), | |||
| ("tasks", OrderedDict()), | |||
| ("schedule", []), | |||
| ] | |||
| ) | |||
| self._cipher = None | |||
| self._wmf = False | |||
| @@ -86,7 +88,7 @@ class ConfigScript: | |||
| def _ask(self, text, default=None, require=True): | |||
| text = self.PROMPT + text | |||
| if default: | |||
| text += " \x1b[33m[{0}]\x1b[0m".format(default) | |||
| text += f" \x1b[33m[{default}]\x1b[0m" | |||
| lines = wrap(re.sub(r"\s\s+", " ", text), self.WIDTH) | |||
| if len(lines) > 1: | |||
| print("\n".join(lines[:-1])) | |||
| @@ -157,7 +159,9 @@ class ConfigScript: | |||
| salt=salt, | |||
| iterations=self.PBKDF_ROUNDS, | |||
| ) | |||
| self._cipher = fernet.Fernet(base64.urlsafe_b64encode(kdf.derive(key.encode()))) | |||
| self._cipher = fernet.Fernet( | |||
| base64.urlsafe_b64encode(kdf.derive(key.encode())) | |||
| ) | |||
| except ImportError: | |||
| print(" error!") | |||
| self._print("""Encryption requires the 'cryptography' package: | |||
| @@ -198,7 +202,7 @@ class ConfigScript: | |||
| front-end.""") | |||
| frontend = self._ask_bool("Enable the IRC front-end?") | |||
| watcher = self._ask_bool("Enable the IRC watcher?") | |||
| scheduler = self._ask_bool("Enable the wiki task scheduler?") | |||
| scheduler = self._ask_bool("Enable the wiki task scheduler?") | |||
| self.data["components"]["irc_frontend"] = frontend | |||
| self.data["components"]["irc_watcher"] = watcher | |||
| self.data["components"]["wiki_scheduler"] = scheduler | |||
| @@ -269,7 +273,9 @@ class ConfigScript: | |||
| self.data["wiki"]["username"] = self._ask("Bot username:") | |||
| password = self._ask_pass("Bot password:", encrypt=False) | |||
| self.data["wiki"]["password"] = password | |||
| self.data["wiki"]["userAgent"] = "EarwigBot/$1 (Python/$2; https://github.com/earwig/earwigbot)" | |||
| self.data["wiki"]["userAgent"] = ( | |||
| "EarwigBot/$1 (Python/$2; https://github.com/earwig/earwigbot)" | |||
| ) | |||
| self.data["wiki"]["summary"] = "([[WP:BOT|Bot]]) $2" | |||
| self.data["wiki"]["useHTTPS"] = True | |||
| self.data["wiki"]["assert"] = "user" | |||
| @@ -282,15 +288,17 @@ class ConfigScript: | |||
| msg = "Will this bot run from the Wikimedia Tool Labs?" | |||
| labs = self._ask_bool(msg, default=False) | |||
| if labs: | |||
| args = [("host", "$1.labsdb"), ("db", "$1_p"), | |||
| ("read_default_file", "~/replica.my.cnf")] | |||
| args = [ | |||
| ("host", "$1.labsdb"), | |||
| ("db", "$1_p"), | |||
| ("read_default_file", "~/replica.my.cnf"), | |||
| ] | |||
| self.data["wiki"]["sql"] = OrderedDict(args) | |||
| else: | |||
| msg = "Will this bot run from the Wikimedia Toolserver?" | |||
| toolserver = self._ask_bool(msg, default=False) | |||
| if toolserver: | |||
| args = [("host", "$1-p.rrdb.toolserver.org"), | |||
| ("db", "$1_p")] | |||
| args = [("host", "$1-p.rrdb.toolserver.org"), ("db", "$1_p")] | |||
| self.data["wiki"]["sql"] = OrderedDict(args) | |||
| self.data["wiki"]["shutoff"] = {} | |||
| @@ -314,11 +322,13 @@ class ConfigScript: | |||
| print() | |||
| frontend = self.data["irc"]["frontend"] = OrderedDict() | |||
| frontend["host"] = self._ask( | |||
| "Hostname of the frontend's IRC server:", "irc.libera.chat") | |||
| "Hostname of the frontend's IRC server:", "irc.libera.chat" | |||
| ) | |||
| frontend["port"] = self._ask("Frontend port:", 6667) | |||
| frontend["nick"] = self._ask("Frontend bot's nickname:") | |||
| frontend["ident"] = self._ask( | |||
| "Frontend bot's ident:", frontend["nick"].lower()) | |||
| "Frontend bot's ident:", frontend["nick"].lower() | |||
| ) | |||
| question = "Frontend bot's real name (gecos):" | |||
| frontend["realname"] = self._ask(question, "EarwigBot") | |||
| if self._ask_bool("Should the bot identify to NickServ?"): | |||
| @@ -370,7 +380,7 @@ class ConfigScript: | |||
| watcher["nickservUsername"] = ns_user | |||
| watcher["nickservPassword"] = ns_pass | |||
| if self._wmf: | |||
| chan = "#{0}.{1}".format(self._lang, self._proj) | |||
| chan = f"#{self._lang}.{self._proj}" | |||
| watcher["channels"] = [chan] | |||
| else: | |||
| chan_question = "Watcher channels to join by default:" | |||
| @@ -387,14 +397,17 @@ class ConfigScript: | |||
| fp.write(RULES_TEMPLATE) | |||
| self._pause() | |||
| self.data["irc"]["version"] = "EarwigBot - $1 - Python/$2 https://github.com/earwig/earwigbot" | |||
| self.data["irc"]["version"] = ( | |||
| "EarwigBot - $1 - Python/$2 https://github.com/earwig/earwigbot" | |||
| ) | |||
| def _set_commands(self): | |||
| print() | |||
| msg = """Would you like to disable the default IRC commands? You can | |||
| fine-tune which commands are disabled later on.""" | |||
| if (not self.data["components"]["irc_frontend"] or | |||
| self._ask_bool(msg, default=False)): | |||
| if not self.data["components"]["irc_frontend"] or self._ask_bool( | |||
| msg, default=False | |||
| ): | |||
| self.data["commands"]["disable"] = True | |||
| print() | |||
| self._print("""I am now creating the 'commands/' directory, where you | |||
| @@ -435,8 +448,14 @@ class ConfigScript: | |||
| def _save(self): | |||
| with open(self.config.path, "w") as stream: | |||
| yaml.dump(self.data, stream, OrderedDumper, indent=4, | |||
| allow_unicode=True, default_flow_style=False) | |||
| yaml.dump( | |||
| self.data, | |||
| stream, | |||
| OrderedDumper, | |||
| indent=4, | |||
| allow_unicode=True, | |||
| default_flow_style=False, | |||
| ) | |||
| def make_new(self): | |||
| """Make a new config file based on the user's input.""" | |||
| @@ -447,8 +466,8 @@ class ConfigScript: | |||
| raise | |||
| try: | |||
| open(self.config.path, "w").close() | |||
| chmod(self.config.path, stat.S_IRUSR|stat.S_IWUSR) | |||
| except IOError: | |||
| chmod(self.config.path, stat.S_IRUSR | stat.S_IWUSR) | |||
| except OSError: | |||
| print("I can't seem to write to the config file:") | |||
| raise | |||
| self._set_metadata() | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -55,9 +53,11 @@ This module contains all exceptions used by EarwigBot:: | |||
| +-- ParserExclusionError | |||
| """ | |||
| class EarwigBotError(Exception): | |||
| """Base exception class for errors in EarwigBot.""" | |||
| class NoConfigError(EarwigBotError): | |||
| """The bot cannot be run without a config file. | |||
| @@ -65,9 +65,11 @@ class NoConfigError(EarwigBotError): | |||
| one to be created. | |||
| """ | |||
| class IRCError(EarwigBotError): | |||
| """Base exception class for errors in IRC-relation sections of the bot.""" | |||
| class BrokenSocketError(IRCError): | |||
| """A socket has broken, because it is not sending data. | |||
| @@ -75,15 +77,18 @@ class BrokenSocketError(IRCError): | |||
| <earwigbot.irc.connection.IRCConnection._get>`. | |||
| """ | |||
| class WikiToolsetError(EarwigBotError): | |||
| """Base exception class for errors in the Wiki Toolset.""" | |||
| class SiteNotFoundError(WikiToolsetError): | |||
| """A particular site could not be found in the sites database. | |||
| Raised by :py:class:`~earwigbot.wiki.sitesdb.SitesDB`. | |||
| """ | |||
| class ServiceError(WikiToolsetError): | |||
| """Base exception class for an error within a service (the API or SQL). | |||
| @@ -92,6 +97,7 @@ class ServiceError(WikiToolsetError): | |||
| non-functional so another, less-preferred one can be tried. | |||
| """ | |||
| class APIError(ServiceError): | |||
| """Couldn't connect to a site's API. | |||
| @@ -101,18 +107,21 @@ class APIError(ServiceError): | |||
| Raised by :py:meth:`Site.api_query <earwigbot.wiki.site.Site.api_query>`. | |||
| """ | |||
| class SQLError(ServiceError): | |||
| """Some error involving SQL querying occurred. | |||
| Raised by :py:meth:`Site.sql_query <earwigbot.wiki.site.Site.sql_query>`. | |||
| """ | |||
| class NoServiceError(WikiToolsetError): | |||
| """No service is functioning to handle a specific task. | |||
| Raised by :py:meth:`Site.delegate <earwigbot.wiki.site.Site.delegate>`. | |||
| """ | |||
| class LoginError(WikiToolsetError): | |||
| """An error occured while trying to login. | |||
| @@ -121,6 +130,7 @@ class LoginError(WikiToolsetError): | |||
| Raised by :py:meth:`Site._login <earwigbot.wiki.site.Site._login>`. | |||
| """ | |||
| class PermissionsError(WikiToolsetError): | |||
| """A permissions error ocurred. | |||
| @@ -134,6 +144,7 @@ class PermissionsError(WikiToolsetError): | |||
| other API methods depending on settings. | |||
| """ | |||
| class NamespaceNotFoundError(WikiToolsetError): | |||
| """A requested namespace name or namespace ID does not exist. | |||
| @@ -143,18 +154,21 @@ class NamespaceNotFoundError(WikiToolsetError): | |||
| <earwigbot.wiki.site.Site.namespace_name_to_id>`. | |||
| """ | |||
| class PageNotFoundError(WikiToolsetError): | |||
| """Attempted to get information about a page that does not exist. | |||
| Raised by :py:class:`~earwigbot.wiki.page.Page`. | |||
| """ | |||
| class InvalidPageError(WikiToolsetError): | |||
| """Attempted to get information about a page whose title is invalid. | |||
| Raised by :py:class:`~earwigbot.wiki.page.Page`. | |||
| """ | |||
| class RedirectError(WikiToolsetError): | |||
| """A redirect-only method was called on a malformed or non-redirect page. | |||
| @@ -162,12 +176,14 @@ class RedirectError(WikiToolsetError): | |||
| <earwigbot.wiki.page.Page.get_redirect_target>`. | |||
| """ | |||
| class UserNotFoundError(WikiToolsetError): | |||
| """Attempted to get certain information about a user that does not exist. | |||
| Raised by :py:class:`~earwigbot.wiki.user.User`. | |||
| """ | |||
| class EditError(WikiToolsetError): | |||
| """An error occured while editing. | |||
| @@ -178,6 +194,7 @@ class EditError(WikiToolsetError): | |||
| :py:meth:`Page.add_section <earwigbot.wiki.page.Page.add_section>`. | |||
| """ | |||
| class EditConflictError(EditError): | |||
| """We gotten an edit conflict or a (rarer) delete/recreate conflict. | |||
| @@ -185,6 +202,7 @@ class EditConflictError(EditError): | |||
| :py:meth:`Page.add_section <earwigbot.wiki.page.Page.add_section>`. | |||
| """ | |||
| class NoContentError(EditError): | |||
| """We tried to create a page or new section with no content. | |||
| @@ -192,6 +210,7 @@ class NoContentError(EditError): | |||
| :py:meth:`Page.add_section <earwigbot.wiki.page.Page.add_section>`. | |||
| """ | |||
| class ContentTooBigError(EditError): | |||
| """The edit we tried to push exceeded the article size limit. | |||
| @@ -199,6 +218,7 @@ class ContentTooBigError(EditError): | |||
| :py:meth:`Page.add_section <earwigbot.wiki.page.Page.add_section>`. | |||
| """ | |||
| class SpamDetectedError(EditError): | |||
| """The spam filter refused our edit. | |||
| @@ -206,6 +226,7 @@ class SpamDetectedError(EditError): | |||
| :py:meth:`Page.add_section <earwigbot.wiki.page.Page.add_section>`. | |||
| """ | |||
| class FilteredError(EditError): | |||
| """The edit filter refused our edit. | |||
| @@ -213,6 +234,7 @@ class FilteredError(EditError): | |||
| :py:meth:`Page.add_section <earwigbot.wiki.page.Page.add_section>`. | |||
| """ | |||
| class CopyvioCheckError(WikiToolsetError): | |||
| """An error occured when checking a page for copyright violations. | |||
| @@ -225,6 +247,7 @@ class CopyvioCheckError(WikiToolsetError): | |||
| <earwigbot.wiki.copyvios.CopyvioMixIn.copyvio_compare>`. | |||
| """ | |||
| class UnknownSearchEngineError(CopyvioCheckError): | |||
| """Attempted to do a copyvio check with an unknown search engine. | |||
| @@ -235,6 +258,7 @@ class UnknownSearchEngineError(CopyvioCheckError): | |||
| <earwigbot.wiki.copyvios.CopyvioMixIn.copyvio_check>`. | |||
| """ | |||
| class UnsupportedSearchEngineError(CopyvioCheckError): | |||
| """Attmpted to do a copyvio check using an unavailable engine. | |||
| @@ -245,6 +269,7 @@ class UnsupportedSearchEngineError(CopyvioCheckError): | |||
| <earwigbot.wiki.copyvios.CopyvioMixIn.copyvio_check>`. | |||
| """ | |||
| class SearchQueryError(CopyvioCheckError): | |||
| """Some error ocurred while doing a search query. | |||
| @@ -252,6 +277,7 @@ class SearchQueryError(CopyvioCheckError): | |||
| <earwigbot.wiki.copyvios.CopyvioMixIn.copyvio_check>`. | |||
| """ | |||
| class ParserExclusionError(CopyvioCheckError): | |||
| """A content parser detected that the given source should be excluded. | |||
| @@ -260,6 +286,7 @@ class ParserExclusionError(CopyvioCheckError): | |||
| exposed in client code. | |||
| """ | |||
| class ParserRedirectError(CopyvioCheckError): | |||
| """A content parser detected that a redirect should be followed. | |||
| @@ -267,6 +294,7 @@ class ParserRedirectError(CopyvioCheckError): | |||
| <earwigbot.wiki.copyvios.CopyvioMixIn.copyvio_check>`; should not be | |||
| exposed in client code. | |||
| """ | |||
| def __init__(self, url): | |||
| super().__init__() | |||
| self.url = url | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -28,6 +26,7 @@ from earwigbot.exceptions import BrokenSocketError | |||
| __all__ = ["IRCConnection"] | |||
| class IRCConnection: | |||
| """Interface with an IRC server.""" | |||
| @@ -50,8 +49,7 @@ class IRCConnection: | |||
| def __repr__(self): | |||
| """Return the canonical string representation of the IRCConnection.""" | |||
| res = "IRCConnection(host={0!r}, port={1!r}, nick={2!r}, ident={3!r}, realname={4!r})" | |||
| return res.format(self.host, self.port, self.nick, self.ident, | |||
| self.realname) | |||
| return res.format(self.host, self.port, self.nick, self.ident, self.realname) | |||
| def __str__(self): | |||
| """Return a nice string representation of the IRCConnection.""" | |||
| @@ -63,18 +61,18 @@ class IRCConnection: | |||
| self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) | |||
| try: | |||
| self._sock.connect((self.host, self.port)) | |||
| except socket.error: | |||
| except OSError: | |||
| self.logger.exception("Couldn't connect to IRC server; retrying") | |||
| sleep(8) | |||
| self._connect() | |||
| self._send("NICK {0}".format(self.nick)) | |||
| self._send("USER {0} {1} * :{2}".format(self.ident, self.host, self.realname)) | |||
| self._send(f"NICK {self.nick}") | |||
| self._send(f"USER {self.ident} {self.host} * :{self.realname}") | |||
| def _close(self): | |||
| """Completely close our connection with the IRC server.""" | |||
| try: | |||
| self._sock.shutdown(socket.SHUT_RDWR) # Shut down connection first | |||
| except socket.error: | |||
| except OSError: | |||
| pass # Ignore if the socket is already down | |||
| self._sock.close() | |||
| @@ -94,7 +92,7 @@ class IRCConnection: | |||
| sleep(0.75 - time_since_last) | |||
| try: | |||
| self._sock.sendall(msg.encode() + b"\r\n") | |||
| except socket.error: | |||
| except OSError: | |||
| self._is_running = False | |||
| else: | |||
| if not hidelog: | |||
| @@ -131,7 +129,7 @@ class IRCConnection: | |||
| def _quit(self, msg=None): | |||
| """Issue a quit message to the server. Doesn't close the connection.""" | |||
| if msg: | |||
| self._send("QUIT :{0}".format(msg)) | |||
| self._send(f"QUIT :{msg}") | |||
| else: | |||
| self._send("QUIT") | |||
| @@ -142,11 +140,10 @@ class IRCConnection: | |||
| self.pong(line[1][1:]) | |||
| elif line[1] == "001": # Update nickname on startup | |||
| if line[2] != self.nick: | |||
| self.logger.warn("Nickname changed from {0} to {1}".format( | |||
| self.nick, line[2])) | |||
| self.logger.warn(f"Nickname changed from {self.nick} to {line[2]}") | |||
| self._nick = line[2] | |||
| elif line[1] == "376": # After sign-on, get our userhost | |||
| self._send("WHOIS {0}".format(self.nick)) | |||
| self._send(f"WHOIS {self.nick}") | |||
| elif line[1] == "311": # Receiving WHOIS result | |||
| if line[2] == self.nick: | |||
| self._ident = line[4] | |||
| @@ -189,7 +186,7 @@ class IRCConnection: | |||
| def say(self, target, msg, hidelog=False): | |||
| """Send a private message to a target on the server.""" | |||
| for msg in self._split(msg, len(target) + 10): | |||
| msg = "PRIVMSG {0} :{1}".format(target, msg) | |||
| msg = f"PRIVMSG {target} :{msg}" | |||
| self._send(msg, hidelog) | |||
| def reply(self, data, msg, hidelog=False): | |||
| @@ -197,45 +194,45 @@ class IRCConnection: | |||
| if data.is_private: | |||
| self.say(data.chan, msg, hidelog) | |||
| else: | |||
| msg = "\x02{0}\x0F: {1}".format(data.reply_nick, msg) | |||
| msg = f"\x02{data.reply_nick}\x0f: {msg}" | |||
| self.say(data.chan, msg, hidelog) | |||
| def action(self, target, msg, hidelog=False): | |||
| """Send a private message to a target on the server as an action.""" | |||
| msg = "\x01ACTION {0}\x01".format(msg) | |||
| msg = f"\x01ACTION {msg}\x01" | |||
| self.say(target, msg, hidelog) | |||
| def notice(self, target, msg, hidelog=False): | |||
| """Send a notice to a target on the server.""" | |||
| for msg in self._split(msg, len(target) + 9): | |||
| msg = "NOTICE {0} :{1}".format(target, msg) | |||
| msg = f"NOTICE {target} :{msg}" | |||
| self._send(msg, hidelog) | |||
| def join(self, chan, hidelog=False): | |||
| """Join a channel on the server.""" | |||
| msg = "JOIN {0}".format(chan) | |||
| msg = f"JOIN {chan}" | |||
| self._send(msg, hidelog) | |||
| def part(self, chan, msg=None, hidelog=False): | |||
| """Part from a channel on the server, optionally using an message.""" | |||
| if msg: | |||
| self._send("PART {0} :{1}".format(chan, msg), hidelog) | |||
| self._send(f"PART {chan} :{msg}", hidelog) | |||
| else: | |||
| self._send("PART {0}".format(chan), hidelog) | |||
| self._send(f"PART {chan}", hidelog) | |||
| def mode(self, target, level, msg, hidelog=False): | |||
| """Send a mode message to the server.""" | |||
| msg = "MODE {0} {1} {2}".format(target, level, msg) | |||
| msg = f"MODE {target} {level} {msg}" | |||
| self._send(msg, hidelog) | |||
| def ping(self, target, hidelog=False): | |||
| """Ping another entity on the server.""" | |||
| msg = "PING {0}".format(target) | |||
| msg = f"PING {target}" | |||
| self._send(msg, hidelog) | |||
| def pong(self, target, hidelog=False): | |||
| """Pong another entity on the server.""" | |||
| msg = "PONG {0}".format(target) | |||
| msg = f"PONG {target}" | |||
| self._send(msg, hidelog) | |||
| def loop(self): | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2016 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -24,6 +22,7 @@ import re | |||
| __all__ = ["Data"] | |||
| class Data: | |||
| """Store data from an individual line received on IRC.""" | |||
| @@ -46,7 +45,7 @@ class Data: | |||
| def __str__(self): | |||
| """Return a nice string representation of the Data.""" | |||
| return "<Data of {0!r}>".format(" ".join(self.line)) | |||
| return "<Data of {!r}>".format(" ".join(self.line)) | |||
| def _parse(self): | |||
| """Parse a line from IRC into its components as instance attributes.""" | |||
| @@ -97,8 +96,7 @@ class Data: | |||
| self._is_command = True | |||
| self._trigger = self.command[0] | |||
| self._command = self.command[1:] # Strip the "!" or "." | |||
| elif re.match(r"{0}\W*?$".format(re.escape(self.my_nick)), | |||
| self.command, re.U): | |||
| elif re.match(rf"{re.escape(self.my_nick)}\W*?$", self.command, re.U): | |||
| # e.g. "EarwigBot, command arg1 arg2" | |||
| self._is_command = True | |||
| self._trigger = self.my_nick | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2021 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -22,10 +20,11 @@ | |||
| from time import sleep | |||
| from earwigbot.irc import IRCConnection, Data | |||
| from earwigbot.irc import Data, IRCConnection | |||
| __all__ = ["Frontend"] | |||
| class Frontend(IRCConnection): | |||
| """ | |||
| **EarwigBot: IRC Frontend Component** | |||
| @@ -38,13 +37,20 @@ class Frontend(IRCConnection): | |||
| :py:mod:`earwigbot.commands` or the bot's custom command directory | |||
| (explained in the :doc:`documentation </customizing>`). | |||
| """ | |||
| NICK_SERVICES = "NickServ" | |||
| def __init__(self, bot): | |||
| self.bot = bot | |||
| cf = bot.config.irc["frontend"] | |||
| super().__init__(cf["host"], cf["port"], cf["nick"], cf["ident"], | |||
| cf["realname"], bot.logger.getChild("frontend")) | |||
| super().__init__( | |||
| cf["host"], | |||
| cf["port"], | |||
| cf["nick"], | |||
| cf["ident"], | |||
| cf["realname"], | |||
| bot.logger.getChild("frontend"), | |||
| ) | |||
| self._auth_wait = False | |||
| self._channels = set() | |||
| @@ -53,8 +59,9 @@ class Frontend(IRCConnection): | |||
| def __repr__(self): | |||
| """Return the canonical string representation of the Frontend.""" | |||
| res = "Frontend(host={0!r}, port={1!r}, nick={2!r}, ident={3!r}, realname={4!r}, bot={5!r})" | |||
| return res.format(self.host, self.port, self.nick, self.ident, | |||
| self.realname, self.bot) | |||
| return res.format( | |||
| self.host, self.port, self.nick, self.ident, self.realname, self.bot | |||
| ) | |||
| def __str__(self): | |||
| """Return a nice string representation of the Frontend.""" | |||
| @@ -133,7 +140,7 @@ class Frontend(IRCConnection): | |||
| self._join_channels() | |||
| else: | |||
| self.logger.debug("Identifying with services") | |||
| msg = "IDENTIFY {0} {1}".format(username, password) | |||
| msg = f"IDENTIFY {username} {password}" | |||
| self.say(self.NICK_SERVICES, msg, hidelog=True) | |||
| self._auth_wait = True | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2021 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -24,14 +22,18 @@ import re | |||
| __all__ = ["RC"] | |||
| class RC: | |||
| """Store data from an event received from our IRC watcher.""" | |||
| re_color = re.compile("\x03([0-9]{1,2}(,[0-9]{1,2})?)?") | |||
| re_edit = re.compile("\A\[\[(.*?)\]\]\s(.*?)\s(https?://.*?)\s\*\s(.*?)\s\*\s(.*?)\Z") | |||
| re_edit = re.compile( | |||
| "\A\[\[(.*?)\]\]\s(.*?)\s(https?://.*?)\s\*\s(.*?)\s\*\s(.*?)\Z" | |||
| ) | |||
| re_log = re.compile("\A\[\[(.*?)\]\]\s(.*?)\s\s\*\s(.*?)\s\*\s(.*?)\Z") | |||
| pretty_edit = "\x02New {0}\x0F: \x0314[[\x0307{1}\x0314]]\x0306 * \x0303{2}\x0306 * \x0302{3}\x0306 * \x0310{4}" | |||
| pretty_log = "\x02New {0}\x0F: \x0303{1}\x0306 * \x0302{2}\x0306 * \x0310{3}" | |||
| pretty_edit = "\x02New {0}\x0f: \x0314[[\x0307{1}\x0314]]\x0306 * \x0303{2}\x0306 * \x0302{3}\x0306 * \x0310{4}" | |||
| pretty_log = "\x02New {0}\x0f: \x0303{1}\x0306 * \x0302{2}\x0306 * \x0310{3}" | |||
| plain_edit = "New {0}: [[{1}]] * {2} * {3} * {4}" | |||
| plain_log = "New {0}: {1} * {2} * {3}" | |||
| @@ -41,11 +43,11 @@ class RC: | |||
| def __repr__(self): | |||
| """Return the canonical string representation of the RC.""" | |||
| return "RC(chan={0!r}, msg={1!r})".format(self.chan, self.msg) | |||
| return f"RC(chan={self.chan!r}, msg={self.msg!r})" | |||
| def __str__(self): | |||
| """Return a nice string representation of the RC.""" | |||
| return "<RC of {0!r} on {1}>".format(self.msg, self.chan) | |||
| return f"<RC of {self.msg!r} on {self.chan}>" | |||
| def parse(self): | |||
| """Parse a recent change event into some variables.""" | |||
| @@ -62,7 +64,7 @@ class RC: | |||
| # We're probably missing the https:// part, because it's a log | |||
| # entry, which lacks a URL: | |||
| page, flags, user, comment = self.re_log.findall(msg)[0] | |||
| url = "https://{0}.org/wiki/{1}".format(self.chan[1:], page) | |||
| url = f"https://{self.chan[1:]}.org/wiki/{page}" | |||
| self.is_edit = False # This is a log entry, not edit | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2024 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -23,7 +21,7 @@ | |||
| import importlib.machinery | |||
| import importlib.util | |||
| from earwigbot.irc import IRCConnection, RC | |||
| from earwigbot.irc import RC, IRCConnection | |||
| __all__ = ["Watcher"] | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2024 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -1,6 +1,4 @@ | |||
| #! /usr/bin/env python | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2024 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -69,12 +67,11 @@ class _ResourceManager: | |||
| def __str__(self): | |||
| """Return a nice string representation of the manager.""" | |||
| return "<{0} of {1}>".format(self.__class__.__name__, self.bot) | |||
| return f"<{self.__class__.__name__} of {self.bot}>" | |||
| def __iter__(self): | |||
| with self.lock: | |||
| for resource in self._resources.values(): | |||
| yield resource | |||
| yield from self._resources.values() | |||
| def _is_disabled(self, name): | |||
| """Check whether a resource should be disabled.""" | |||
| @@ -99,7 +96,7 @@ class _ResourceManager: | |||
| self.logger.exception(e.format(res_type, name, path)) | |||
| else: | |||
| self._resources[resource.name] = resource | |||
| self.logger.debug("Loaded {0} {1}".format(res_type, resource.name)) | |||
| self.logger.debug(f"Loaded {res_type} {resource.name}") | |||
| def _load_module(self, name, path): | |||
| """Load a specific resource from a module, identified by name and path. | |||
| @@ -124,12 +121,12 @@ class _ResourceManager: | |||
| for obj in vars(module).values(): | |||
| if type(obj) is type: | |||
| isresource = issubclass(obj, self._resource_base) | |||
| if isresource and not obj is self._resource_base: | |||
| if isresource and obj is not self._resource_base: | |||
| self._load_resource(name, path, obj) | |||
| def _load_directory(self, dir): | |||
| """Load all valid resources in a given directory.""" | |||
| self.logger.debug("Loading directory {0}".format(dir)) | |||
| self.logger.debug(f"Loading directory {dir}") | |||
| processed = [] | |||
| for name in listdir(dir): | |||
| if not name.endswith(".py") and not name.endswith(".pyc"): | |||
| @@ -141,7 +138,7 @@ class _ResourceManager: | |||
| continue | |||
| processed.append(modname) | |||
| if self._is_disabled(modname): | |||
| log = "Skipping disabled module {0}".format(modname) | |||
| log = f"Skipping disabled module {modname}" | |||
| self.logger.debug(log) | |||
| continue | |||
| self._load_module(modname, dir) | |||
| @@ -188,7 +185,7 @@ class _ResourceManager: | |||
| resources = ", ".join(self._resources.keys()) | |||
| self.logger.info(msg.format(len(self._resources), name, resources)) | |||
| else: | |||
| self.logger.info("Loaded 0 {0}".format(name)) | |||
| self.logger.info(f"Loaded 0 {name}") | |||
| def get(self, key): | |||
| """Return the class instance associated with a certain resource. | |||
| @@ -241,7 +238,7 @@ class CommandManager(_ResourceManager): | |||
| if hook in command.hooks and self._wrap_check(command, data): | |||
| thread = Thread(target=self._wrap_process, args=(command, data)) | |||
| start_time = strftime("%b %d %H:%M:%S") | |||
| thread.name = "irc:{0} ({1})".format(command.name, start_time) | |||
| thread.name = f"irc:{command.name} ({start_time})" | |||
| thread.daemon = True | |||
| thread.start() | |||
| return | |||
| @@ -287,7 +284,7 @@ class TaskManager(_ResourceManager): | |||
| task_thread = Thread(target=self._wrapper, args=(task,), kwargs=kwargs) | |||
| start_time = strftime("%b %d %H:%M:%S") | |||
| task_thread.name = "{0} ({1})".format(task_name, start_time) | |||
| task_thread.name = f"{task_name} ({start_time})" | |||
| task_thread.daemon = True | |||
| task_thread.start() | |||
| return task_thread | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -21,10 +19,10 @@ | |||
| # SOFTWARE. | |||
| from earwigbot import exceptions | |||
| from earwigbot import wiki | |||
| __all__ = ["Task"] | |||
| class Task: | |||
| """ | |||
| **EarwigBot: Base Bot Task** | |||
| @@ -39,6 +37,7 @@ class Task: | |||
| <earwigbot.managers.TaskManager.start>`. ``**kwargs`` get passed to the | |||
| Task's :meth:`run` method. | |||
| """ | |||
| name = None | |||
| number = 0 | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2024 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -167,7 +165,7 @@ class WikiProjectTagger(Task): | |||
| self.process_category(site.get_page(title), job, recursive) | |||
| if "file" in kwargs: | |||
| with open(kwargs["file"], "r") as fileobj: | |||
| with open(kwargs["file"]) as fileobj: | |||
| for line in fileobj: | |||
| if line.strip(): | |||
| if line.startswith("[[") and line.endswith("]]"): | |||
| @@ -344,9 +342,9 @@ class WikiProjectTagger(Task): | |||
| def update_banner(self, banner, job, code): | |||
| """Update an existing *banner* based on a *job* and a page's *code*.""" | |||
| has = lambda key: ( | |||
| banner.has(key) and banner.get(key).value.strip() not in ("", "?") | |||
| ) | |||
| def has(key): | |||
| return banner.has(key) and banner.get(key).value.strip() not in ("", "?") | |||
| updated = False | |||
| if job.autoassess is not False: | |||
| @@ -1,6 +1,4 @@ | |||
| #! /usr/bin/env python | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -49,8 +47,8 @@ or run specific tasks. | |||
| """ | |||
| from argparse import Action, ArgumentParser, REMAINDER | |||
| import logging | |||
| from argparse import REMAINDER, Action, ArgumentParser | |||
| from os import path | |||
| from time import sleep | |||
| @@ -59,8 +57,10 @@ from earwigbot.bot import Bot | |||
| __all__ = ["main"] | |||
| class _StoreTaskArg(Action): | |||
| """A custom argparse action to read remaining command-line arguments.""" | |||
| def __call__(self, parser, namespace, values, option_string=None): | |||
| kwargs = {} | |||
| name = None | |||
| @@ -96,32 +96,53 @@ class _StoreTaskArg(Action): | |||
| def main(): | |||
| """Main entry point for the command-line utility.""" | |||
| version = "EarwigBot v{0}".format(__version__) | |||
| version = f"EarwigBot v{__version__}" | |||
| desc = """This is EarwigBot's command-line utility, enabling you to easily | |||
| start the bot or run specific tasks.""" | |||
| parser = ArgumentParser(description=desc) | |||
| parser.add_argument("path", nargs="?", metavar="PATH", default=path.curdir, | |||
| help="""path to the bot's working directory, which will | |||
| parser.add_argument( | |||
| "path", | |||
| nargs="?", | |||
| metavar="PATH", | |||
| default=path.curdir, | |||
| help="""path to the bot's working directory, which will | |||
| be created if it doesn't exist; current | |||
| directory assumed if not specified""") | |||
| directory assumed if not specified""", | |||
| ) | |||
| parser.add_argument("-v", "--version", action="version", version=version) | |||
| logger = parser.add_mutually_exclusive_group() | |||
| logger.add_argument("-d", "--debug", action="store_true", | |||
| help="print all logs, including DEBUG-level messages") | |||
| logger.add_argument("-q", "--quiet", action="store_true", | |||
| help="don't print any logs except warnings and errors") | |||
| parser.add_argument("-t", "--task", metavar="NAME", | |||
| help="""given the name of a task, the bot will run it | |||
| instead of the main bot and then exit""") | |||
| parser.add_argument("task_args", nargs=REMAINDER, action=_StoreTaskArg, | |||
| metavar="TASK_ARGS", | |||
| help="""with --task, will pass these arguments to the | |||
| task's run() method""") | |||
| logger.add_argument( | |||
| "-d", | |||
| "--debug", | |||
| action="store_true", | |||
| help="print all logs, including DEBUG-level messages", | |||
| ) | |||
| logger.add_argument( | |||
| "-q", | |||
| "--quiet", | |||
| action="store_true", | |||
| help="don't print any logs except warnings and errors", | |||
| ) | |||
| parser.add_argument( | |||
| "-t", | |||
| "--task", | |||
| metavar="NAME", | |||
| help="""given the name of a task, the bot will run it | |||
| instead of the main bot and then exit""", | |||
| ) | |||
| parser.add_argument( | |||
| "task_args", | |||
| nargs=REMAINDER, | |||
| action=_StoreTaskArg, | |||
| metavar="TASK_ARGS", | |||
| help="""with --task, will pass these arguments to the | |||
| task's run() method""", | |||
| ) | |||
| args = parser.parse_args() | |||
| if not args.task and args.task_args: | |||
| unrecognized = " ".join(args.task_args) | |||
| parser.error("unrecognized arguments: {0}".format(unrecognized)) | |||
| parser.error(f"unrecognized arguments: {unrecognized}") | |||
| level = logging.INFO | |||
| if args.debug: | |||
| @@ -153,5 +174,6 @@ def main(): | |||
| if bot.is_running: | |||
| bot.stop() | |||
| if __name__ == "__main__": | |||
| main() | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -24,6 +22,7 @@ from earwigbot.wiki.page import Page | |||
| __all__ = ["Category"] | |||
| class Category(Page): | |||
| """ | |||
| **EarwigBot: Wiki Toolset: Category** | |||
| @@ -56,7 +55,7 @@ class Category(Page): | |||
| def __str__(self): | |||
| """Return a nice string representation of the Category.""" | |||
| return '<Category "{0}" of {1}>'.format(self.title, str(self.site)) | |||
| return f'<Category "{self.title}" of {str(self.site)}>' | |||
| def __iter__(self): | |||
| """Iterate over all members of the category.""" | |||
| @@ -64,8 +63,12 @@ class Category(Page): | |||
| def _get_members_via_api(self, limit, follow): | |||
| """Iterate over Pages in the category using the API.""" | |||
| params = {"action": "query", "list": "categorymembers", | |||
| "cmtitle": self.title, "continue": ""} | |||
| params = { | |||
| "action": "query", | |||
| "list": "categorymembers", | |||
| "cmtitle": self.title, | |||
| "continue": "", | |||
| } | |||
| while 1: | |||
| params["cmlimit"] = limit if limit else "max" | |||
| @@ -102,13 +105,13 @@ class Category(Page): | |||
| title = ":".join((namespace, base)) | |||
| else: # Avoid doing a silly (albeit valid) ":Pagename" thing | |||
| title = base | |||
| yield self.site.get_page(title, follow_redirects=follow, | |||
| pageid=row[2]) | |||
| yield self.site.get_page(title, follow_redirects=follow, pageid=row[2]) | |||
| def _get_size_via_api(self, member_type): | |||
| """Return the size of the category using the API.""" | |||
| result = self.site.api_query(action="query", prop="categoryinfo", | |||
| titles=self.title) | |||
| result = self.site.api_query( | |||
| action="query", prop="categoryinfo", titles=self.title | |||
| ) | |||
| info = list(result["query"]["pages"].values())[0]["categoryinfo"] | |||
| return info[member_type] | |||
| @@ -127,7 +130,7 @@ class Category(Page): | |||
| """Return the size of the category.""" | |||
| services = { | |||
| self.site.SERVICE_API: self._get_size_via_api, | |||
| self.site.SERVICE_SQL: self._get_size_via_sql | |||
| self.site.SERVICE_SQL: self._get_size_via_sql, | |||
| } | |||
| return self.site.delegate(services, (member_type,)) | |||
| @@ -201,7 +204,7 @@ class Category(Page): | |||
| """ | |||
| services = { | |||
| self.site.SERVICE_API: self._get_members_via_api, | |||
| self.site.SERVICE_SQL: self._get_members_via_sql | |||
| self.site.SERVICE_SQL: self._get_members_via_sql, | |||
| } | |||
| if follow_redirects is None: | |||
| follow_redirects = self._follow_redirects | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -34,8 +32,10 @@ Import directly with ``from earwigbot.wiki import constants`` or | |||
| """ | |||
| # Default User Agent when making API queries: | |||
| from earwigbot import __version__ as _v | |||
| from platform import python_version as _p | |||
| from earwigbot import __version__ as _v | |||
| USER_AGENT = "EarwigBot/{0} (Python/{1}; https://github.com/earwig/earwigbot)" | |||
| USER_AGENT = USER_AGENT.format(_v, _p()) | |||
| del _v, _p | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2016 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -20,18 +18,18 @@ | |||
| # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |||
| # SOFTWARE. | |||
| from time import sleep, time | |||
| from time import sleep | |||
| from urllib.request import build_opener | |||
| from earwigbot import exceptions | |||
| from earwigbot.wiki.copyvios.markov import MarkovChain | |||
| from earwigbot.wiki.copyvios.parsers import ArticleTextParser | |||
| from earwigbot.wiki.copyvios.search import SEARCH_ENGINES | |||
| from earwigbot.wiki.copyvios.workers import ( | |||
| globalize, localize, CopyvioWorkspace) | |||
| from earwigbot.wiki.copyvios.workers import CopyvioWorkspace, globalize, localize | |||
| __all__ = ["CopyvioMixIn", "globalize", "localize"] | |||
| class CopyvioMixIn: | |||
| """ | |||
| **EarwigBot: Wiki Toolset: Copyright Violation MixIn** | |||
| @@ -46,8 +44,10 @@ class CopyvioMixIn: | |||
| def __init__(self, site): | |||
| self._search_config = site._search_config | |||
| self._exclusions_db = self._search_config.get("exclusions_db") | |||
| self._addheaders = [("User-Agent", site.user_agent), | |||
| ("Accept-Encoding", "gzip")] | |||
| self._addheaders = [ | |||
| ("User-Agent", site.user_agent), | |||
| ("Accept-Encoding", "gzip"), | |||
| ] | |||
| def _get_search_engine(self): | |||
| """Return a function that can be called to do web searches. | |||
| @@ -80,8 +80,15 @@ class CopyvioMixIn: | |||
| return klass(credentials, opener) | |||
| def copyvio_check(self, min_confidence=0.75, max_queries=15, max_time=-1, | |||
| no_searches=False, no_links=False, short_circuit=True): | |||
| def copyvio_check( | |||
| self, | |||
| min_confidence=0.75, | |||
| max_queries=15, | |||
| max_time=-1, | |||
| no_searches=False, | |||
| no_links=False, | |||
| short_circuit=True, | |||
| ): | |||
| """Check the page for copyright violations. | |||
| Returns a :class:`.CopyvioCheckResult` object with information on the | |||
| @@ -117,25 +124,34 @@ class CopyvioMixIn: | |||
| log = "Starting copyvio check for [[{0}]]" | |||
| self._logger.info(log.format(self.title)) | |||
| searcher = self._get_search_engine() | |||
| parser = ArticleTextParser(self.get(), args={ | |||
| "nltk_dir": self._search_config["nltk_dir"], | |||
| "lang": self._site.lang | |||
| }) | |||
| parser = ArticleTextParser( | |||
| self.get(), | |||
| args={"nltk_dir": self._search_config["nltk_dir"], "lang": self._site.lang}, | |||
| ) | |||
| article = MarkovChain(parser.strip()) | |||
| parser_args = {} | |||
| if self._exclusions_db: | |||
| self._exclusions_db.sync(self.site.name) | |||
| exclude = lambda u: self._exclusions_db.check(self.site.name, u) | |||
| parser_args["mirror_hints"] = \ | |||
| self._exclusions_db.get_mirror_hints(self) | |||
| def exclude(u): | |||
| return self._exclusions_db.check(self.site.name, u) | |||
| parser_args["mirror_hints"] = self._exclusions_db.get_mirror_hints(self) | |||
| else: | |||
| exclude = None | |||
| workspace = CopyvioWorkspace( | |||
| article, min_confidence, max_time, self._logger, self._addheaders, | |||
| short_circuit=short_circuit, parser_args=parser_args, exclude_check=exclude, | |||
| config=self._search_config) | |||
| article, | |||
| min_confidence, | |||
| max_time, | |||
| self._logger, | |||
| self._addheaders, | |||
| short_circuit=short_circuit, | |||
| parser_args=parser_args, | |||
| exclude_check=exclude, | |||
| config=self._search_config, | |||
| ) | |||
| if article.size < 20: # Auto-fail very small articles | |||
| result = workspace.get_result() | |||
| @@ -187,8 +203,15 @@ class CopyvioMixIn: | |||
| self._logger.info(log.format(self.title, url)) | |||
| article = MarkovChain(ArticleTextParser(self.get()).strip()) | |||
| workspace = CopyvioWorkspace( | |||
| article, min_confidence, max_time, self._logger, self._addheaders, | |||
| max_time, num_workers=1, config=self._search_config) | |||
| article, | |||
| min_confidence, | |||
| max_time, | |||
| self._logger, | |||
| self._addheaders, | |||
| max_time, | |||
| num_workers=1, | |||
| config=self._search_config, | |||
| ) | |||
| workspace.enqueue([url]) | |||
| workspace.wait() | |||
| result = workspace.get_result() | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2016 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -33,18 +31,23 @@ __all__ = ["ExclusionsDB"] | |||
| DEFAULT_SOURCES = { | |||
| "all": [ # Applies to all, but located on enwiki | |||
| "User:EarwigBot/Copyvios/Exclusions", | |||
| "User:EranBot/Copyright/Blacklist" | |||
| "User:EranBot/Copyright/Blacklist", | |||
| ], | |||
| "enwiki": [ | |||
| "Wikipedia:Mirrors and forks/ABC", "Wikipedia:Mirrors and forks/DEF", | |||
| "Wikipedia:Mirrors and forks/GHI", "Wikipedia:Mirrors and forks/JKL", | |||
| "Wikipedia:Mirrors and forks/MNO", "Wikipedia:Mirrors and forks/PQR", | |||
| "Wikipedia:Mirrors and forks/STU", "Wikipedia:Mirrors and forks/VWXYZ" | |||
| ] | |||
| "Wikipedia:Mirrors and forks/ABC", | |||
| "Wikipedia:Mirrors and forks/DEF", | |||
| "Wikipedia:Mirrors and forks/GHI", | |||
| "Wikipedia:Mirrors and forks/JKL", | |||
| "Wikipedia:Mirrors and forks/MNO", | |||
| "Wikipedia:Mirrors and forks/PQR", | |||
| "Wikipedia:Mirrors and forks/STU", | |||
| "Wikipedia:Mirrors and forks/VWXYZ", | |||
| ], | |||
| } | |||
| _RE_STRIP_PREFIX = r"^https?://(www\.)?" | |||
| class ExclusionsDB: | |||
| """ | |||
| **EarwigBot: Wiki Toolset: Exclusions Database Manager** | |||
| @@ -66,7 +69,7 @@ class ExclusionsDB: | |||
| def __str__(self): | |||
| """Return a nice string representation of the ExclusionsDB.""" | |||
| return "<ExclusionsDB at {0}>".format(self._dbfile) | |||
| return f"<ExclusionsDB at {self._dbfile}>" | |||
| def _create(self): | |||
| """Initialize the exclusions database with its necessary tables.""" | |||
| @@ -95,7 +98,10 @@ class ExclusionsDB: | |||
| if source == "User:EarwigBot/Copyvios/Exclusions": | |||
| for line in data.splitlines(): | |||
| match = re.match(r"^\s*url\s*=\s*(?:\<nowiki\>\s*)?(.+?)\s*(?:\</nowiki\>\s*)?(?:#.*?)?$", line) | |||
| match = re.match( | |||
| r"^\s*url\s*=\s*(?:\<nowiki\>\s*)?(.+?)\s*(?:\</nowiki\>\s*)?(?:#.*?)?$", | |||
| line, | |||
| ) | |||
| if match: | |||
| url = re.sub(_RE_STRIP_PREFIX, "", match.group(1)) | |||
| if url: | |||
| @@ -121,7 +127,9 @@ class ExclusionsDB: | |||
| """Update the database from listed sources in the index.""" | |||
| query1 = "SELECT source_page FROM sources WHERE source_sitename = ?" | |||
| query2 = "SELECT exclusion_url FROM exclusions WHERE exclusion_sitename = ?" | |||
| query3 = "DELETE FROM exclusions WHERE exclusion_sitename = ? AND exclusion_url = ?" | |||
| query3 = ( | |||
| "DELETE FROM exclusions WHERE exclusion_sitename = ? AND exclusion_url = ?" | |||
| ) | |||
| query4 = "INSERT INTO exclusions VALUES (?, ?)" | |||
| query5 = "SELECT 1 FROM updates WHERE update_sitename = ?" | |||
| query6 = "UPDATE updates SET update_time = ? WHERE update_sitename = ?" | |||
| @@ -206,7 +214,7 @@ class ExclusionsDB: | |||
| self._logger.debug(log.format(sitename, url)) | |||
| return True | |||
| log = "No exclusions in {0} for {1}".format(sitename, url) | |||
| log = f"No exclusions in {sitename} for {url}" | |||
| self._logger.debug(log) | |||
| return False | |||
| @@ -224,9 +232,12 @@ class ExclusionsDB: | |||
| if try_mobile: | |||
| fragments = re.search(r"^([\w]+)\.([\w]+).([\w]+)$", site.domain) | |||
| if fragments: | |||
| roots.append("{0}.m.{1}.{2}".format(*fragments.groups())) | |||
| roots.append("{}.m.{}.{}".format(*fragments.groups())) | |||
| general = [root + site._script_path + "/" + script | |||
| for root in roots for script in scripts] | |||
| general = [ | |||
| root + site._script_path + "/" + script | |||
| for root in roots | |||
| for script in scripts | |||
| ] | |||
| specific = [root + path for root in roots] | |||
| return general + specific | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -20,13 +18,14 @@ | |||
| # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |||
| # SOFTWARE. | |||
| from re import sub, UNICODE | |||
| from re import UNICODE, sub | |||
| __all__ = ["EMPTY", "EMPTY_INTERSECTION", "MarkovChain", "MarkovChainIntersection"] | |||
| __all__ = ["EMPTY", "EMPTY_INTERSECTION", "MarkovChain", | |||
| "MarkovChainIntersection"] | |||
| class MarkovChain: | |||
| """Implements a basic ngram Markov chain of words.""" | |||
| START = -1 | |||
| END = -2 | |||
| degree = 5 # 2 for bigrams, 3 for trigrams, etc. | |||
| @@ -44,7 +43,7 @@ class MarkovChain: | |||
| chain = {} | |||
| for i in range(len(words) - self.degree + 1): | |||
| phrase = tuple(words[i:i+self.degree]) | |||
| phrase = tuple(words[i : i + self.degree]) | |||
| if phrase in chain: | |||
| chain[phrase] += 1 | |||
| else: | |||
| @@ -57,11 +56,11 @@ class MarkovChain: | |||
| def __repr__(self): | |||
| """Return the canonical string representation of the MarkovChain.""" | |||
| return "MarkovChain(text={0!r})".format(self.text) | |||
| return f"MarkovChain(text={self.text!r})" | |||
| def __str__(self): | |||
| """Return a nice string representation of the MarkovChain.""" | |||
| return "<MarkovChain of size {0}>".format(self.size) | |||
| return f"<MarkovChain of size {self.size}>" | |||
| class MarkovChainIntersection(MarkovChain): | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2019 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -21,11 +19,11 @@ | |||
| # SOFTWARE. | |||
| import json | |||
| from os import path | |||
| import re | |||
| from io import StringIO | |||
| import urllib.parse | |||
| import urllib.request | |||
| from io import StringIO | |||
| from os import path | |||
| import mwparserfromhell | |||
| @@ -40,8 +38,10 @@ pdfpage = importer.new("pdfminer.pdfpage") | |||
| __all__ = ["ArticleTextParser", "get_parser"] | |||
| class _BaseTextParser: | |||
| """Base class for a parser that handles text.""" | |||
| TYPE = None | |||
| def __init__(self, text, url=None, args=None): | |||
| @@ -51,16 +51,17 @@ class _BaseTextParser: | |||
| def __repr__(self): | |||
| """Return the canonical string representation of the text parser.""" | |||
| return "{0}(text={1!r})".format(self.__class__.__name__, self.text) | |||
| return f"{self.__class__.__name__}(text={self.text!r})" | |||
| def __str__(self): | |||
| """Return a nice string representation of the text parser.""" | |||
| name = self.__class__.__name__ | |||
| return "<{0} of text with size {1}>".format(name, len(self.text)) | |||
| return f"<{name} of text with size {len(self.text)}>" | |||
| class ArticleTextParser(_BaseTextParser): | |||
| """A parser that can strip and chunk wikicode article text.""" | |||
| TYPE = "Article" | |||
| TEMPLATE_MERGE_THRESHOLD = 35 | |||
| NLTK_DEFAULT = "english" | |||
| @@ -81,7 +82,7 @@ class ArticleTextParser(_BaseTextParser): | |||
| "pt": "portuguese", | |||
| "sl": "slovene", | |||
| "sv": "swedish", | |||
| "tr": "turkish" | |||
| "tr": "turkish", | |||
| } | |||
| def _merge_templates(self, code): | |||
| @@ -100,8 +101,11 @@ class ArticleTextParser(_BaseTextParser): | |||
| def _get_tokenizer(self): | |||
| """Return a NLTK punctuation tokenizer for the article's language.""" | |||
| datafile = lambda lang: "file:" + path.join( | |||
| self._args["nltk_dir"], "tokenizers", "punkt", lang + ".pickle") | |||
| def datafile(lang): | |||
| return "file:" + path.join( | |||
| self._args["nltk_dir"], "tokenizers", "punkt", lang + ".pickle" | |||
| ) | |||
| lang = self.NLTK_LANGS.get(self._args.get("lang"), self.NLTK_DEFAULT) | |||
| try: | |||
| @@ -112,6 +116,7 @@ class ArticleTextParser(_BaseTextParser): | |||
| def _get_sentences(self, min_query, max_query, split_thresh): | |||
| """Split the article text into sentences of a certain length.""" | |||
| def cut_sentence(words): | |||
| div = len(words) | |||
| if div == 0: | |||
| @@ -125,7 +130,7 @@ class ArticleTextParser(_BaseTextParser): | |||
| result = [] | |||
| if length >= split_thresh: | |||
| result.append(" ".join(words[:div])) | |||
| return result + cut_sentence(words[div + 1:]) | |||
| return result + cut_sentence(words[div + 1 :]) | |||
| tokenizer = self._get_tokenizer() | |||
| sentences = [] | |||
| @@ -150,6 +155,7 @@ class ArticleTextParser(_BaseTextParser): | |||
| The actual stripping is handled by :py:mod:`mwparserfromhell`. | |||
| """ | |||
| def remove(code, node): | |||
| """Remove a node from a code object, ignoring ValueError. | |||
| @@ -223,16 +229,14 @@ class ArticleTextParser(_BaseTextParser): | |||
| """ | |||
| schemes = ("http://", "https://") | |||
| links = mwparserfromhell.parse(self.text).ifilter_external_links() | |||
| return [str(link.url) for link in links | |||
| if link.url.startswith(schemes)] | |||
| return [str(link.url) for link in links if link.url.startswith(schemes)] | |||
| class _HTMLParser(_BaseTextParser): | |||
| """A parser that can extract the text from an HTML document.""" | |||
| TYPE = "HTML" | |||
| hidden_tags = [ | |||
| "script", "style" | |||
| ] | |||
| hidden_tags = ["script", "style"] | |||
| def _fail_if_mirror(self, soup): | |||
| """Look for obvious signs that the given soup is a wiki mirror. | |||
| @@ -243,8 +247,9 @@ class _HTMLParser(_BaseTextParser): | |||
| if "mirror_hints" not in self._args: | |||
| return | |||
| func = lambda attr: attr and any( | |||
| hint in attr for hint in self._args["mirror_hints"]) | |||
| def func(attr): | |||
| return attr and any(hint in attr for hint in self._args["mirror_hints"]) | |||
| if soup.find_all(href=func) or soup.find_all(src=func): | |||
| raise ParserExclusionError() | |||
| @@ -258,7 +263,10 @@ class _HTMLParser(_BaseTextParser): | |||
| def _clean_soup(self, soup): | |||
| """Clean a BeautifulSoup tree of invisible tags.""" | |||
| is_comment = lambda text: isinstance(text, bs4.element.Comment) | |||
| def is_comment(text): | |||
| return isinstance(text, bs4.element.Comment) | |||
| for comment in soup.find_all(text=is_comment): | |||
| comment.extract() | |||
| for tag in self.hidden_tags: | |||
| @@ -281,15 +289,17 @@ class _HTMLParser(_BaseTextParser): | |||
| if not match: | |||
| return "" | |||
| post_id = match.group(1) | |||
| url = "https://%s/feeds/posts/default/%s?" % (url.netloc, post_id) | |||
| url = f"https://{url.netloc}/feeds/posts/default/{post_id}?" | |||
| params = { | |||
| "alt": "json", | |||
| "v": "2", | |||
| "dynamicviews": "1", | |||
| "rewriteforssl": "true", | |||
| } | |||
| raw = self._open(url + urllib.parse.urlencode(params), | |||
| allow_content_types=["application/json"]) | |||
| raw = self._open( | |||
| url + urllib.parse.urlencode(params), | |||
| allow_content_types=["application/json"], | |||
| ) | |||
| if raw is None: | |||
| return "" | |||
| try: | |||
| @@ -334,6 +344,7 @@ class _HTMLParser(_BaseTextParser): | |||
| class _PDFParser(_BaseTextParser): | |||
| """A parser that can extract text from a PDF file.""" | |||
| TYPE = "PDF" | |||
| substitutions = [ | |||
| ("\x0c", "\n"), | |||
| @@ -364,6 +375,7 @@ class _PDFParser(_BaseTextParser): | |||
| class _PlainTextParser(_BaseTextParser): | |||
| """A parser that can unicode-ify and strip text from a plain text page.""" | |||
| TYPE = "Text" | |||
| def parse(self): | |||
| @@ -377,9 +389,10 @@ _CONTENT_TYPES = { | |||
| "application/xhtml+xml": _HTMLParser, | |||
| "application/pdf": _PDFParser, | |||
| "application/x-pdf": _PDFParser, | |||
| "text/plain": _PlainTextParser | |||
| "text/plain": _PlainTextParser, | |||
| } | |||
| def get_parser(content_type): | |||
| """Return the parser most able to handle a given content type, or None.""" | |||
| return _CONTENT_TYPES.get(content_type) | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -27,6 +25,7 @@ from earwigbot.wiki.copyvios.markov import EMPTY, EMPTY_INTERSECTION | |||
| __all__ = ["CopyvioSource", "CopyvioCheckResult"] | |||
| class CopyvioSource: | |||
| """ | |||
| **EarwigBot: Wiki Toolset: Copyvio Source** | |||
| @@ -43,8 +42,15 @@ class CopyvioSource: | |||
| - :py:attr:`excluded`: whether this URL was in the exclusions list | |||
| """ | |||
| def __init__(self, workspace, url, headers=None, timeout=5, | |||
| parser_args=None, search_config=None): | |||
| def __init__( | |||
| self, | |||
| workspace, | |||
| url, | |||
| headers=None, | |||
| timeout=5, | |||
| parser_args=None, | |||
| search_config=None, | |||
| ): | |||
| self.workspace = workspace | |||
| self.url = url | |||
| self.headers = headers | |||
| @@ -63,17 +69,18 @@ class CopyvioSource: | |||
| def __repr__(self): | |||
| """Return the canonical string representation of the source.""" | |||
| res = ("CopyvioSource(url={0!r}, confidence={1!r}, skipped={2!r}, " | |||
| "excluded={3!r})") | |||
| return res.format( | |||
| self.url, self.confidence, self.skipped, self.excluded) | |||
| res = ( | |||
| "CopyvioSource(url={0!r}, confidence={1!r}, skipped={2!r}, " | |||
| "excluded={3!r})" | |||
| ) | |||
| return res.format(self.url, self.confidence, self.skipped, self.excluded) | |||
| def __str__(self): | |||
| """Return a nice string representation of the source.""" | |||
| if self.excluded: | |||
| return "<CopyvioSource ({0}, excluded)>".format(self.url) | |||
| return f"<CopyvioSource ({self.url}, excluded)>" | |||
| if self.skipped: | |||
| return "<CopyvioSource ({0}, skipped)>".format(self.url) | |||
| return f"<CopyvioSource ({self.url}, skipped)>" | |||
| res = "<CopyvioSource ({0} with {1} conf)>" | |||
| return res.format(self.url, self.confidence) | |||
| @@ -129,8 +136,9 @@ class CopyvioCheckResult: | |||
| - :py:attr:`possible_miss`: whether some URLs might have been missed | |||
| """ | |||
| def __init__(self, violation, sources, queries, check_time, article_chain, | |||
| possible_miss): | |||
| def __init__( | |||
| self, violation, sources, queries, check_time, article_chain, possible_miss | |||
| ): | |||
| self.violation = violation | |||
| self.sources = sources | |||
| self.queries = queries | |||
| @@ -141,8 +149,7 @@ class CopyvioCheckResult: | |||
| def __repr__(self): | |||
| """Return the canonical string representation of the result.""" | |||
| res = "CopyvioCheckResult(violation={0!r}, sources={1!r}, queries={2!r}, time={3!r})" | |||
| return res.format(self.violation, self.sources, self.queries, | |||
| self.time) | |||
| return res.format(self.violation, self.sources, self.queries, self.time) | |||
| def __str__(self): | |||
| """Return a nice string representation of the result.""" | |||
| @@ -171,5 +178,12 @@ class CopyvioCheckResult: | |||
| return log.format(title, self.queries, self.time) | |||
| log = "{0} for [[{1}]] (best: {2} ({3} confidence); {4} sources; {5} queries; {6} seconds)" | |||
| is_vio = "Violation detected" if self.violation else "No violation" | |||
| return log.format(is_vio, title, self.url, self.confidence, | |||
| len(self.sources), self.queries, self.time) | |||
| return log.format( | |||
| is_vio, | |||
| title, | |||
| self.url, | |||
| self.confidence, | |||
| len(self.sources), | |||
| self.queries, | |||
| self.time, | |||
| ) | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2016 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -21,22 +19,28 @@ | |||
| # SOFTWARE. | |||
| from gzip import GzipFile | |||
| from io import StringIO | |||
| from json import loads | |||
| from re import sub as re_sub | |||
| from socket import error | |||
| from io import StringIO | |||
| from urllib.parse import quote, urlencode | |||
| from urllib.error import URLError | |||
| from urllib.parse import urlencode | |||
| from earwigbot import importer | |||
| from earwigbot.exceptions import SearchQueryError | |||
| lxml = importer.new("lxml") | |||
| __all__ = ["BingSearchEngine", "GoogleSearchEngine", "YandexSearchEngine", "SEARCH_ENGINES"] | |||
| __all__ = [ | |||
| "BingSearchEngine", | |||
| "GoogleSearchEngine", | |||
| "YandexSearchEngine", | |||
| "SEARCH_ENGINES", | |||
| ] | |||
| class _BaseSearchEngine: | |||
| """Base class for a simple search engine interface.""" | |||
| name = "Base" | |||
| def __init__(self, cred, opener): | |||
| @@ -47,19 +51,19 @@ class _BaseSearchEngine: | |||
| def __repr__(self): | |||
| """Return the canonical string representation of the search engine.""" | |||
| return "{0}()".format(self.__class__.__name__) | |||
| return f"{self.__class__.__name__}()" | |||
| def __str__(self): | |||
| """Return a nice string representation of the search engine.""" | |||
| return "<{0}>".format(self.__class__.__name__) | |||
| return f"<{self.__class__.__name__}>" | |||
| def _open(self, *args): | |||
| """Open a URL (like urlopen) and try to return its contents.""" | |||
| try: | |||
| response = self.opener.open(*args) | |||
| result = response.read() | |||
| except (URLError, error) as exc: | |||
| err = SearchQueryError("{0} Error: {1}".format(self.name, exc)) | |||
| except (OSError, URLError) as exc: | |||
| err = SearchQueryError(f"{self.name} Error: {exc}") | |||
| err.cause = exc | |||
| raise err | |||
| @@ -90,6 +94,7 @@ class _BaseSearchEngine: | |||
| class BingSearchEngine(_BaseSearchEngine): | |||
| """A search engine interface with Bing Search (via Azure Marketplace).""" | |||
| name = "Bing" | |||
| def __init__(self, cred, opener): | |||
| @@ -106,7 +111,7 @@ class BingSearchEngine(_BaseSearchEngine): | |||
| Raises :py:exc:`~earwigbot.exceptions.SearchQueryError` on errors. | |||
| """ | |||
| service = "SearchWeb" if self.cred["type"] == "searchweb" else "Search" | |||
| url = "https://api.datamarket.azure.com/Bing/{0}/Web?".format(service) | |||
| url = f"https://api.datamarket.azure.com/Bing/{service}/Web?" | |||
| params = { | |||
| "$format": "json", | |||
| "$top": str(self.count), | |||
| @@ -114,7 +119,7 @@ class BingSearchEngine(_BaseSearchEngine): | |||
| "Market": "'en-US'", | |||
| "Adult": "'Off'", | |||
| "Options": "'DisableLocationDetection'", | |||
| "WebSearchOptions": "'DisableHostCollapsing+DisableQueryAlterations'" | |||
| "WebSearchOptions": "'DisableHostCollapsing+DisableQueryAlterations'", | |||
| } | |||
| result = self._open(url + urlencode(params)) | |||
| @@ -134,6 +139,7 @@ class BingSearchEngine(_BaseSearchEngine): | |||
| class GoogleSearchEngine(_BaseSearchEngine): | |||
| """A search engine interface with Google Search.""" | |||
| name = "Google" | |||
| def search(self, query): | |||
| @@ -143,7 +149,7 @@ class GoogleSearchEngine(_BaseSearchEngine): | |||
| Raises :py:exc:`~earwigbot.exceptions.SearchQueryError` on errors. | |||
| """ | |||
| domain = self.cred.get("proxy", "www.googleapis.com") | |||
| url = "https://{0}/customsearch/v1?".format(domain) | |||
| url = f"https://{domain}/customsearch/v1?" | |||
| params = { | |||
| "cx": self.cred["id"], | |||
| "key": self.cred["key"], | |||
| @@ -151,7 +157,7 @@ class GoogleSearchEngine(_BaseSearchEngine): | |||
| "alt": "json", | |||
| "num": str(self.count), | |||
| "safe": "off", | |||
| "fields": "items(link)" | |||
| "fields": "items(link)", | |||
| } | |||
| result = self._open(url + urlencode(params)) | |||
| @@ -170,6 +176,7 @@ class GoogleSearchEngine(_BaseSearchEngine): | |||
| class YandexSearchEngine(_BaseSearchEngine): | |||
| """A search engine interface with Yandex Search.""" | |||
| name = "Yandex" | |||
| @staticmethod | |||
| @@ -183,7 +190,7 @@ class YandexSearchEngine(_BaseSearchEngine): | |||
| Raises :py:exc:`~earwigbot.exceptions.SearchQueryError` on errors. | |||
| """ | |||
| domain = self.cred.get("proxy", "yandex.com") | |||
| url = "https://{0}/search/xml?".format(domain) | |||
| url = f"https://{domain}/search/xml?" | |||
| query = re_sub(r"[^a-zA-Z0-9 ]", "", query).encode("utf8") | |||
| params = { | |||
| "user": self.cred["user"], | |||
| @@ -192,7 +199,7 @@ class YandexSearchEngine(_BaseSearchEngine): | |||
| "l10n": "en", | |||
| "filter": "none", | |||
| "maxpassages": "1", | |||
| "groupby": "mode=flat.groups-on-page={0}".format(self.count) | |||
| "groupby": f"mode=flat.groups-on-page={self.count}", | |||
| } | |||
| result = self._open(url + urlencode(params)) | |||
| @@ -207,5 +214,5 @@ class YandexSearchEngine(_BaseSearchEngine): | |||
| SEARCH_ENGINES = { | |||
| "Bing": BingSearchEngine, | |||
| "Google": GoogleSearchEngine, | |||
| "Yandex": YandexSearchEngine | |||
| "Yandex": YandexSearchEngine, | |||
| } | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2019 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -22,21 +20,20 @@ | |||
| import base64 | |||
| import collections | |||
| from collections import deque | |||
| import functools | |||
| import time | |||
| import urllib.parse | |||
| from collections import deque | |||
| from gzip import GzipFile | |||
| from http.client import HTTPException | |||
| from io import StringIO | |||
| from logging import getLogger | |||
| from math import log | |||
| from queue import Empty, Queue | |||
| from socket import error as socket_error | |||
| from io import StringIO | |||
| from struct import error as struct_error | |||
| from threading import Lock, Thread | |||
| import time | |||
| from urllib.error import URLError | |||
| import urllib.parse | |||
| from urllib.request import build_opener, Request | |||
| from urllib.request import Request, build_opener | |||
| from earwigbot import importer | |||
| from earwigbot.exceptions import ParserExclusionError, ParserRedirectError | |||
| @@ -49,13 +46,14 @@ tldextract = importer.new("tldextract") | |||
| __all__ = ["globalize", "localize", "CopyvioWorkspace"] | |||
| _MAX_REDIRECTS = 3 | |||
| _MAX_RAW_SIZE = 20 * 1024 ** 2 | |||
| _MAX_RAW_SIZE = 20 * 1024**2 | |||
| _is_globalized = False | |||
| _global_queues = None | |||
| _global_workers = [] | |||
| _OpenedURL = collections.namedtuple('_OpenedURL', ['content', 'parser_class']) | |||
| _OpenedURL = collections.namedtuple("_OpenedURL", ["content", "parser_class"]) | |||
| def globalize(num_workers=8): | |||
| """Cause all copyvio checks to be done by one global set of workers. | |||
| @@ -74,11 +72,12 @@ def globalize(num_workers=8): | |||
| _global_queues = _CopyvioQueues() | |||
| for i in range(num_workers): | |||
| worker = _CopyvioWorker("global-{0}".format(i), _global_queues) | |||
| worker = _CopyvioWorker(f"global-{i}", _global_queues) | |||
| worker.start() | |||
| _global_workers.append(worker) | |||
| _is_globalized = True | |||
| def localize(): | |||
| """Return to using page-specific workers for copyvio checks. | |||
| @@ -137,11 +136,12 @@ class _CopyvioWorker: | |||
| if "path" in proxy_info: | |||
| if not parsed.path.startswith(proxy_info["path"]): | |||
| continue | |||
| path = path[len(proxy_info["path"]):] | |||
| path = path[len(proxy_info["path"]) :] | |||
| url = proxy_info["target"] + path | |||
| if "auth" in proxy_info: | |||
| extra_headers["Authorization"] = "Basic %s" % ( | |||
| base64.b64encode(proxy_info["auth"])) | |||
| base64.b64encode(proxy_info["auth"]) | |||
| ) | |||
| return url, True | |||
| return url, False | |||
| @@ -158,8 +158,10 @@ class _CopyvioWorker: | |||
| request = Request(url, headers=extra_headers) | |||
| try: | |||
| response = self._opener.open(request, timeout=timeout) | |||
| except (URLError, HTTPException, socket_error, ValueError): | |||
| url, remapped = self._try_map_proxy_url(url, parsed, extra_headers, is_error=True) | |||
| except (OSError, URLError, HTTPException, ValueError): | |||
| url, remapped = self._try_map_proxy_url( | |||
| url, parsed, extra_headers, is_error=True | |||
| ) | |||
| if not remapped: | |||
| self._logger.exception("Failed to fetch URL: %s", url) | |||
| return None | |||
| @@ -167,7 +169,7 @@ class _CopyvioWorker: | |||
| request = Request(url, headers=extra_headers) | |||
| try: | |||
| response = self._opener.open(request, timeout=timeout) | |||
| except (URLError, HTTPException, socket_error, ValueError): | |||
| except (OSError, URLError, HTTPException, ValueError): | |||
| self._logger.exception("Failed to fetch URL after proxy remap: %s", url) | |||
| return None | |||
| @@ -180,18 +182,19 @@ class _CopyvioWorker: | |||
| content_type = content_type.split(";", 1)[0] | |||
| parser_class = get_parser(content_type) | |||
| if not parser_class and ( | |||
| not allow_content_types or content_type not in allow_content_types): | |||
| not allow_content_types or content_type not in allow_content_types | |||
| ): | |||
| return None | |||
| if not parser_class: | |||
| parser_class = get_parser("text/plain") | |||
| if size > (15 if parser_class.TYPE == "PDF" else 2) * 1024 ** 2: | |||
| if size > (15 if parser_class.TYPE == "PDF" else 2) * 1024**2: | |||
| return None | |||
| try: | |||
| # Additional safety check for pages using Transfer-Encoding: chunked | |||
| # where we can't read the Content-Length | |||
| content = response.read(_MAX_RAW_SIZE + 1) | |||
| except (URLError, socket_error): | |||
| except (OSError, URLError): | |||
| return None | |||
| if len(content) > _MAX_RAW_SIZE: | |||
| return None | |||
| @@ -201,7 +204,7 @@ class _CopyvioWorker: | |||
| gzipper = GzipFile(fileobj=stream) | |||
| try: | |||
| content = gzipper.read() | |||
| except (IOError, struct_error): | |||
| except (OSError, struct_error): | |||
| return None | |||
| if len(content) > _MAX_RAW_SIZE: | |||
| @@ -252,7 +255,7 @@ class _CopyvioWorker: | |||
| site, queue = self._queues.unassigned.get(timeout=timeout) | |||
| if site is StopIteration: | |||
| raise StopIteration | |||
| self._logger.debug("Acquired new site queue: {0}".format(site)) | |||
| self._logger.debug(f"Acquired new site queue: {site}") | |||
| self._site = site | |||
| self._queue = queue | |||
| @@ -274,7 +277,7 @@ class _CopyvioWorker: | |||
| self._queues.lock.release() | |||
| return self._dequeue() | |||
| self._logger.debug("Got source URL: {0}".format(source.url)) | |||
| self._logger.debug(f"Got source URL: {source.url}") | |||
| if source.skipped: | |||
| self._logger.debug("Source has been skipped") | |||
| self._queues.lock.release() | |||
| @@ -335,9 +338,20 @@ class _CopyvioWorker: | |||
| class CopyvioWorkspace: | |||
| """Manages a single copyvio check distributed across threads.""" | |||
| def __init__(self, article, min_confidence, max_time, logger, headers, | |||
| url_timeout=5, num_workers=8, short_circuit=True, | |||
| parser_args=None, exclude_check=None, config=None): | |||
| def __init__( | |||
| self, | |||
| article, | |||
| min_confidence, | |||
| max_time, | |||
| logger, | |||
| headers, | |||
| url_timeout=5, | |||
| num_workers=8, | |||
| short_circuit=True, | |||
| parser_args=None, | |||
| exclude_check=None, | |||
| config=None, | |||
| ): | |||
| self.sources = [] | |||
| self.finished = False | |||
| self.possible_miss = False | |||
| @@ -351,8 +365,12 @@ class CopyvioWorkspace: | |||
| self._finish_lock = Lock() | |||
| self._short_circuit = short_circuit | |||
| self._source_args = { | |||
| "workspace": self, "headers": headers, "timeout": url_timeout, | |||
| "parser_args": parser_args, "search_config": config} | |||
| "workspace": self, | |||
| "headers": headers, | |||
| "timeout": url_timeout, | |||
| "parser_args": parser_args, | |||
| "search_config": config, | |||
| } | |||
| self._exclude_check = exclude_check | |||
| if _is_globalized: | |||
| @@ -361,11 +379,12 @@ class CopyvioWorkspace: | |||
| self._queues = _CopyvioQueues() | |||
| self._num_workers = num_workers | |||
| for i in range(num_workers): | |||
| name = "local-{0:04}.{1}".format(id(self) % 10000, i) | |||
| name = f"local-{id(self) % 10000:04}.{i}" | |||
| _CopyvioWorker(name, self._queues, self._until).start() | |||
| def _calculate_confidence(self, delta): | |||
| """Return the confidence of a violation as a float between 0 and 1.""" | |||
| def conf_with_article_and_delta(article, delta): | |||
| """Calculate confidence using the article and delta chain sizes.""" | |||
| # This piecewise function exhibits exponential growth until it | |||
| @@ -377,7 +396,7 @@ class CopyvioWorkspace: | |||
| if ratio <= 0.52763: | |||
| return -log(1 - ratio) | |||
| else: | |||
| return (-0.8939 * (ratio ** 2)) + (1.8948 * ratio) - 0.0009 | |||
| return (-0.8939 * (ratio**2)) + (1.8948 * ratio) - 0.0009 | |||
| def conf_with_delta(delta): | |||
| """Calculate confidence using just the delta chain size.""" | |||
| @@ -395,8 +414,12 @@ class CopyvioWorkspace: | |||
| return (delta - 50) / delta | |||
| d_size = float(delta.size) | |||
| return abs(max(conf_with_article_and_delta(self._article.size, d_size), | |||
| conf_with_delta(d_size))) | |||
| return abs( | |||
| max( | |||
| conf_with_article_and_delta(self._article.size, d_size), | |||
| conf_with_delta(d_size), | |||
| ) | |||
| ) | |||
| def _finish_early(self): | |||
| """Finish handling links prematurely (if we've hit min_confidence).""" | |||
| @@ -418,12 +441,12 @@ class CopyvioWorkspace: | |||
| self.sources.append(source) | |||
| if self._exclude_check and self._exclude_check(url): | |||
| self._logger.debug("enqueue(): exclude {0}".format(url)) | |||
| self._logger.debug(f"enqueue(): exclude {url}") | |||
| source.excluded = True | |||
| source.skip() | |||
| continue | |||
| if self._short_circuit and self.finished: | |||
| self._logger.debug("enqueue(): auto-skip {0}".format(url)) | |||
| self._logger.debug(f"enqueue(): auto-skip {url}") | |||
| source.skip() | |||
| continue | |||
| @@ -431,6 +454,7 @@ class CopyvioWorkspace: | |||
| key = tldextract.extract(url).registered_domain | |||
| except ImportError: # Fall back on very naive method | |||
| from urllib.parse import urlparse | |||
| key = ".".join(urlparse(url).netloc.split(".")[-2:]) | |||
| logmsg = "enqueue(): {0} {1} -> {2}" | |||
| @@ -450,7 +474,7 @@ class CopyvioWorkspace: | |||
| conf = self._calculate_confidence(delta) | |||
| else: | |||
| conf = 0.0 | |||
| self._logger.debug("compare(): {0} -> {1}".format(source.url, conf)) | |||
| self._logger.debug(f"compare(): {source.url} -> {conf}") | |||
| with self._finish_lock: | |||
| if source_chain: | |||
| source.update(conf, source_chain, delta) | |||
| @@ -463,7 +487,7 @@ class CopyvioWorkspace: | |||
| def wait(self): | |||
| """Wait for the workers to finish handling the sources.""" | |||
| self._logger.debug("Waiting on {0} sources".format(len(self.sources))) | |||
| self._logger.debug(f"Waiting on {len(self.sources)} sources") | |||
| for source in self.sources: | |||
| source.join(self._until) | |||
| with self._finish_lock: | |||
| @@ -474,6 +498,7 @@ class CopyvioWorkspace: | |||
| def get_result(self, num_queries=0): | |||
| """Return a CopyvioCheckResult containing the results of this check.""" | |||
| def cmpfunc(s1, s2): | |||
| if s2.confidence != s1.confidence: | |||
| return 1 if s2.confidence > s1.confidence else -1 | |||
| @@ -482,6 +507,11 @@ class CopyvioWorkspace: | |||
| return int(s1.skipped) - int(s2.skipped) | |||
| self.sources.sort(cmpfunc) | |||
| return CopyvioCheckResult(self.finished, self.sources, num_queries, | |||
| time.time() - self._start_time, self._article, | |||
| self.possible_miss) | |||
| return CopyvioCheckResult( | |||
| self.finished, | |||
| self.sources, | |||
| num_queries, | |||
| time.time() - self._start_time, | |||
| self._article, | |||
| self.possible_miss, | |||
| ) | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2019 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -20,9 +18,9 @@ | |||
| # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |||
| # SOFTWARE. | |||
| from hashlib import md5 | |||
| from logging import getLogger, NullHandler | |||
| import re | |||
| from hashlib import md5 | |||
| from logging import NullHandler, getLogger | |||
| from time import gmtime, strftime | |||
| from urllib.parse import quote | |||
| @@ -33,6 +31,7 @@ from earwigbot.wiki.copyvios import CopyvioMixIn | |||
| __all__ = ["Page"] | |||
| class Page(CopyvioMixIn): | |||
| """ | |||
| **EarwigBot: Wiki Toolset: Page** | |||
| @@ -75,13 +74,13 @@ class Page(CopyvioMixIn): | |||
| - :py:meth:`~earwigbot.wiki.copyvios.CopyrightMixIn.copyvio_compare`: | |||
| checks the page like :py:meth:`copyvio_check`, but against a specific URL | |||
| """ | |||
| PAGE_UNKNOWN = 0 | |||
| PAGE_INVALID = 1 | |||
| PAGE_MISSING = 2 | |||
| PAGE_EXISTS = 3 | |||
| def __init__(self, site, title, follow_redirects=False, pageid=None, | |||
| logger=None): | |||
| def __init__(self, site, title, follow_redirects=False, pageid=None, logger=None): | |||
| """Constructor for new Page instances. | |||
| Takes four arguments: a Site object, the Page's title (or pagename), | |||
| @@ -145,7 +144,7 @@ class Page(CopyvioMixIn): | |||
| def __str__(self): | |||
| """Return a nice string representation of the Page.""" | |||
| return '<Page "{0}" of {1}>'.format(self.title, str(self.site)) | |||
| return f'<Page "{self.title}" of {str(self.site)}>' | |||
| def _assert_validity(self): | |||
| """Used to ensure that our page's title is valid. | |||
| @@ -157,7 +156,7 @@ class Page(CopyvioMixIn): | |||
| contains "[") it will always be invalid, and cannot be edited. | |||
| """ | |||
| if self._exists == self.PAGE_INVALID: | |||
| e = "Page '{0}' is invalid.".format(self._title) | |||
| e = f"Page '{self._title}' is invalid." | |||
| raise exceptions.InvalidPageError(e) | |||
| def _assert_existence(self): | |||
| @@ -169,7 +168,7 @@ class Page(CopyvioMixIn): | |||
| """ | |||
| self._assert_validity() | |||
| if self._exists == self.PAGE_MISSING: | |||
| e = "Page '{0}' does not exist.".format(self._title) | |||
| e = f"Page '{self._title}' does not exist." | |||
| raise exceptions.PageNotFoundError(e) | |||
| def _load(self): | |||
| @@ -208,9 +207,15 @@ class Page(CopyvioMixIn): | |||
| """ | |||
| if not result: | |||
| query = self.site.api_query | |||
| result = query(action="query", prop="info|revisions", | |||
| inprop="protection|url", rvprop="user", rvlimit=1, | |||
| rvdir="newer", titles=self._title) | |||
| result = query( | |||
| action="query", | |||
| prop="info|revisions", | |||
| inprop="protection|url", | |||
| rvprop="user", | |||
| rvlimit=1, | |||
| rvdir="newer", | |||
| titles=self._title, | |||
| ) | |||
| if "interwiki" in result["query"]: | |||
| self._title = result["query"]["interwiki"][0]["title"] | |||
| @@ -247,7 +252,7 @@ class Page(CopyvioMixIn): | |||
| # These last two fields will only be specified if the page exists: | |||
| self._lastrevid = res.get("lastrevid") | |||
| try: | |||
| self._creator = res['revisions'][0]['user'] | |||
| self._creator = res["revisions"][0]["user"] | |||
| except KeyError: | |||
| pass | |||
| @@ -263,9 +268,14 @@ class Page(CopyvioMixIn): | |||
| """ | |||
| if not result: | |||
| query = self.site.api_query | |||
| result = query(action="query", prop="revisions", rvlimit=1, | |||
| rvprop="content|timestamp", rvslots="main", | |||
| titles=self._title) | |||
| result = query( | |||
| action="query", | |||
| prop="revisions", | |||
| rvlimit=1, | |||
| rvprop="content|timestamp", | |||
| rvslots="main", | |||
| titles=self._title, | |||
| ) | |||
| res = list(result["query"]["pages"].values())[0] | |||
| try: | |||
| @@ -279,8 +289,19 @@ class Page(CopyvioMixIn): | |||
| self._load_attributes() | |||
| self._assert_existence() | |||
| def _edit(self, params=None, text=None, summary=None, minor=None, bot=None, | |||
| force=None, section=None, captcha_id=None, captcha_word=None, **kwargs): | |||
| def _edit( | |||
| self, | |||
| params=None, | |||
| text=None, | |||
| summary=None, | |||
| minor=None, | |||
| bot=None, | |||
| force=None, | |||
| section=None, | |||
| captcha_id=None, | |||
| captcha_word=None, | |||
| **kwargs, | |||
| ): | |||
| """Edit the page! | |||
| If *params* is given, we'll use it as our API query parameters. | |||
| @@ -296,9 +317,18 @@ class Page(CopyvioMixIn): | |||
| # Build our API query string: | |||
| if not params: | |||
| params = self._build_edit_params(text, summary, minor, bot, force, | |||
| section, captcha_id, captcha_word, kwargs) | |||
| else: # Make sure we have the right token: | |||
| params = self._build_edit_params( | |||
| text, | |||
| summary, | |||
| minor, | |||
| bot, | |||
| force, | |||
| section, | |||
| captcha_id, | |||
| captcha_word, | |||
| kwargs, | |||
| ) | |||
| else: # Make sure we have the right token: | |||
| params["token"] = self.site.get_token() | |||
| # Try the API query, catching most errors with our handler: | |||
| @@ -319,14 +349,29 @@ class Page(CopyvioMixIn): | |||
| # Otherwise, there was some kind of problem. Throw an exception: | |||
| raise exceptions.EditError(result["edit"]) | |||
| def _build_edit_params(self, text, summary, minor, bot, force, section, | |||
| captcha_id, captcha_word, kwargs): | |||
| def _build_edit_params( | |||
| self, | |||
| text, | |||
| summary, | |||
| minor, | |||
| bot, | |||
| force, | |||
| section, | |||
| captcha_id, | |||
| captcha_word, | |||
| kwargs, | |||
| ): | |||
| """Given some keyword arguments, build an API edit query string.""" | |||
| unitxt = text.encode("utf8") if isinstance(text, str) else text | |||
| hashed = md5(unitxt).hexdigest() # Checksum to ensure text is correct | |||
| params = { | |||
| "action": "edit", "title": self._title, "text": text, | |||
| "token": self.site.get_token(), "summary": summary, "md5": hashed} | |||
| "action": "edit", | |||
| "title": self._title, | |||
| "text": text, | |||
| "token": self.site.get_token(), | |||
| "summary": summary, | |||
| "md5": hashed, | |||
| } | |||
| if section: | |||
| params["section"] = section | |||
| @@ -365,9 +410,16 @@ class Page(CopyvioMixIn): | |||
| is protected), or we'll try to fix it (for example, if the token is | |||
| invalid, we'll try to get a new one). | |||
| """ | |||
| perms = ["noedit", "noedit-anon", "cantcreate", "cantcreate-anon", | |||
| "protectedtitle", "noimageredirect", "noimageredirect-anon", | |||
| "blocked"] | |||
| perms = [ | |||
| "noedit", | |||
| "noedit-anon", | |||
| "cantcreate", | |||
| "cantcreate-anon", | |||
| "protectedtitle", | |||
| "noimageredirect", | |||
| "noimageredirect-anon", | |||
| "blocked", | |||
| ] | |||
| if error.code in perms: | |||
| raise exceptions.PermissionsError(error.info) | |||
| elif error.code in ["editconflict", "pagedeleted", "articleexists"]: | |||
| @@ -551,7 +603,7 @@ class Page(CopyvioMixIn): | |||
| """ | |||
| if self._namespace < 0: | |||
| ns = self.site.namespace_id_to_name(self._namespace) | |||
| e = "Pages in the {0} namespace can't have talk pages.".format(ns) | |||
| e = f"Pages in the {ns} namespace can't have talk pages." | |||
| raise exceptions.InvalidPageError(e) | |||
| if self._is_talkpage: | |||
| @@ -587,9 +639,15 @@ class Page(CopyvioMixIn): | |||
| # Kill two birds with one stone by doing an API query for both our | |||
| # attributes and our page content: | |||
| query = self.site.api_query | |||
| result = query(action="query", rvlimit=1, titles=self._title, | |||
| prop="info|revisions", inprop="protection|url", | |||
| rvprop="content|timestamp", rvslots="main") | |||
| result = query( | |||
| action="query", | |||
| rvlimit=1, | |||
| titles=self._title, | |||
| prop="info|revisions", | |||
| inprop="protection|url", | |||
| rvprop="content|timestamp", | |||
| rvslots="main", | |||
| ) | |||
| self._load_attributes(result=result) | |||
| self._assert_existence() | |||
| self._load_content(result=result) | |||
| @@ -674,8 +732,9 @@ class Page(CopyvioMixIn): | |||
| the page was deleted/recreated between getting our edit token and | |||
| editing our page. Be careful with this! | |||
| """ | |||
| self._edit(text=text, summary=summary, minor=minor, bot=bot, | |||
| force=force, **kwargs) | |||
| self._edit( | |||
| text=text, summary=summary, minor=minor, bot=bot, force=force, **kwargs | |||
| ) | |||
| def add_section(self, text, title, minor=False, bot=True, force=False, **kwargs): | |||
| """Add a new section to the bottom of the page. | |||
| @@ -687,8 +746,15 @@ class Page(CopyvioMixIn): | |||
| This should create the page if it does not already exist, with just the | |||
| new section as content. | |||
| """ | |||
| self._edit(text=text, summary=title, minor=minor, bot=bot, force=force, | |||
| section="new", **kwargs) | |||
| self._edit( | |||
| text=text, | |||
| summary=title, | |||
| minor=minor, | |||
| bot=bot, | |||
| force=force, | |||
| section="new", | |||
| **kwargs, | |||
| ) | |||
| def check_exclusion(self, username=None, optouts=None): | |||
| """Check whether or not we are allowed to edit the page. | |||
| @@ -710,6 +776,7 @@ class Page(CopyvioMixIn): | |||
| ``{{bots|optout=all}}``, but `True` on | |||
| ``{{bots|optout=orfud,norationale,replaceable}}``. | |||
| """ | |||
| def parse_param(template, param): | |||
| value = template.get(param).value | |||
| return [item.strip().lower() for item in value.split(",")] | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2024 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -22,7 +20,7 @@ | |||
| from http.cookiejar import CookieJar | |||
| from json import dumps | |||
| from logging import getLogger, NullHandler | |||
| from logging import NullHandler, getLogger | |||
| from os.path import expanduser | |||
| from threading import RLock | |||
| from time import sleep, time | |||
| @@ -228,11 +226,11 @@ class Site: | |||
| ) | |||
| ) | |||
| name, password = self._login_info | |||
| login = "({0}, {1})".format(repr(name), "hidden" if password else None) | |||
| login = "({}, {})".format(repr(name), "hidden" if password else None) | |||
| oauth = "hidden" if self._oauth else None | |||
| cookies = self._cookiejar.__class__.__name__ | |||
| if hasattr(self._cookiejar, "filename"): | |||
| cookies += "({0!r})".format(getattr(self._cookiejar, "filename")) | |||
| cookies += "({!r})".format(getattr(self._cookiejar, "filename")) | |||
| else: | |||
| cookies += "()" | |||
| agent = self.user_agent | |||
| @@ -267,26 +265,26 @@ class Site: | |||
| since_last_query = time() - self._last_query_time # Throttling support | |||
| if since_last_query < self._wait_between_queries: | |||
| wait_time = self._wait_between_queries - since_last_query | |||
| log = "Throttled: waiting {0} seconds".format(round(wait_time, 2)) | |||
| log = f"Throttled: waiting {round(wait_time, 2)} seconds" | |||
| self._logger.debug(log) | |||
| sleep(wait_time) | |||
| self._last_query_time = time() | |||
| url, params = self._build_api_query(params, ignore_maxlag, no_assert) | |||
| if "lgpassword" in params: | |||
| self._logger.debug("{0} -> <hidden>".format(url)) | |||
| self._logger.debug(f"{url} -> <hidden>") | |||
| else: | |||
| data = dumps(params) | |||
| if len(data) > 1000: | |||
| self._logger.debug("{0} -> {1}...".format(url, data[:997])) | |||
| self._logger.debug(f"{url} -> {data[:997]}...") | |||
| else: | |||
| self._logger.debug("{0} -> {1}".format(url, data)) | |||
| self._logger.debug(f"{url} -> {data}") | |||
| try: | |||
| response = self._session.post(url, data=params) | |||
| response.raise_for_status() | |||
| except requests.RequestException as exc: | |||
| raise exceptions.APIError("API query failed: {0}".format(exc)) | |||
| raise exceptions.APIError(f"API query failed: {exc}") | |||
| return self._handle_api_result(response, params, tries, wait, ae_retry) | |||
| @@ -602,7 +600,7 @@ class Site: | |||
| elif res == "WrongPass" or res == "WrongPluginPass": | |||
| e = "The given password is incorrect." | |||
| else: | |||
| e = "Couldn't login; server says '{0}'.".format(res) | |||
| e = f"Couldn't login; server says '{res}'." | |||
| raise exceptions.LoginError(e) | |||
| def _logout(self): | |||
| @@ -915,7 +913,7 @@ class Site: | |||
| else: | |||
| return self._namespaces[ns_id][0] | |||
| except KeyError: | |||
| e = "There is no namespace with id {0}.".format(ns_id) | |||
| e = f"There is no namespace with id {ns_id}." | |||
| raise exceptions.NamespaceNotFoundError(e) | |||
| def namespace_name_to_id(self, name): | |||
| @@ -933,7 +931,7 @@ class Site: | |||
| if lname in lnames: | |||
| return ns_id | |||
| e = "There is no namespace with name '{0}'.".format(name) | |||
| e = f"There is no namespace with name '{name}'." | |||
| raise exceptions.NamespaceNotFoundError(e) | |||
| def get_page(self, title, follow_redirects=False, pageid=None): | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2024 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -20,13 +18,13 @@ | |||
| # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |||
| # SOFTWARE. | |||
| from collections import OrderedDict | |||
| import errno | |||
| from http.cookiejar import LWPCookieJar, LoadError | |||
| import sqlite3 as sqlite | |||
| import stat | |||
| from collections import OrderedDict | |||
| from http.cookiejar import LoadError, LWPCookieJar | |||
| from os import chmod, path | |||
| from platform import python_version | |||
| import stat | |||
| import sqlite3 as sqlite | |||
| from earwigbot import __version__ | |||
| from earwigbot.exceptions import SiteNotFoundError | |||
| @@ -78,7 +76,7 @@ class SitesDB: | |||
| def __str__(self): | |||
| """Return a nice string representation of the SitesDB.""" | |||
| return "<SitesDB at {0}>".format(self._sitesdb) | |||
| return f"<SitesDB at {self._sitesdb}>" | |||
| def _get_cookiejar(self): | |||
| """Return a LWPCookieJar object loaded from our .cookies file. | |||
| @@ -102,7 +100,7 @@ class SitesDB: | |||
| self._cookiejar.load() | |||
| except LoadError: | |||
| pass # File contains bad data, so ignore it completely | |||
| except IOError as e: | |||
| except OSError as e: | |||
| if e.errno == errno.ENOENT: # "No such file or directory" | |||
| # Create the file and restrict reading/writing only to the | |||
| # owner, so others can't peak at our cookies: | |||
| @@ -149,7 +147,7 @@ class SitesDB: | |||
| query1 = "SELECT * FROM sites WHERE site_name = ?" | |||
| query2 = "SELECT sql_data_key, sql_data_value FROM sql_data WHERE sql_site = ?" | |||
| query3 = "SELECT ns_id, ns_name, ns_is_primary_name FROM namespaces WHERE ns_site = ?" | |||
| error = "Site '{0}' not found in the sitesdb.".format(name) | |||
| error = f"Site '{name}' not found in the sitesdb." | |||
| with sqlite.connect(self._sitesdb) as conn: | |||
| try: | |||
| site_data = conn.execute(query1, (name,)).fetchone() | |||
| @@ -263,7 +261,7 @@ class SitesDB: | |||
| if site: | |||
| return site[0] | |||
| else: | |||
| url = "//{0}.{1}.%".format(lang, project) | |||
| url = f"//{lang}.{project}.%" | |||
| site = conn.execute(query2, (url,)).fetchone() | |||
| return site[0] if site else None | |||
| except sqlite.OperationalError: | |||
| @@ -322,7 +320,7 @@ class SitesDB: | |||
| else: | |||
| conn.execute("DELETE FROM sql_data WHERE sql_site = ?", (name,)) | |||
| conn.execute("DELETE FROM namespaces WHERE ns_site = ?", (name,)) | |||
| self._logger.info("Removed site '{0}'".format(name)) | |||
| self._logger.info(f"Removed site '{name}'") | |||
| return True | |||
| def get_site(self, name=None, project=None, lang=None): | |||
| @@ -379,7 +377,7 @@ class SitesDB: | |||
| name = self._get_site_name_from_sitesdb(project, lang) | |||
| if name: | |||
| return self._get_site_object(name) | |||
| e = "Site '{0}:{1}' not found in the sitesdb.".format(project, lang) | |||
| e = f"Site '{project}:{lang}' not found in the sitesdb." | |||
| raise SiteNotFoundError(e) | |||
| def add_site( | |||
| @@ -412,7 +410,7 @@ class SitesDB: | |||
| if not project or not lang: | |||
| e = "Without a base_url, both a project and a lang must be given." | |||
| raise SiteNotFoundError(e) | |||
| base_url = "//{0}.{1}.org".format(lang, project) | |||
| base_url = f"//{lang}.{project}.org" | |||
| cookiejar = self._get_cookiejar() | |||
| config = self.config | |||
| @@ -443,7 +441,7 @@ class SitesDB: | |||
| wait_between_queries=wait_between_queries, | |||
| ) | |||
| self._logger.info("Added site '{0}'".format(site.name)) | |||
| self._logger.info(f"Added site '{site.name}'") | |||
| self._add_site_to_sitesdb(site) | |||
| return self._get_site_object(site.name) | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -20,9 +18,9 @@ | |||
| # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |||
| # SOFTWARE. | |||
| from logging import getLogger, NullHandler | |||
| from logging import NullHandler, getLogger | |||
| from socket import AF_INET, AF_INET6, inet_pton | |||
| from time import gmtime, strptime | |||
| from socket import AF_INET, AF_INET6, error as socket_error, inet_pton | |||
| from earwigbot.exceptions import UserNotFoundError | |||
| from earwigbot.wiki import constants | |||
| @@ -30,6 +28,7 @@ from earwigbot.wiki.page import Page | |||
| __all__ = ["User"] | |||
| class User: | |||
| """ | |||
| **EarwigBot: Wiki Toolset: User** | |||
| @@ -88,11 +87,11 @@ class User: | |||
| def __repr__(self): | |||
| """Return the canonical string representation of the User.""" | |||
| return "User(name={0!r}, site={1!r})".format(self._name, self._site) | |||
| return f"User(name={self._name!r}, site={self._site!r})" | |||
| def __str__(self): | |||
| """Return a nice string representation of the User.""" | |||
| return '<User "{0}" of {1}>'.format(self.name, str(self.site)) | |||
| return f'<User "{self.name}" of {str(self.site)}>' | |||
| def _get_attribute(self, attr): | |||
| """Internally used to get an attribute by name. | |||
| @@ -106,7 +105,7 @@ class User: | |||
| if not hasattr(self, attr): | |||
| self._load_attributes() | |||
| if not self._exists: | |||
| e = "User '{0}' does not exist.".format(self._name) | |||
| e = f"User '{self._name}' does not exist." | |||
| raise UserNotFoundError(e) | |||
| return getattr(self, attr) | |||
| @@ -117,8 +116,9 @@ class User: | |||
| is not defined. This defines it. | |||
| """ | |||
| props = "blockinfo|groups|rights|editcount|registration|emailable|gender" | |||
| result = self.site.api_query(action="query", list="users", | |||
| ususers=self._name, usprop=props) | |||
| result = self.site.api_query( | |||
| action="query", list="users", ususers=self._name, usprop=props | |||
| ) | |||
| res = result["query"]["users"][0] | |||
| # normalize our username in case it was entered oddly | |||
| @@ -136,7 +136,7 @@ class User: | |||
| self._blockinfo = { | |||
| "by": res["blockedby"], | |||
| "reason": res["blockreason"], | |||
| "expiry": res["blockexpiry"] | |||
| "expiry": res["blockexpiry"], | |||
| } | |||
| except KeyError: | |||
| self._blockinfo = False | |||
| @@ -280,10 +280,10 @@ class User: | |||
| """ | |||
| try: | |||
| inet_pton(AF_INET, self.name) | |||
| except socket_error: | |||
| except OSError: | |||
| try: | |||
| inet_pton(AF_INET6, self.name) | |||
| except socket_error: | |||
| except OSError: | |||
| return False | |||
| return True | |||
| @@ -302,7 +302,7 @@ class User: | |||
| conventions are followed. | |||
| """ | |||
| prefix = self.site.namespace_id_to_name(constants.NS_USER) | |||
| pagename = ':'.join((prefix, self._name)) | |||
| pagename = ":".join((prefix, self._name)) | |||
| return Page(self.site, pagename) | |||
| def get_talkpage(self): | |||
| @@ -312,5 +312,5 @@ class User: | |||
| conventions are followed. | |||
| """ | |||
| prefix = self.site.namespace_id_to_name(constants.NS_USER_TALK) | |||
| pagename = ':'.join((prefix, self._name)) | |||
| pagename = ":".join((prefix, self._name)) | |||
| return Page(self.site, pagename) | |||
| @@ -0,0 +1,6 @@ | |||
| [tool.ruff] | |||
| target-version = "py311" | |||
| [tool.ruff.lint] | |||
| select = ["E4", "E7", "E9", "F", "I", "UP"] | |||
| ignore = ["F403"] | |||
| @@ -1,6 +1,4 @@ | |||
| #! /usr/bin/env python | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2024 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -21,7 +19,7 @@ | |||
| # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |||
| # SOFTWARE. | |||
| from setuptools import setup, find_packages | |||
| from setuptools import find_packages, setup | |||
| from earwigbot import __version__ | |||
| @@ -69,7 +67,7 @@ setup( | |||
| url="https://github.com/earwig/earwigbot", | |||
| description="EarwigBot is a Python robot that edits Wikipedia and interacts with people over IRC.", | |||
| long_description=long_docs, | |||
| download_url="https://github.com/earwig/earwigbot/tarball/v{0}".format(__version__), | |||
| download_url=f"https://github.com/earwig/earwigbot/tarball/v{__version__}", | |||
| keywords="earwig earwigbot irc wikipedia wiki mediawiki", | |||
| license="MIT License", | |||
| classifiers=[ | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -39,18 +37,19 @@ Fake objects: | |||
| """ | |||
| import logging | |||
| from os import path | |||
| import re | |||
| from os import path | |||
| from threading import Lock | |||
| from unittest import TestCase | |||
| from earwigbot.bot import Bot | |||
| from earwigbot.commands import CommandManager | |||
| from earwigbot.config import BotConfig | |||
| from earwigbot.irc import IRCConnection, Data | |||
| from earwigbot.irc import Data, IRCConnection | |||
| from earwigbot.tasks import TaskManager | |||
| from earwigbot.wiki import SitesDB | |||
| class CommandTestCase(TestCase): | |||
| re_sender = re.compile(":(.*?)!(.*?)@(.*?)\Z") | |||
| @@ -75,17 +74,17 @@ class CommandTestCase(TestCase): | |||
| self.assertIn(line, msgs) | |||
| def assertSaid(self, msg): | |||
| self.assertSent("PRIVMSG #channel :{0}".format(msg)) | |||
| self.assertSent(f"PRIVMSG #channel :{msg}") | |||
| def assertSaidIn(self, msgs): | |||
| msgs = ["PRIVMSG #channel :{0}".format(msg) for msg in msgs] | |||
| msgs = [f"PRIVMSG #channel :{msg}" for msg in msgs] | |||
| self.assertSentIn(msgs) | |||
| def assertReply(self, msg): | |||
| self.assertSaid("\x02Foo\x0F: {0}".format(msg)) | |||
| self.assertSaid(f"\x02Foo\x0f: {msg}") | |||
| def assertReplyIn(self, msgs): | |||
| msgs = ["\x02Foo\x0F: {0}".format(msg) for msg in msgs] | |||
| msgs = [f"\x02Foo\x0f: {msg}" for msg in msgs] | |||
| self.assertSaidIn(msgs) | |||
| def maker(self, line, chan, msg=None): | |||
| @@ -98,7 +97,7 @@ class CommandTestCase(TestCase): | |||
| return data | |||
| def make_msg(self, command, *args): | |||
| line = ":Foo!bar@example.com PRIVMSG #channel :!{0}".format(command) | |||
| line = f":Foo!bar@example.com PRIVMSG #channel :!{command}" | |||
| line = line.strip().split() | |||
| line.extend(args) | |||
| return self.maker(line, line[2], " ".join(line[3:])[1:]) | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -25,8 +23,8 @@ import unittest | |||
| from earwigbot.commands.calc import Command | |||
| from tests import CommandTestCase | |||
| class TestCalc(CommandTestCase): | |||
| class TestCalc(CommandTestCase): | |||
| def setUp(self): | |||
| super().setUp(Command) | |||
| @@ -55,5 +53,6 @@ class TestCalc(CommandTestCase): | |||
| self.command.process(self.make_msg("calc", *q)) | |||
| self.assertReply(test[1]) | |||
| if __name__ == "__main__": | |||
| unittest.main(verbosity=2) | |||
| @@ -1,5 +1,3 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # | |||
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| @@ -25,8 +23,8 @@ import unittest | |||
| from earwigbot.commands.test import Command | |||
| from tests import CommandTestCase | |||
| class TestTest(CommandTestCase): | |||
| class TestTest(CommandTestCase): | |||
| def setUp(self): | |||
| super().setUp(Command) | |||
| @@ -40,10 +38,11 @@ class TestTest(CommandTestCase): | |||
| def test_process(self): | |||
| def test(): | |||
| self.command.process(self.make_msg("test")) | |||
| self.assertSaidIn(["Hey \x02Foo\x0F!", "'sup \x02Foo\x0F?"]) | |||
| self.assertSaidIn(["Hey \x02Foo\x0f!", "'sup \x02Foo\x0f?"]) | |||
| for i in range(64): | |||
| test() | |||
| if __name__ == "__main__": | |||
| unittest.main(verbosity=2) | |||