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