Browse Source

Code cleanup wtih ruff + add pre-commit hook

tags/v0.4
Ben Kurtovic 8 months ago
parent
commit
552277420d
65 changed files with 1260 additions and 845 deletions
  1. +7
    -0
      .pre-commit-config.yaml
  2. +71
    -72
      docs/conf.py
  3. +5
    -3
      earwigbot/__init__.py
  4. +12
    -10
      earwigbot/bot.py
  5. +27
    -11
      earwigbot/commands/__init__.py
  6. +17
    -17
      earwigbot/commands/access.py
  7. +19
    -19
      earwigbot/commands/calc.py
  8. +29
    -12
      earwigbot/commands/chanops.py
  9. +66
    -38
      earwigbot/commands/cidr.py
  10. +17
    -10
      earwigbot/commands/crypt.py
  11. +5
    -5
      earwigbot/commands/ctcp.py
  12. +13
    -10
      earwigbot/commands/dictionary.py
  13. +6
    -6
      earwigbot/commands/editcount.py
  14. +10
    -8
      earwigbot/commands/help.py
  15. +19
    -15
      earwigbot/commands/lag.py
  16. +7
    -7
      earwigbot/commands/langcode.py
  17. +6
    -3
      earwigbot/commands/link.py
  18. +24
    -22
      earwigbot/commands/notes.py
  19. +10
    -8
      earwigbot/commands/quit.py
  20. +7
    -6
      earwigbot/commands/registration.py
  21. +82
    -50
      earwigbot/commands/remind.py
  22. +6
    -6
      earwigbot/commands/rights.py
  23. +91
    -46
      earwigbot/commands/stalk.py
  24. +5
    -5
      earwigbot/commands/test.py
  25. +28
    -23
      earwigbot/commands/threads.py
  26. +4
    -4
      earwigbot/commands/time_command.py
  27. +3
    -3
      earwigbot/commands/trout.py
  28. +7
    -6
      earwigbot/commands/watchers.py
  29. +28
    -15
      earwigbot/config/__init__.py
  30. +8
    -9
      earwigbot/config/formatter.py
  31. +2
    -4
      earwigbot/config/node.py
  32. +14
    -12
      earwigbot/config/ordered_yaml.py
  33. +7
    -5
      earwigbot/config/permissions.py
  34. +51
    -32
      earwigbot/config/script.py
  35. +30
    -2
      earwigbot/exceptions.py
  36. +0
    -2
      earwigbot/irc/__init__.py
  37. +20
    -23
      earwigbot/irc/connection.py
  38. +3
    -5
      earwigbot/irc/data.py
  39. +15
    -8
      earwigbot/irc/frontend.py
  40. +10
    -8
      earwigbot/irc/rc.py
  41. +1
    -3
      earwigbot/irc/watcher.py
  42. +0
    -2
      earwigbot/lazy.py
  43. +9
    -12
      earwigbot/managers.py
  44. +2
    -3
      earwigbot/tasks/__init__.py
  45. +4
    -6
      earwigbot/tasks/wikiproject_tagger.py
  46. +41
    -19
      earwigbot/util.py
  47. +0
    -2
      earwigbot/wiki/__init__.py
  48. +14
    -11
      earwigbot/wiki/category.py
  49. +3
    -3
      earwigbot/wiki/constants.py
  50. +44
    -21
      earwigbot/wiki/copyvios/__init__.py
  51. +26
    -15
      earwigbot/wiki/copyvios/exclusions.py
  52. +7
    -8
      earwigbot/wiki/copyvios/markov.py
  53. +35
    -22
      earwigbot/wiki/copyvios/parsers.py
  54. +30
    -16
      earwigbot/wiki/copyvios/result.py
  55. +24
    -17
      earwigbot/wiki/copyvios/search.py
  56. +68
    -38
      earwigbot/wiki/copyvios/workers.py
  57. +103
    -36
      earwigbot/wiki/page.py
  58. +11
    -13
      earwigbot/wiki/site.py
  59. +12
    -14
      earwigbot/wiki/sitesdb.py
  60. +14
    -14
      earwigbot/wiki/user.py
  61. +6
    -0
      pyproject.toml
  62. +2
    -4
      setup.py
  63. +8
    -9
      tests/__init__.py
  64. +2
    -3
      tests/test_calc.py
  65. +3
    -4
      tests/test_test.py

+ 7
- 0
.pre-commit-config.yaml View File

@@ -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

+ 71
- 72
docs/conf.py View File

@@ -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'

+ 5
- 3
earwigbot/__init__.py View File

@@ -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:


+ 12
- 10
earwigbot/bot.py View File

@@ -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:


+ 27
- 11
earwigbot/commands/__init__.py View File

@@ -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.


+ 17
- 17
earwigbot/commands/access.py View File

@@ -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)

+ 19
- 19
earwigbot/commands/calc.py View File

@@ -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("&gt;", ">")
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:


+ 29
- 12
earwigbot/commands/chanops.py View File

