From 552277420de1955cd062be412d4c6c814658c1ae Mon Sep 17 00:00:00 2001 From: Ben Kurtovic Date: Mon, 8 Apr 2024 17:24:15 -0400 Subject: [PATCH] Code cleanup wtih ruff + add pre-commit hook --- .pre-commit-config.yaml | 7 ++ docs/conf.py | 143 +++++++++++++++++----------------- earwigbot/__init__.py | 8 +- earwigbot/bot.py | 22 +++--- earwigbot/commands/__init__.py | 38 ++++++--- earwigbot/commands/access.py | 34 ++++---- earwigbot/commands/calc.py | 38 ++++----- earwigbot/commands/chanops.py | 41 +++++++--- earwigbot/commands/cidr.py | 104 ++++++++++++++++--------- earwigbot/commands/crypt.py | 27 ++++--- earwigbot/commands/ctcp.py | 10 +-- earwigbot/commands/dictionary.py | 23 +++--- earwigbot/commands/editcount.py | 12 +-- earwigbot/commands/help.py | 18 +++-- earwigbot/commands/lag.py | 34 ++++---- earwigbot/commands/langcode.py | 14 ++-- earwigbot/commands/link.py | 9 ++- earwigbot/commands/notes.py | 46 +++++------ earwigbot/commands/quit.py | 18 +++-- earwigbot/commands/registration.py | 13 ++-- earwigbot/commands/remind.py | 132 +++++++++++++++++++------------ earwigbot/commands/rights.py | 12 +-- earwigbot/commands/stalk.py | 137 +++++++++++++++++++++----------- earwigbot/commands/test.py | 10 +-- earwigbot/commands/threads.py | 51 ++++++------ earwigbot/commands/time_command.py | 8 +- earwigbot/commands/trout.py | 6 +- earwigbot/commands/watchers.py | 13 ++-- earwigbot/config/__init__.py | 43 ++++++---- earwigbot/config/formatter.py | 17 ++-- earwigbot/config/node.py | 6 +- earwigbot/config/ordered_yaml.py | 26 ++++--- earwigbot/config/permissions.py | 12 +-- earwigbot/config/script.py | 83 ++++++++++++-------- earwigbot/exceptions.py | 32 +++++++- earwigbot/irc/__init__.py | 2 - earwigbot/irc/connection.py | 43 +++++----- earwigbot/irc/data.py | 8 +- earwigbot/irc/frontend.py | 23 ++++-- earwigbot/irc/rc.py | 18 +++-- earwigbot/irc/watcher.py | 4 +- earwigbot/lazy.py | 2 - earwigbot/managers.py | 21 +++-- earwigbot/tasks/__init__.py | 5 +- earwigbot/tasks/wikiproject_tagger.py | 10 +-- earwigbot/util.py | 60 +++++++++----- earwigbot/wiki/__init__.py | 2 - earwigbot/wiki/category.py | 25 +++--- earwigbot/wiki/constants.py | 6 +- earwigbot/wiki/copyvios/__init__.py | 65 +++++++++++----- earwigbot/wiki/copyvios/exclusions.py | 41 ++++++---- earwigbot/wiki/copyvios/markov.py | 15 ++-- earwigbot/wiki/copyvios/parsers.py | 57 ++++++++------ earwigbot/wiki/copyvios/result.py | 46 +++++++---- earwigbot/wiki/copyvios/search.py | 41 ++++++---- earwigbot/wiki/copyvios/workers.py | 106 ++++++++++++++++--------- earwigbot/wiki/page.py | 139 ++++++++++++++++++++++++--------- earwigbot/wiki/site.py | 24 +++--- earwigbot/wiki/sitesdb.py | 26 +++---- earwigbot/wiki/user.py | 28 +++---- pyproject.toml | 6 ++ setup.py | 6 +- tests/__init__.py | 17 ++-- tests/test_calc.py | 5 +- tests/test_test.py | 7 +- 65 files changed, 1260 insertions(+), 845 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100644 pyproject.toml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..a9e08a0 --- /dev/null +++ b/.pre-commit-config.yaml @@ -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 diff --git a/docs/conf.py b/docs/conf.py index af1e53b..bbb5dd3 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -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 # " v 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 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' diff --git a/earwigbot/__init__.py b/earwigbot/__init__.py index 4b2c13d..092b202 100644 --- a/earwigbot/__init__.py +++ b/earwigbot/__init__.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2019 Ben Kurtovic # # 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: diff --git a/earwigbot/bot.py b/earwigbot/bot.py index 4b1e325..d103e74 100644 --- a/earwigbot/bot.py +++ b/earwigbot/bot.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2015 Ben Kurtovic # # 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 "".format(self.config.root_dir) + return f"" 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: diff --git a/earwigbot/commands/__init__.py b/earwigbot/commands/__init__.py index f41babe..24ba782 100644 --- a/earwigbot/commands/__init__.py +++ b/earwigbot/commands/__init__.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2015 Ben Kurtovic # # 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 "``. """ + # 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 "".format(self.name, self.bot) + return f"" def setup(self): """Hook called immediately after the command is loaded. diff --git a/earwigbot/commands/access.py b/earwigbot/commands/access.py index 0b5bc43..fbe762a 100644 --- a/earwigbot/commands/access.py +++ b/earwigbot/commands/access.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2015 Ben Kurtovic # # 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) diff --git a/earwigbot/commands/calc.py b/earwigbot/commands/calc.py index f030123..5cbd27a 100644 --- a/earwigbot/commands/calc.py +++ b/earwigbot/commands/calc.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2015 Ben Kurtovic # # 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)(.*?)') - r_tag = re.compile(r'<\S+.*?>') + r_result = re.compile(r"(?i)(.*?)") + 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: diff --git a/earwigbot/commands/chanops.py b/earwigbot/commands/chanops.py index 31414a7..de80ba6 100644 --- a/earwigbot/commands/chanops.py +++ b/earwigbot/commands/chanops.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2021 Ben Kurtovic # # 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)) + ), + ) diff --git a/earwigbot/commands/cidr.py b/earwigbot/commands/cidr.py index 74a96b8..a6dfbee 100644 --- a/earwigbot/commands/cidr.py +++ b/earwigbot/commands/cidr.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2016 Ben Kurtovic # # 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" diff --git a/earwigbot/commands/crypt.py b/earwigbot/commands/crypt.py index 0b8374e..6f3a497 100644 --- a/earwigbot/commands/crypt.py +++ b/earwigbot/commands/crypt.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2015 Ben Kurtovic # # 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)}") diff --git a/earwigbot/commands/ctcp.py b/earwigbot/commands/ctcp.py index bc1130c..78d13ec 100644 --- a/earwigbot/commands/ctcp.py +++ b/earwigbot/commands/ctcp.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2015 Ben Kurtovic # # 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") diff --git a/earwigbot/commands/dictionary.py b/earwigbot/commands/dictionary.py index 9d3448b..49462cb 100644 --- a/earwigbot/commands/dictionary.py +++ b/earwigbot/commands/dictionary.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2015 Ben Kurtovic # # 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): diff --git a/earwigbot/commands/editcount.py b/earwigbot/commands/editcount.py index 2bc26c0..2684c89 100644 --- a/earwigbot/commands/editcount.py +++ b/earwigbot/commands/editcount.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2015 Ben Kurtovic # # 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)) diff --git a/earwigbot/commands/help.py b/earwigbot/commands/help.py index 1b3253a..9dc66b5 100644 --- a/earwigbot/commands/help.py +++ b/earwigbot/commands/help.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2015 Ben Kurtovic # # 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 '." 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())) diff --git a/earwigbot/commands/lag.py b/earwigbot/commands/lag.py index d363b31..3ae501c 100644 --- a/earwigbot/commands/lag.py +++ b/earwigbot/commands/lag.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2015 Ben Kurtovic # # 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" diff --git a/earwigbot/commands/langcode.py b/earwigbot/commands/langcode.py index 06a7d47..9ca5d9b 100644 --- a/earwigbot/commands/langcode.py +++ b/earwigbot/commands/langcode.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2015 Ben Kurtovic # # 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.") diff --git a/earwigbot/commands/link.py b/earwigbot/commands/link.py index e0cafc0..3a63842 100644 --- a/earwigbot/commands/link.py +++ b/earwigbot/commands/link.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2015 Ben Kurtovic # # 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 diff --git a/earwigbot/commands/notes.py b/earwigbot/commands/notes.py index a79923d..cd853df 100644 --- a/earwigbot/commands/notes.py +++ b/earwigbot/commands/notes.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2015 Ben Kurtovic # # 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.""" diff --git a/earwigbot/commands/quit.py b/earwigbot/commands/quit.py index 2c5d47e..36db8ce 100644 --- a/earwigbot/commands/quit.py +++ b/earwigbot/commands/quit.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2015 Ben Kurtovic # # 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.") diff --git a/earwigbot/commands/registration.py b/earwigbot/commands/registration.py index 6ba8fc0..da9e139 100644 --- a/earwigbot/commands/registration.py +++ b/earwigbot/commands/registration.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2015 Ben Kurtovic # # 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] diff --git a/earwigbot/commands/remind.py b/earwigbot/commands/remind.py index 631b2a3..d94cecf 100644 --- a/earwigbot/commands/remind.py +++ b/earwigbot/commands/remind.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2016 Ben Kurtovic # # 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): diff --git a/earwigbot/commands/rights.py b/earwigbot/commands/rights.py index d1f80ca..88e9fbd 100644 --- a/earwigbot/commands/rights.py +++ b/earwigbot/commands/rights.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2015 Ben Kurtovic # # 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))) diff --git a/earwigbot/commands/stalk.py b/earwigbot/commands/stalk.py index 25bdef1..1bec40c 100644 --- a/earwigbot/commands/stalk.py +++ b/earwigbot/commands/stalk.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2021 Ben Kurtovic # # 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.""" diff --git a/earwigbot/commands/test.py b/earwigbot/commands/test.py index d2e9be0..f1a7e4c 100644 --- a/earwigbot/commands/test.py +++ b/earwigbot/commands/test.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2015 Ben Kurtovic # # 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}?") diff --git a/earwigbot/commands/threads.py b/earwigbot/commands/threads.py index 52edc53..e15d1ea 100644 --- a/earwigbot/commands/threads.py +++ b/earwigbot/commands/threads.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2015 Ben Kurtovic # # 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) diff --git a/earwigbot/commands/time_command.py b/earwigbot/commands/time_command.py index 5a2bc78..65b45cd 100644 --- a/earwigbot/commands/time_command.py +++ b/earwigbot/commands/time_command.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2015 Ben Kurtovic # # 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")) diff --git a/earwigbot/commands/trout.py b/earwigbot/commands/trout.py index 7aee359..216adb9 100644 --- a/earwigbot/commands/trout.py +++ b/earwigbot/commands/trout.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2015 Ben Kurtovic # # 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)) diff --git a/earwigbot/commands/watchers.py b/earwigbot/commands/watchers.py index a3ef6bd..afd1ac9 100644 --- a/earwigbot/commands/watchers.py +++ b/earwigbot/commands/watchers.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2015 Ben Kurtovic # # 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)) diff --git a/earwigbot/config/__init__.py b/earwigbot/config/__init__.py index a1abc25..da42559 100644 --- a/earwigbot/config/__init__.py +++ b/earwigbot/config/__init__.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2015 Ben Kurtovic # # 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 "".format(self.root_dir) + return f"" 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: diff --git a/earwigbot/config/formatter.py b/earwigbot/config/formatter.py index 1219d93..79b2522 100644 --- a/earwigbot/config/formatter.py +++ b/earwigbot/config/formatter.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2015 Ben Kurtovic # # 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 diff --git a/earwigbot/config/node.py b/earwigbot/config/node.py index b451b30..86dd4dd 100644 --- a/earwigbot/config/node.py +++ b/earwigbot/config/node.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2015 Ben Kurtovic # # 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 diff --git a/earwigbot/config/ordered_yaml.py b/earwigbot/config/ordered_yaml.py index 60fdcf9..e8ecfae 100644 --- a/earwigbot/config/ordered_yaml.py +++ b/earwigbot/config/ordered_yaml.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2015 Ben Kurtovic # # 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: diff --git a/earwigbot/config/permissions.py b/earwigbot/config/permissions.py index 17ed862..188d5f0 100644 --- a/earwigbot/config/permissions.py +++ b/earwigbot/config/permissions.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2015 Ben Kurtovic # # 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 "".format(self._dbfile) + return f"" 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): diff --git a/earwigbot/config/script.py b/earwigbot/config/script.py index 5533303..cfb22ea 100644 --- a/earwigbot/config/script.py +++ b/earwigbot/config/script.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2016 Ben Kurtovic # # 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() diff --git a/earwigbot/exceptions.py b/earwigbot/exceptions.py index 61f1572..31032c8 100644 --- a/earwigbot/exceptions.py +++ b/earwigbot/exceptions.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2015 Ben Kurtovic # # 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): `. """ + 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 `. """ + class SQLError(ServiceError): """Some error involving SQL querying occurred. Raised by :py:meth:`Site.sql_query `. """ + class NoServiceError(WikiToolsetError): """No service is functioning to handle a specific task. Raised by :py:meth:`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 `. """ + 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): `. """ + 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): `. """ + 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 `. """ + 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 `. """ + 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 `. """ + 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 `. """ + class SpamDetectedError(EditError): """The spam filter refused our edit. @@ -206,6 +226,7 @@ class SpamDetectedError(EditError): :py:meth:`Page.add_section `. """ + class FilteredError(EditError): """The edit filter refused our edit. @@ -213,6 +234,7 @@ class FilteredError(EditError): :py:meth:`Page.add_section `. """ + class CopyvioCheckError(WikiToolsetError): """An error occured when checking a page for copyright violations. @@ -225,6 +247,7 @@ class CopyvioCheckError(WikiToolsetError): `. """ + class UnknownSearchEngineError(CopyvioCheckError): """Attempted to do a copyvio check with an unknown search engine. @@ -235,6 +258,7 @@ class UnknownSearchEngineError(CopyvioCheckError): `. """ + class UnsupportedSearchEngineError(CopyvioCheckError): """Attmpted to do a copyvio check using an unavailable engine. @@ -245,6 +269,7 @@ class UnsupportedSearchEngineError(CopyvioCheckError): `. """ + class SearchQueryError(CopyvioCheckError): """Some error ocurred while doing a search query. @@ -252,6 +277,7 @@ class SearchQueryError(CopyvioCheckError): `. """ + 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): `; should not be exposed in client code. """ + def __init__(self, url): super().__init__() self.url = url diff --git a/earwigbot/irc/__init__.py b/earwigbot/irc/__init__.py index f9d200c..f4a2be5 100644 --- a/earwigbot/irc/__init__.py +++ b/earwigbot/irc/__init__.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2015 Ben Kurtovic # # Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/earwigbot/irc/connection.py b/earwigbot/irc/connection.py index 41b9d69..823becd 100644 --- a/earwigbot/irc/connection.py +++ b/earwigbot/irc/connection.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2015 Ben Kurtovic # # 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): diff --git a/earwigbot/irc/data.py b/earwigbot/irc/data.py index a89d9b9..b150438 100644 --- a/earwigbot/irc/data.py +++ b/earwigbot/irc/data.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2016 Ben Kurtovic # # 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 "".format(" ".join(self.line)) + return "".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 diff --git a/earwigbot/irc/frontend.py b/earwigbot/irc/frontend.py index a2a38fe..4bbe5c7 100644 --- a/earwigbot/irc/frontend.py +++ b/earwigbot/irc/frontend.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2021 Ben Kurtovic # # 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 `). """ + 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 diff --git a/earwigbot/irc/rc.py b/earwigbot/irc/rc.py index 318a103..be29441 100644 --- a/earwigbot/irc/rc.py +++ b/earwigbot/irc/rc.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2021 Ben Kurtovic # # 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 "".format(self.msg, self.chan) + return f"" 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 diff --git a/earwigbot/irc/watcher.py b/earwigbot/irc/watcher.py index db85ae4..013a49b 100644 --- a/earwigbot/irc/watcher.py +++ b/earwigbot/irc/watcher.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2024 Ben Kurtovic # # 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"] diff --git a/earwigbot/lazy.py b/earwigbot/lazy.py index e0795bd..21c0351 100644 --- a/earwigbot/lazy.py +++ b/earwigbot/lazy.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2024 Ben Kurtovic # # Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/earwigbot/managers.py b/earwigbot/managers.py index e834ae1..f09f5fa 100644 --- a/earwigbot/managers.py +++ b/earwigbot/managers.py @@ -1,6 +1,4 @@ #! /usr/bin/env python -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2024 Ben Kurtovic # # 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 diff --git a/earwigbot/tasks/__init__.py b/earwigbot/tasks/__init__.py index 3b6c37e..a811ec8 100644 --- a/earwigbot/tasks/__init__.py +++ b/earwigbot/tasks/__init__.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2015 Ben Kurtovic # # 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: `. ``**kwargs`` get passed to the Task's :meth:`run` method. """ + name = None number = 0 diff --git a/earwigbot/tasks/wikiproject_tagger.py b/earwigbot/tasks/wikiproject_tagger.py index 4c0649e..c0e80de 100644 --- a/earwigbot/tasks/wikiproject_tagger.py +++ b/earwigbot/tasks/wikiproject_tagger.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2024 Ben Kurtovic # # 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: diff --git a/earwigbot/util.py b/earwigbot/util.py index be31dab..a06269e 100755 --- a/earwigbot/util.py +++ b/earwigbot/util.py @@ -1,6 +1,4 @@ #! /usr/bin/env python -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2015 Ben Kurtovic # # 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() diff --git a/earwigbot/wiki/__init__.py b/earwigbot/wiki/__init__.py index 57473ad..4c86bf5 100644 --- a/earwigbot/wiki/__init__.py +++ b/earwigbot/wiki/__init__.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2015 Ben Kurtovic # # Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/earwigbot/wiki/category.py b/earwigbot/wiki/category.py index b9f154f..e9e04a2 100644 --- a/earwigbot/wiki/category.py +++ b/earwigbot/wiki/category.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2015 Ben Kurtovic # # 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 ''.format(self.title, str(self.site)) + return f'' 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 diff --git a/earwigbot/wiki/constants.py b/earwigbot/wiki/constants.py index e96f294..9e18a4b 100644 --- a/earwigbot/wiki/constants.py +++ b/earwigbot/wiki/constants.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2015 Ben Kurtovic # # 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 diff --git a/earwigbot/wiki/copyvios/__init__.py b/earwigbot/wiki/copyvios/__init__.py index b511b47..08ba328 100644 --- a/earwigbot/wiki/copyvios/__init__.py +++ b/earwigbot/wiki/copyvios/__init__.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2016 Ben Kurtovic # # 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() diff --git a/earwigbot/wiki/copyvios/exclusions.py b/earwigbot/wiki/copyvios/exclusions.py index 62a6daa..47d8dff 100644 --- a/earwigbot/wiki/copyvios/exclusions.py +++ b/earwigbot/wiki/copyvios/exclusions.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2016 Ben Kurtovic # # 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 "".format(self._dbfile) + return f"" 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*(?:\\s*)?(.+?)\s*(?:\\s*)?(?:#.*?)?$", line) + match = re.match( + r"^\s*url\s*=\s*(?:\\s*)?(.+?)\s*(?:\\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 diff --git a/earwigbot/wiki/copyvios/markov.py b/earwigbot/wiki/copyvios/markov.py index b5e6606..d994045 100644 --- a/earwigbot/wiki/copyvios/markov.py +++ b/earwigbot/wiki/copyvios/markov.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2015 Ben Kurtovic # # 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 "".format(self.size) + return f"" class MarkovChainIntersection(MarkovChain): diff --git a/earwigbot/wiki/copyvios/parsers.py b/earwigbot/wiki/copyvios/parsers.py index 9dea64a..9d0351a 100644 --- a/earwigbot/wiki/copyvios/parsers.py +++ b/earwigbot/wiki/copyvios/parsers.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2019 Ben Kurtovic # # 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) diff --git a/earwigbot/wiki/copyvios/result.py b/earwigbot/wiki/copyvios/result.py index 9997be6..a2c668d 100644 --- a/earwigbot/wiki/copyvios/result.py +++ b/earwigbot/wiki/copyvios/result.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2015 Ben Kurtovic # # 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 "".format(self.url) + return f"" if self.skipped: - return "".format(self.url) + return f"" res = "" 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, + ) diff --git a/earwigbot/wiki/copyvios/search.py b/earwigbot/wiki/copyvios/search.py index dedffd8..1a7d47c 100644 --- a/earwigbot/wiki/copyvios/search.py +++ b/earwigbot/wiki/copyvios/search.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2016 Ben Kurtovic # # 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, } diff --git a/earwigbot/wiki/copyvios/workers.py b/earwigbot/wiki/copyvios/workers.py index a3d70cd..bc700e6 100644 --- a/earwigbot/wiki/copyvios/workers.py +++ b/earwigbot/wiki/copyvios/workers.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2019 Ben Kurtovic # # 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, + ) diff --git a/earwigbot/wiki/page.py b/earwigbot/wiki/page.py index aa61e44..217d356 100644 --- a/earwigbot/wiki/page.py +++ b/earwigbot/wiki/page.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2019 Ben Kurtovic # # 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 ''.format(self.title, str(self.site)) + return f'' 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(",")] diff --git a/earwigbot/wiki/site.py b/earwigbot/wiki/site.py index 274be89..fa9a06a 100644 --- a/earwigbot/wiki/site.py +++ b/earwigbot/wiki/site.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2024 Ben Kurtovic # # 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} -> ".format(url)) + self._logger.debug(f"{url} -> ") 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): diff --git a/earwigbot/wiki/sitesdb.py b/earwigbot/wiki/sitesdb.py index aff98b5..5e66df0 100644 --- a/earwigbot/wiki/sitesdb.py +++ b/earwigbot/wiki/sitesdb.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2024 Ben Kurtovic # # 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 "".format(self._sitesdb) + return f"" 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) diff --git a/earwigbot/wiki/user.py b/earwigbot/wiki/user.py index 258158a..9aab1a9 100644 --- a/earwigbot/wiki/user.py +++ b/earwigbot/wiki/user.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2015 Ben Kurtovic # # 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 ''.format(self.name, str(self.site)) + return f'' 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) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a088abd --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,6 @@ +[tool.ruff] +target-version = "py311" + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F", "I", "UP"] +ignore = ["F403"] diff --git a/setup.py b/setup.py index 9feb5c6..8d85372 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,4 @@ #! /usr/bin/env python -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2024 Ben Kurtovic # # 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=[ diff --git a/tests/__init__.py b/tests/__init__.py index 6af3a44..510d48d 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2015 Ben Kurtovic # # 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:]) diff --git a/tests/test_calc.py b/tests/test_calc.py index 55c5e3c..1a42025 100644 --- a/tests/test_calc.py +++ b/tests/test_calc.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2015 Ben Kurtovic # # 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) diff --git a/tests/test_test.py b/tests/test_test.py index 81e6e38..cd7b9ab 100644 --- a/tests/test_test.py +++ b/tests/test_test.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # Copyright (C) 2009-2015 Ben Kurtovic # # 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)