@@ -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))
),
)

+ 66
- 38
earwigbot/commands/cidr.py View File

@@ -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"

+ 17
- 10
earwigbot/commands/crypt.py View File

@@ -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)}")

+ 5
- 5
earwigbot/commands/ctcp.py View File

@@ -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")

+ 13
- 10
earwigbot/commands/dictionary.py View File

@@ -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):


+ 6
- 6
earwigbot/commands/editcount.py View File

@@ -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))

+ 10
- 8
earwigbot/commands/help.py View File

@@ -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()))

+ 19
- 15
earwigbot/commands/lag.py View File

@@ -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"

+ 7
- 7
earwigbot/commands/langcode.py View File

@@ -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.")

+ 6
- 3
earwigbot/commands/link.py View File

@@ -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



+ 24
- 22
earwigbot/commands/notes.py View File

@@ -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."""


+ 10
- 8
earwigbot/commands/quit.py View File

@@ -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.")

+ 7
- 6
earwigbot/commands/registration.py View File

@@ -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]


+ 82
- 50
earwigbot/commands/remind.py View File

@@ -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):


+ 6
- 6
earwigbot/commands/rights.py View File

@@ -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)))

+ 91
- 46
earwigbot/commands/stalk.py View File

@@ -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."""


+ 5
- 5
earwigbot/commands/test.py View File

@@ -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}?")

+ 28
- 23
earwigbot/commands/threads.py View File

@@ -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)

+ 4
- 4
earwigbot/commands/time_command.py View File

@@ -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"))

+ 3
- 3
earwigbot/commands/trout.py View File

@@ -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))

+ 7
- 6
earwigbot/commands/watchers.py View File

@@ -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))

+ 28
- 15
earwigbot/config/__init__.py View File

@@ -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:


+ 8
- 9
earwigbot/config/formatter.py View File

@@ -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

+ 2
- 4
earwigbot/config/node.py View File

@@ -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


+ 14
- 12
earwigbot/config/ordered_yaml.py View File

@@ -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:


+ 7
- 5
earwigbot/config/permissions.py View File

@@ -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):


+ 51
- 32
earwigbot/config/script.py View File

@@ -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()


+ 30
- 2
earwigbot/exceptions.py View File

@@ -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

+ 0
- 2
earwigbot/irc/__init__.py View File

@@ -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
- 23
earwigbot/irc/connection.py View File

@@ -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):


+ 3
- 5
earwigbot/irc/data.py View File

@@ -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


+ 15
- 8
earwigbot/irc/frontend.py View File

@@ -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



+ 10
- 8
earwigbot/irc/rc.py View File

@@ -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
- 3
earwigbot/irc/watcher.py View File

@@ -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"]



+ 0
- 2
earwigbot/lazy.py View File

@@ -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


+ 9
- 12
earwigbot/managers.py View File

@@ -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


+ 2
- 3
earwigbot/tasks/__init__.py View File

@@ -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



+ 4
- 6
earwigbot/tasks/wikiproject_tagger.py View File

@@ -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:


+ 41
- 19
earwigbot/util.py View File

@@ -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()

+ 0
- 2
earwigbot/wiki/__init__.py View File

@@ -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


+ 14
- 11
earwigbot/wiki/category.py View File

@@ -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


+ 3
- 3
earwigbot/wiki/constants.py View File

@@ -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


+ 44
- 21
earwigbot/wiki/copyvios/__init__.py View File

@@ -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()


+ 26
- 15
earwigbot/wiki/copyvios/exclusions.py View File

@@ -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

+ 7
- 8
earwigbot/wiki/copyvios/markov.py View File

@@ -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):


+ 35
- 22
earwigbot/wiki/copyvios/parsers.py View File

@@ -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)

+ 30
- 16
earwigbot/wiki/copyvios/result.py View File

@@ -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,
)

+ 24
- 17
earwigbot/wiki/copyvios/search.py View File

@@ -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,
}

+ 68
- 38
earwigbot/wiki/copyvios/workers.py View File

@@ -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,
)

+ 103
- 36
earwigbot/wiki/page.py View File

@@ -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(",")]


+ 11
- 13
earwigbot/wiki/site.py View File

@@ -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):


+ 12
- 14
earwigbot/wiki/sitesdb.py View File

@@ -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)



+ 14
- 14
earwigbot/wiki/user.py View File

@@ -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)

+ 6
- 0
pyproject.toml View File

@@ -0,0 +1,6 @@
[tool.ruff]
target-version = "py311"

[tool.ruff.lint]
select = ["E4", "E7", "E9", "F", "I", "UP"]
ignore = ["F403"]

+ 2
- 4
setup.py View File

@@ -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=[


+ 8
- 9
tests/__init__.py View File

@@ -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:])


+ 2
- 3
tests/test_calc.py View File

@@ -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)

+ 3
- 4
tests/test_test.py View File

@@ -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)

Loading…
Cancel
Save