Browse Source

Code cleanup wtih ruff + add pre-commit hook

tags/v0.4
Ben Kurtovic 7 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 # EarwigBot documentation build configuration file, created by
# sphinx-quickstart on Sun Apr 29 01:42:25 2012. # 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 # All configuration values have a default; values that are commented out
# serve to show the default. # serve to show the default.


import sys, os
import os
import sys


# If extensions (or modules to document with autodoc) are in another directory, # 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 # 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. # 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 ----------------------------------------------------- # -- General configuration -----------------------------------------------------


# If your documentation needs a minimal Sphinx version, state it here. # 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 # Add any Sphinx extension module names here, as strings. They can be extensions
# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. # 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. # Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
templates_path = ["_templates"]


# The suffix of source filenames. # The suffix of source filenames.
source_suffix = '.rst'
source_suffix = ".rst"


# The encoding of source files. # The encoding of source files.
#source_encoding = 'utf-8-sig'
# source_encoding = 'utf-8-sig'


# The master toctree document. # The master toctree document.
master_doc = 'index'
master_doc = "index"


# General information about the project. # 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 # The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the # |version| and |release|, also used in various other places throughout the
# built documents. # built documents.
# #
# The short X.Y version. # The short X.Y version.
version = '0.4'
version = "0.4"
# The full version, including alpha/beta/rc tags. # 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 # The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages. # for a list of supported languages.
#language = None
# language = None


# There are two options for replacing |today|: either, you set today to some # There are two options for replacing |today|: either, you set today to some
# non-false value, then it is used: # non-false value, then it is used:
#today = ''
# today = ''
# Else, today_fmt is used as the format for a strftime call. # 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 # List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files. # 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. # 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. # 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 # If true, the current module name will be prepended to all description
# unit titles (such as .. function::). # unit titles (such as .. function::).
#add_module_names = True
# add_module_names = True


# If true, sectionauthor and moduleauthor directives will be shown in the # If true, sectionauthor and moduleauthor directives will be shown in the
# output. They are ignored by default. # output. They are ignored by default.
#show_authors = False
# show_authors = False


# The name of the Pygments (syntax highlighting) style to use. # 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. # A list of ignored prefixes for module index sorting.
#modindex_common_prefix = []
# modindex_common_prefix = []




# -- Options for HTML output --------------------------------------------------- # -- Options for HTML output ---------------------------------------------------


# The theme to use for HTML and HTML Help pages. See the documentation for # The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes. # 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 # 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 # further. For a list of options available for each theme, see the
# documentation. # documentation.
#html_theme_options = {}
# html_theme_options = {}


# Add any paths that contain custom themes here, relative to this directory. # 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 # The name for this set of Sphinx documents. If None, it defaults to
# "<project> v<release> documentation". # "<project> v<release> documentation".
#html_title = None
# html_title = None


# A shorter title for the navigation bar. Default is the same as html_title. # 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 # The name of an image file (relative to this directory) to place at the top
# of the sidebar. # 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 # 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 # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
# pixels large. # pixels large.
#html_favicon = None
# html_favicon = None


# Add any paths that contain custom static files (such as style sheets) here, # 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, # relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css". # 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, # If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
# using the given strftime format. # 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 # If true, SmartyPants will be used to convert quotes and dashes to
# typographically correct entities. # typographically correct entities.
#html_use_smartypants = True
# html_use_smartypants = True


# Custom sidebar templates, maps document names to template names. # 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 # Additional templates that should be rendered to pages, maps page names to
# template names. # template names.
#html_additional_pages = {}
# html_additional_pages = {}


# If false, no module index is generated. # If false, no module index is generated.
#html_domain_indices = True
# html_domain_indices = True


# If false, no index is generated. # 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. # 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. # 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. # 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. # 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 # 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 # contain a <link> tag referring to it. The value of this option must be the
# base URL from which the finished HTML is served. # 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"). # 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. # Output file base name for HTML help builder.
htmlhelp_basename = 'EarwigBotdoc'
htmlhelp_basename = "EarwigBotdoc"




# -- Options for LaTeX output -------------------------------------------------- # -- Options for LaTeX output --------------------------------------------------


latex_elements = { 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 # Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title, author, documentclass [howto/manual]). # (source start file, target name, title, author, documentclass [howto/manual]).
latex_documents = [ 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 name of an image file (relative to this directory) to place at the top of
# the title page. # the title page.
#latex_logo = None
# latex_logo = None


# For "manual" documents, if this is true, then toplevel headings are parts, # For "manual" documents, if this is true, then toplevel headings are parts,
# not chapters. # not chapters.
#latex_use_parts = False
# latex_use_parts = False


# If true, show page references after internal links. # If true, show page references after internal links.
#latex_show_pagerefs = False
# latex_show_pagerefs = False


# If true, show URL addresses after external links. # 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. # Documents to append as an appendix to all manuals.
#latex_appendices = []
# latex_appendices = []


# If false, no module index is generated. # If false, no module index is generated.
#latex_domain_indices = True
# latex_domain_indices = True




# -- Options for manual page output -------------------------------------------- # -- Options for manual page output --------------------------------------------


# One entry per manual page. List of tuples # One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section). # (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. # If true, show URL addresses after external links.
#man_show_urls = False
# man_show_urls = False




# -- Options for Texinfo output ------------------------------------------------ # -- Options for Texinfo output ------------------------------------------------
@@ -227,16 +220,22 @@ man_pages = [
# (source start file, target name, title, author, # (source start file, target name, title, author,
# dir menu entry, description, category) # dir menu entry, description, category)
texinfo_documents = [ 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. # Documents to append as an appendix to all manuals.
#texinfo_appendices = []
# texinfo_appendices = []


# If false, no module index is generated. # If false, no module index is generated.
#texinfo_domain_indices = True
# texinfo_domain_indices = True


# How to display URL addresses: 'footnote', 'no', or 'inline'. # 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> # Copyright (C) 2009-2019 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -37,13 +35,17 @@ __email__ = "ben.kurtovic@gmail.com"
__release__ = False __release__ = False


if not __release__: if not __release__:

def _get_git_commit_id(): def _get_git_commit_id():
"""Return the ID of the git HEAD commit.""" """Return the ID of the git HEAD commit."""
from os.path import dirname, split

from git import Repo from git import Repo
from os.path import split, dirname
path = split(dirname(__file__))[0] path = split(dirname(__file__))[0]
commit_id = Repo(path).head.object.hexsha commit_id = Repo(path).head.object.hexsha
return commit_id[:8] return commit_id[:8]

try: try:
__version__ += "+" + _get_git_commit_id() __version__ += "+" + _get_git_commit_id()
except Exception: 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> # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -21,7 +19,8 @@
# SOFTWARE. # SOFTWARE.


import logging 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 time import gmtime, sleep


from earwigbot import __version__ from earwigbot import __version__
@@ -32,6 +31,7 @@ from earwigbot.wiki import SitesDB


__all__ = ["Bot"] __all__ = ["Bot"]



class Bot: class Bot:
""" """
**EarwigBot: Main Bot Class** **EarwigBot: Main Bot Class**
@@ -77,11 +77,11 @@ class Bot:


def __repr__(self): def __repr__(self):
"""Return the canonical string representation of the Bot.""" """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): def __str__(self):
"""Return a nice string representation of the Bot.""" """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): def _dispatch_irc_component(self, name, klass):
"""Create a new IRC component, record it internally, and start it.""" """Create a new IRC component, record it internally, and start it."""
@@ -100,6 +100,7 @@ class Bot:


def _start_wiki_scheduler(self): def _start_wiki_scheduler(self):
"""Start the wiki scheduler in a separate thread if enabled.""" """Start the wiki scheduler in a separate thread if enabled."""

def wiki_scheduler(): def wiki_scheduler():
run_at = 15 run_at = 15
while self._keep_looping: while self._keep_looping:
@@ -118,7 +119,7 @@ class Bot:
if component: if component:
component.keep_alive() component.keep_alive()
if component.is_stopped(): if component.is_stopped():
log = "IRC {0} has stopped; restarting".format(name)
log = f"IRC {name} has stopped; restarting"
self.logger.warn(log) self.logger.warn(log)
self._dispatch_irc_component(name, klass) self._dispatch_irc_component(name, klass)


@@ -151,7 +152,8 @@ class Bot:
skips = component_names + ["MainThread", "reminder", "irc:quit"] skips = component_names + ["MainThread", "reminder", "irc:quit"]
for thread in enumerate_threads(): for thread in enumerate_threads():
if thread.is_alive() and not any( 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) tasks.append(thread.name)
if tasks: if tasks:
log = "The following commands or tasks will be killed: {0}" 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 ensuring that all components remain online and restarting components
that get disconnected from their servers. 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_irc_components()
self._start_wiki_scheduler() self._start_wiki_scheduler()
while self._keep_looping: while self._keep_looping:
@@ -195,7 +197,7 @@ class Bot:
If given, *msg* will be used as our quit message. If given, *msg* will be used as our quit message.
""" """
if msg: if msg:
self.logger.info('Restarting bot ("{0}")'.format(msg))
self.logger.info(f'Restarting bot ("{msg}")')
else: else:
self.logger.info("Restarting bot") self.logger.info("Restarting bot")
with self.component_lock: with self.component_lock:
@@ -211,7 +213,7 @@ class Bot:
If given, *msg* will be used as our quit message. If given, *msg* will be used as our quit message.
""" """
if msg: if msg:
self.logger.info('Stopping bot ("{0}")'.format(msg))
self.logger.info(f'Stopping bot ("{msg}")')
else: else:
self.logger.info("Stopping bot") self.logger.info("Stopping bot")
with self.component_lock: 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> # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -22,6 +20,7 @@


__all__ = ["Command"] __all__ = ["Command"]



class Command: class Command:
""" """
**EarwigBot: Base IRC Command** **EarwigBot: Base IRC Command**
@@ -36,6 +35,7 @@ class Command:
This docstring is reported to the user when they type ``"!help This docstring is reported to the user when they type ``"!help
<command>"``. <command>"``.
""" """

# The command's name, as reported to the user when they use !help: # The command's name, as reported to the user when they use !help:
name = None name = None


@@ -62,15 +62,31 @@ class Command:
self.logger = bot.commands.logger.getChild(self.name) self.logger = bot.commands.logger.getChild(self.name)


# Convenience functions: # 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.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() self.setup()


@@ -81,7 +97,7 @@ class Command:


def __str__(self): def __str__(self):
"""Return a nice string representation of the Command.""" """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): def setup(self):
"""Hook called immediately after the command is loaded. """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> # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -24,8 +22,10 @@ import re


from earwigbot.commands import Command from earwigbot.commands import Command



class Access(Command): class Access(Command):
"""Control and get info on who can access the bot.""" """Control and get info on who can access the bot."""

name = "access" name = "access"
commands = ["access", "permission", "permissions", "perm", "perms"] commands = ["access", "permission", "permissions", "perm", "perms"]


@@ -42,15 +42,15 @@ class Access(Command):
elif data.args[0] == "help": elif data.args[0] == "help":
self.reply(data, "Subcommands are self, list, add, and remove.") self.reply(data, "Subcommands are self, list, add, and remove.")
else: 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])) self.reply(data, msg.format(data.args[0]))


def do_self(self, data, permdb): def do_self(self, data, permdb):
if permdb.is_owner(data): 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))) self.reply(data, msg.format(permdb.is_owner(data)))
elif permdb.is_admin(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))) self.reply(data, msg.format(permdb.is_admin(data)))
else: else:
self.reply(data, "You do not match any bot access rules.") 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"]: elif data.args[1] in ["admin", "admins"]:
name, rules = "admins", permdb.users.get(permdb.ADMIN) name, rules = "admins", permdb.users.get(permdb.ADMIN)
else: else:
msg = "Unknown access level \x0302{0}\x0F."
msg = "Unknown access level \x0302{0}\x0f."
self.reply(data, msg.format(data.args[1])) self.reply(data, msg.format(data.args[1]))
return return
if rules: if rules:
msg = "Bot {0}: {1}.".format(name, ", ".join(map(str, rules)))
msg = "Bot {}: {}.".format(name, ", ".join(map(str, rules)))
else: else:
msg = "No bot {0}.".format(name)
msg = f"No bot {name}."
self.reply(data, msg) self.reply(data, msg)
else: else:
owners = len(permdb.users.get(permdb.OWNER, [])) owners = len(permdb.users.get(permdb.OWNER, []))
admins = len(permdb.users.get(permdb.ADMIN, [])) 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)) self.reply(data, msg.format(owners, admins, data.command))


def do_add(self, data, permdb): def do_add(self, data, permdb):
@@ -85,12 +85,12 @@ class Access(Command):
else: else:
name, level, adder = "admin", permdb.ADMIN, permdb.add_admin name, level, adder = "admin", permdb.ADMIN, permdb.add_admin
if permdb.has_exact(level, nick, ident, host): 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) self.reply(data, msg)
else: else:
rule = adder(nick, ident, host) 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) self.reply(data, msg)


def do_remove(self, data, permdb): def do_remove(self, data, permdb):
@@ -103,11 +103,11 @@ class Access(Command):
name, rmver = "admin", permdb.remove_admin name, rmver = "admin", permdb.remove_admin
rule = rmver(nick, ident, host) rule = rmver(nick, ident, host)
if rule: if rule:
msg = "Removed bot {0} \x0302{1}\x0F.".format(name, rule)
msg = f"Removed bot {name} \x0302{rule}\x0f."
self.reply(data, msg) self.reply(data, msg)
else: 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) self.reply(data, msg)


def get_user_from_args(self, data, permdb): 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) return user.group(1), user.group(2), user.group(3)


def no_arg_error(self, data): 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) 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> # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -21,14 +19,16 @@
# SOFTWARE. # SOFTWARE.


import re import re
import urllib.request
import urllib.parse import urllib.parse
import urllib.request


from earwigbot.commands import Command from earwigbot.commands import Command



class Calc(Command): class Calc(Command):
"""A somewhat advanced calculator: see https://futureboy.us/fsp/frink.fsp """A somewhat advanced calculator: see https://futureboy.us/fsp/frink.fsp
for details.""" for details."""

name = "calc" name = "calc"


def process(self, data): def process(self, data):
@@ -36,15 +36,15 @@ class Calc(Command):
self.reply(data, "What do you want me to calculate?") self.reply(data, "What do you want me to calculate?")
return return


query = ' '.join(data.args)
query = " ".join(data.args)
query = self.cleanup(query) query = self.cleanup(query)


url = "https://futureboy.us/fsp/frink.fsp?fromVal={0}" url = "https://futureboy.us/fsp/frink.fsp?fromVal={0}"
url = url.format(urllib.parse.quote(query)) url = url.format(urllib.parse.quote(query))
result = urllib.request.urlopen(url).read().decode() 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) match = r_result.search(result)
if not match: if not match:
@@ -52,32 +52,32 @@ class Calc(Command):
return return


result = match.group(1) 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("&gt;", ">")
result = result.replace("(undefined symbol)", "(?) ") result = result.replace("(undefined symbol)", "(?) ")
result = result.strip() result = result.strip()


if not result: if not result:
result = '?'
result = "?"
elif " in " in query: elif " in " in query:
result += " " + query.split(" in ", 1)[1] result += " " + query.split(" in ", 1)[1]


res = "%s = %s" % (query, result)
res = f"{query} = {result}"
self.reply(data, res) self.reply(data, res)


@staticmethod @staticmethod
def cleanup(query): def cleanup(query):
fixes = [ 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: 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> # Copyright (C) 2009-2021 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -22,17 +20,32 @@


from earwigbot.commands import Command from earwigbot.commands import Command



class ChanOps(Command): class ChanOps(Command):
"""Voice, devoice, op, or deop users in the channel, or join or part from """Voice, devoice, op, or deop users in the channel, or join or part from
other channels.""" other channels."""

name = "chanops" name = "chanops"
commands = ["chanops", "voice", "devoice", "op", "deop", "join", "part", "listchans"]
commands = [
"chanops",
"voice",
"devoice",
"op",
"deop",
"join",
"part",
"listchans",
]


def process(self, data): def process(self, data):
if data.command == "chanops": if data.command == "chanops":
msg = "Available commands are {0}." 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 return
de_escalate = data.command in ["devoice", "deop"] de_escalate = data.command in ["devoice", "deop"]
if de_escalate and (not data.args or data.args[0] == data.nick): if de_escalate and (not data.args or data.args[0] == data.nick):
@@ -70,7 +83,7 @@ class ChanOps(Command):
return return


self.join(channel) 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) self.logger.info(log)


def do_part(self, data): def do_part(self, data):
@@ -85,11 +98,11 @@ class ChanOps(Command):
else: # "!part reason for parting"; assume current channel else: # "!part reason for parting"; assume current channel
reason = " ".join(data.args) 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: if reason:
msg += ": {0}".format(reason)
log += ' ("{0}")'.format(reason)
msg += f": {reason}"
log += f' ("{reason}")'
self.part(channel, msg) self.part(channel, msg)
self.logger.info(log) self.logger.info(log)


@@ -98,5 +111,9 @@ class ChanOps(Command):
if not chans: if not chans:
self.reply(data, "I am currently in no channels.") self.reply(data, "I am currently in no channels.")
return 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> # Copyright (C) 2009-2016 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # 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 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE. # SOFTWARE.


from collections import namedtuple
import re import re
import socket import socket
from collections import namedtuple
from socket import AF_INET, AF_INET6 from socket import AF_INET, AF_INET6


from earwigbot.commands import Command from earwigbot.commands import Command


_IP = namedtuple("_IP", ["family", "ip", "size"]) _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): class CIDR(Command):
"""Calculates the smallest CIDR range that encompasses a list of IP """Calculates the smallest CIDR range that encompasses a list of IP
addresses. Used to make range blocks.""" addresses. Used to make range blocks."""

name = "cidr" 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 # https://www.mediawiki.org/wiki/Manual:$wgBlockCIDRLimit
LIMIT_IPv4 = 16 LIMIT_IPv4 = 16
@@ -44,22 +50,25 @@ class CIDR(Command):


def process(self, data): def process(self, data):
if not data.args: 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)) self.reply(data, msg.format(data.command))
return return


try: try:
ips = [self._parse_ip(arg) for arg in data.args] ips = [self._parse_ip(arg) for arg in data.args]
except ValueError as exc: 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)) self.reply(data, msg.format(exc.message))
return return


if any(ip.family == AF_INET for ip in ips) and any( 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." msg = "Can't calculate a range for both IPv4 and IPv6 addresses."
self.reply(data, msg) self.reply(data, msg)
return return
@@ -67,11 +76,20 @@ class CIDR(Command):
cidr = self._calculate_range(ips[0].family, ips) cidr = self._calculate_range(ips[0].family, ips)
descr = self._describe(cidr.family, cidr.size) 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): def _parse_ip(self, arg):
"""Converts an argument into an IP address object.""" """Converts an argument into an IP address object."""
@@ -89,10 +107,10 @@ class CIDR(Command):


try: try:
ip = _IP(AF_INET, socket.inet_pton(AF_INET, arg), size) ip = _IP(AF_INET, socket.inet_pton(AF_INET, arg), size)
except socket.error:
except OSError:
try: try:
return _IP(AF_INET6, socket.inet_pton(AF_INET6, arg), size) return _IP(AF_INET6, socket.inet_pton(AF_INET6, arg), size)
except socket.error:
except OSError:
raise ValueError(oldarg) raise ValueError(oldarg)
if size > 32: if size > 32:
raise ValueError(oldarg) raise ValueError(oldarg)
@@ -126,18 +144,20 @@ class CIDR(Command):


def _calculate_range(self, family, ips): def _calculate_range(self, family, ips):
"""Calculate the smallest CIDR range encompassing a list of 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): for i, ip in enumerate(ips):
if ip.size is not None: if ip.size is not None:
suffix = "X" * (len(bin_ips[i]) - ip.size) 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]) size = len(bin_ips[0])
for i in range(len(bin_ips[0])): for i in range(len(bin_ips[0])):
if any(ip[i] == "X" for ip in bin_ips) or ( 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 size = i
break break


@@ -147,33 +167,41 @@ class CIDR(Command):
high = self._format_bin(family, bin_high) high = self._format_bin(family, bin_high)


return _Range( 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 @staticmethod
def _format_bin(family, binary): def _format_bin(family, binary):
"""Convert an IP's binary representation to presentation format.""" """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 @staticmethod
def _format_count(count): def _format_count(count):
"""Nicely format a number of addresses affected by a range block.""" """Nicely format a number of addresses affected by a range block."""
if count == 1: if count == 1:
return "1 address" 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)" 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 base
return "{0:,} addresses".format(count)
return f"{count:,} addresses"


def _describe(self, family, size): def _describe(self, family, size):
"""Return an optional English description of a range.""" """Return an optional English description of a range."""
if (family == AF_INET and size < self.LIMIT_IPv4) or ( 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" 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> # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # 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") hashes = importer.new("cryptography.hazmat.primitives.hashes")
pbkdf2 = importer.new("cryptography.hazmat.primitives.kdf.pbkdf2") pbkdf2 = importer.new("cryptography.hazmat.primitives.kdf.pbkdf2")



class Crypt(Command): class Crypt(Command):
"""Provides hash functions with !hash (!hash list for supported algorithms) """Provides hash functions with !hash (!hash list for supported algorithms)
and basic encryption with !encrypt and !decrypt.""" and basic encryption with !encrypt and !decrypt."""

name = "crypt" name = "crypt"
commands = ["crypt", "hash", "encrypt", "decrypt"] commands = ["crypt", "hash", "encrypt", "decrypt"]


@@ -44,22 +44,22 @@ class Crypt(Command):
return return


if not data.args: 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) self.reply(data, msg)
return return


if data.command == "hash": if data.command == "hash":
algo = data.args[0] algo = data.args[0]
if algo == "list": if algo == "list":
algos = ', '.join(hashlib.algorithms_available)
algos = ", ".join(hashlib.algorithms_available)
msg = algos.join(("Supported algorithms: ", ".")) msg = algos.join(("Supported algorithms: ", "."))
self.reply(data, msg) self.reply(data, msg)
elif algo in hashlib.algorithms_available: elif algo in hashlib.algorithms_available:
string = ' '.join(data.args[1:])
string = " ".join(data.args[1:])
result = getattr(hashlib, algo)(string.encode()).hexdigest() result = getattr(hashlib, algo)(string.encode()).hexdigest()
self.reply(data, result) self.reply(data, result)
else: else:
msg = "Unknown algorithm: '{0}'.".format(algo)
msg = f"Unknown algorithm: '{algo}'."
self.reply(data, msg) self.reply(data, msg)


else: else:
@@ -81,7 +81,9 @@ class Crypt(Command):
salt=salt, salt=salt,
iterations=100000, 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()) ciphertext = f.encrypt(text.encode())
self.reply(data, base64.b64encode(salt + ciphertext).decode()) self.reply(data, base64.b64encode(salt + ciphertext).decode())
else: else:
@@ -95,9 +97,14 @@ class Crypt(Command):
salt=salt, salt=salt,
iterations=100000, 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()) self.reply(data, f.decrypt(ciphertext).decode())
except ImportError: 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: 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> # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # 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 import __version__
from earwigbot.commands import Command from earwigbot.commands import Command



class CTCP(Command): class CTCP(Command):
"""Not an actual command; this module implements responses to the CTCP """Not an actual command; this module implements responses to the CTCP
requests PING, TIME, and VERSION.""" requests PING, TIME, and VERSION."""

name = "ctcp" name = "ctcp"
hooks = ["msg_private"] hooks = ["msg_private"]


@@ -52,17 +52,17 @@ class CTCP(Command):
if command == "PING": if command == "PING":
msg = " ".join(data.line[4:]) msg = " ".join(data.line[4:])
if msg: if msg:
self.notice(target, "\x01PING {0}\x01".format(msg))
self.notice(target, f"\x01PING {msg}\x01")
else: else:
self.notice(target, "\x01PING\x01") self.notice(target, "\x01PING\x01")


elif command == "TIME": elif command == "TIME":
ts = time.strftime("%a, %d %b %Y %H:%M:%S %Z", time.localtime()) 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": elif command == "VERSION":
default = "EarwigBot - $1 - Python/$2 https://github.com/earwig/earwigbot" default = "EarwigBot - $1 - Python/$2 https://github.com/earwig/earwigbot"
vers = self.config.irc.get("version", default) vers = self.config.irc.get("version", default)
vers = vers.replace("$1", __version__) vers = vers.replace("$1", __version__)
vers = vers.replace("$2", platform.python_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> # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # 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 import exceptions
from earwigbot.commands import Command from earwigbot.commands import Command



class Dictionary(Command): class Dictionary(Command):
"""Define words and stuff.""" """Define words and stuff."""

name = "dictionary" name = "dictionary"
commands = ["dict", "dictionary", "define", "def"] commands = ["dict", "dictionary", "define", "def"]


@@ -63,7 +63,7 @@ class Dictionary(Command):


level, languages = self.get_languages(entry) level, languages = self.get_languages(entry)
if not languages: 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 if "#" in term: # Requesting a specific language
lcase_langs = {lang.lower(): lang for lang in languages} lcase_langs = {lang.lower(): lang for lang in languages}
@@ -73,12 +73,12 @@ class Dictionary(Command):
resp = "Language {0} not found in definition." resp = "Language {0} not found in definition."
return resp.format(request) return resp.format(request)
definition = self.get_definition(languages[lang], level) definition = self.get_definition(languages[lang], level)
return "({0}) {1}".format(lang, definition)
return f"({lang}) {definition}"


result = [] result = []
for lang, section in sorted(languages.items()): for lang, section in sorted(languages.items()):
definition = self.get_definition(section, level) definition = self.get_definition(section, level)
result.append("({0}) {1}".format(lang, definition))
result.append(f"({lang}) {definition}")
return "; ".join(result) return "; ".join(result)


def get_languages(self, entry, level=2): def get_languages(self, entry, level=2):
@@ -119,19 +119,22 @@ class Dictionary(Command):
blocks = "=" * (level + 1) blocks = "=" * (level + 1)
defs = [] defs = []
for part, basename in parts_of_speech.items(): 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: for fullname in fullnames:
regex = blocks + r"\s*" + fullname + r"\s*" + blocks regex = blocks + r"\s*" + fullname + r"\s*" + blocks
if re.search(regex, section): if re.search(regex, section):
regex = blocks + r"\s*" + fullname 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) bodies = re.findall(regex, section, re.DOTALL)
if bodies: if bodies:
for body in bodies: for body in bodies:
definition = self.parse_body(body) definition = self.parse_body(body)
if definition: if definition:
msg = "\x02{0}\x0F {1}"
msg = "\x02{0}\x0f {1}"
defs.append(msg.format(part, definition)) defs.append(msg.format(part, definition))


return "; ".join(defs) return "; ".join(defs)
@@ -167,7 +170,7 @@ class Dictionary(Command):


result = [] # Number the senses incrementally result = [] # Number the senses incrementally
for i, sense in enumerate(senses): for i, sense in enumerate(senses):
result.append("{0}. {1}".format(i + 1, sense))
result.append(f"{i + 1}. {sense}")
return " ".join(result) return " ".join(result)


def strip_templates(self, line): 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> # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # 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 import exceptions
from earwigbot.commands import Command from earwigbot.commands import Command



class Editcount(Command): class Editcount(Command):
"""Return a user's edit count.""" """Return a user's edit count."""

name = "editcount" name = "editcount"
commands = ["ec", "editcount"] commands = ["ec", "editcount"]


@@ -34,7 +34,7 @@ class Editcount(Command):
if not data.args: if not data.args:
name = data.nick name = data.nick
else: else:
name = ' '.join(data.args)
name = " ".join(data.args)


site = self.bot.wiki.get_site() site = self.bot.wiki.get_site()
user = site.get_user(name) user = site.get_user(name)
@@ -42,12 +42,12 @@ class Editcount(Command):
try: try:
count = user.editcount count = user.editcount
except exceptions.UserNotFoundError: 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)) self.reply(data, msg.format(name))
return return


safe = quote_plus(user.name.encode("utf8")) 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) 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)) 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> # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # 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 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE. # SOFTWARE.


from platform import python_version
import re import re
from platform import python_version


from earwigbot import __version__ from earwigbot import __version__
from earwigbot.commands import Command from earwigbot.commands import Command



class Help(Command): class Help(Command):
"""Displays information about the bot.""" """Displays information about the bot."""

name = "help" name = "help"
commands = ["help", "version"] commands = ["help", "version"]


@@ -53,7 +53,7 @@ class Help(Command):
"""Give the user a general help message with a list of all commands.""" """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>'." 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]) 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) self.reply(data, msg)


def do_command_help(self, data): def do_command_help(self, data):
@@ -65,16 +65,18 @@ class Help(Command):
if command.__doc__: if command.__doc__:
doc = command.__doc__.replace("\n", "") doc = command.__doc__.replace("\n", "")
doc = re.sub(r"\s\s+", " ", doc) 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)) self.reply(data, msg.format(target, doc))
return return


msg = "Sorry, no help for \x0303{0}\x0F.".format(target)
msg = f"Sorry, no help for \x0303{target}\x0f."
self.reply(data, msg) self.reply(data, msg)


def do_hello(self, data): 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): 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())) 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> # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -23,8 +21,10 @@
from earwigbot import exceptions from earwigbot import exceptions
from earwigbot.commands import Command from earwigbot.commands import Command



class Lag(Command): class Lag(Command):
"""Return replag or maxlag information on specific databases.""" """Return replag or maxlag information on specific databases."""

name = "lag" name = "lag"
commands = ["lag", "replag", "maxlag"] commands = ["lag", "replag", "maxlag"]


@@ -33,22 +33,21 @@ class Lag(Command):
if not site: if not site:
return return
if data.command == "replag": if data.command == "replag":
base = "\x0302{0}\x0F: {1}."
base = "\x0302{0}\x0f: {1}."
msg = base.format(site.name, self.get_replag(site)) msg = base.format(site.name, self.get_replag(site))
elif data.command == "maxlag": elif data.command == "maxlag":
base = "\x0302{0}\x0F: {1}."
base = "\x0302{0}\x0f: {1}."
msg = base.format(site.name, self.get_maxlag(site)) msg = base.format(site.name, self.get_maxlag(site))
else: 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) self.reply(data, msg)


def get_replag(self, site): 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): 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): def get_site(self, data):
if data.kwargs and "project" in data.kwargs and "lang" in data.kwargs: 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: if len(data.args) > 1:
name = " ".join(data.args) name = " ".join(data.args)
self.reply(data, "Unknown site: \x0302{0}\x0F.".format(name))
self.reply(data, f"Unknown site: \x0302{name}\x0f.")
return return
name = data.args[0] name = data.args[0]
if "." in name: if "." in name:
@@ -71,7 +70,7 @@ class Lag(Command):
try: try:
return self.bot.wiki.get_site(name) return self.bot.wiki.get_site(name)
except exceptions.SiteNotFoundError: except exceptions.SiteNotFoundError:
msg = "Unknown site: \x0302{0}\x0F.".format(name)
msg = f"Unknown site: \x0302{name}\x0f."
self.reply(data, msg) self.reply(data, msg)
return return
return self.get_site_from_proj_and_lang(data, project, lang) return self.get_site_from_proj_and_lang(data, project, lang)
@@ -83,19 +82,24 @@ class Lag(Command):
try: try:
site = self.bot.wiki.add_site(project=project, lang=lang) site = self.bot.wiki.add_site(project=project, lang=lang)
except exceptions.APIError: 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)) self.reply(data, msg.format(project, lang))
return return
return site return site


def time(self, seconds): 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 = [] msg = []
for name, size in parts: for name, size in parts:
num = seconds / size num = seconds / size
seconds -= num * size seconds -= num * size
if num: 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) msg.append(chunk)
return ", ".join(msg) if msg else "0 seconds" 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> # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -22,9 +20,11 @@


from earwigbot.commands import Command from earwigbot.commands import Command



class Langcode(Command): class Langcode(Command):
"""Convert a language code into its name (or vice versa), and give a list """Convert a language code into its name (or vice versa), and give a list
of WMF sites in that language.""" of WMF sites in that language."""

name = "langcode" name = "langcode"
commands = ["langcode", "lang", "language"] commands = ["langcode", "lang", "language"]


@@ -46,17 +46,17 @@ class Langcode(Command):
localname = site["localname"].encode("utf8") localname = site["localname"].encode("utf8")
if site["code"] == lcase: if site["code"] == lcase:
if name != localname: if name != localname:
name += " ({0})".format(localname)
name += f" ({localname})"
sites = ", ".join([s["url"] for s in site["site"]]) 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) self.reply(data, msg)
return return
elif name.lower() == lcase or localname.lower() == lcase: elif name.lower() == lcase or localname.lower() == lcase:
if name != localname: if name != localname:
name += " ({0})".format(localname)
name += f" ({localname})"
sites = ", ".join([s["url"] for s in site["site"]]) 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)) self.reply(data, msg.format(name, site["code"], sites))
return 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> # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -24,8 +22,10 @@ import re


from earwigbot.commands import Command from earwigbot.commands import Command



class Link(Command): class Link(Command):
"""Convert a Wikipedia page name into a URL.""" """Convert a Wikipedia page name into a URL."""

name = "link" name = "link"


def setup(self): def setup(self):
@@ -72,7 +72,10 @@ class Link(Command):
# Find all {{templates}} # Find all {{templates}}
templates = re.findall(r"(\{\{(.*?)(\||\}\}))", line) templates = re.findall(r"(\{\{(.*?)(\||\}\}))", line)
if templates: 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] templates = [p_tmpl(i[1]) for i in templates]
results += 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> # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # 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 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE. # SOFTWARE.


from datetime import datetime
from os import path
import re import re
import sqlite3 as sqlite import sqlite3 as sqlite
from datetime import datetime
from os import path
from threading import Lock from threading import Lock


from earwigbot.commands import Command from earwigbot.commands import Command



class Notes(Command): class Notes(Command):
"""A mini IRC-based wiki for storing notes, tips, and reminders.""" """A mini IRC-based wiki for storing notes, tips, and reminders."""

name = "notes" name = "notes"
commands = ["notes", "note", "about"] commands = ["notes", "note", "about"]
version = "2.1" version = "2.1"
@@ -43,7 +43,7 @@ class Notes(Command):
"change": "edit", "change": "edit",
"modify": "edit", "modify": "edit",
"move": "rename", "move": "rename",
"remove": "delete"
"remove": "delete",
} }


def setup(self): def setup(self):
@@ -70,7 +70,7 @@ class Notes(Command):
elif command in self.aliases: elif command in self.aliases:
commands[self.aliases[command]](data) commands[self.aliases[command]](data)
else: else:
msg = "Unknown subcommand: \x0303{0}\x0F.".format(command)
msg = f"Unknown subcommand: \x0303{command}\x0f."
self.reply(data, msg) self.reply(data, msg)


def do_help(self, data): def do_help(self, data):
@@ -94,8 +94,10 @@ class Notes(Command):
try: try:
command = data.args[1] command = data.args[1]
except IndexError: 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()) cmnds = ", ".join(info.keys())
self.reply(data, msg.format(self.version, cmnds, data.command)) self.reply(data, msg.format(self.version, cmnds, data.command))
return return
@@ -103,9 +105,9 @@ class Notes(Command):
command = self.aliases[command] command = self.aliases[command]
try: try:
help_ = re.sub(r"\s\s+", " ", info[command].replace("\n", "")) 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: except KeyError:
msg = "Unknown subcommand: \x0303{0}\x0F.".format(command)
msg = f"Unknown subcommand: \x0303{command}\x0f."
self.reply(data, msg) self.reply(data, msg)


def do_list(self, data): def do_list(self, data):
@@ -119,7 +121,7 @@ class Notes(Command):


if entries: if entries:
entries = [entry[0].encode("utf8") for entry in 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: else:
self.reply(data, "No entries in the database.") self.reply(data, "No entries in the database.")


@@ -142,10 +144,10 @@ class Notes(Command):


title = title.encode("utf8") title = title.encode("utf8")
if content: if content:
msg = "\x0302{0}\x0F: {1}"
msg = "\x0302{0}\x0f: {1}"
self.reply(data, msg.format(title, content.encode("utf8"))) self.reply(data, msg.format(title, content.encode("utf8")))
else: 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): def do_edit(self, data):
"""Edit an entry in the notes database.""" """Edit an entry in the notes database."""
@@ -191,7 +193,7 @@ class Notes(Command):
else: else:
conn.execute(query4, (revid, id_)) 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"))) self.reply(data, msg.format(title.encode("utf8")))


def do_info(self, data): def do_info(self, data):
@@ -216,17 +218,17 @@ class Notes(Command):
title = info[0][0] title = info[0][0]
times = [datum[1] for datum in info] times = [datum[1] for datum in info]
earliest = min(times) 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) msg = msg.format(title.encode("utf8"), len(info), earliest)
if len(times) > 1: if len(times) > 1:
latest = max(times) latest = max(times)
msg += "; last edit on {0}".format(latest)
msg += f"; last edit on {latest}"
names = [datum[2] for datum in info] 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) self.reply(data, msg)
else: else:
title = data.args[1] 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): def do_rename(self, data):
"""Rename an entry in the notes database.""" """Rename an entry in the notes database."""
@@ -254,7 +256,7 @@ class Notes(Command):
try: try:
id_, author = conn.execute(query1, (slug,)).fetchone() id_, author = conn.execute(query1, (slug,)).fetchone()
except (sqlite.OperationalError, TypeError): 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) self.reply(data, msg)
return return
permdb = self.config.irc["permissions"] permdb = self.config.irc["permissions"]
@@ -265,7 +267,7 @@ class Notes(Command):
args = (self._slugify(newtitle), newtitle.decode("utf8"), id_) args = (self._slugify(newtitle), newtitle.decode("utf8"), id_)
conn.execute(query2, args) 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)) self.reply(data, msg.format(data.args[1], newtitle))


def do_delete(self, data): def do_delete(self, data):
@@ -286,7 +288,7 @@ class Notes(Command):
try: try:
id_, author = conn.execute(query1, (slug,)).fetchone() id_, author = conn.execute(query1, (slug,)).fetchone()
except (sqlite.OperationalError, TypeError): 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) self.reply(data, msg)
return return
permdb = self.config.irc["permissions"] permdb = self.config.irc["permissions"]
@@ -297,7 +299,7 @@ class Notes(Command):
conn.execute(query2, (id_,)) conn.execute(query2, (id_,))
conn.execute(query3, (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): def _slugify(self, name):
"""Convert *name* into an identifier for storing in the database.""" """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> # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -22,9 +20,11 @@


from earwigbot.commands import Command from earwigbot.commands import Command



class Quit(Command): class Quit(Command):
"""Quit, restart, or reload components from the bot. Only the owners can """Quit, restart, or reload components from the bot. Only the owners can
run this command.""" run this command."""

name = "quit" name = "quit"
commands = ["quit", "restart", "reload"] commands = ["quit", "restart", "reload"]


@@ -45,24 +45,26 @@ class Quit(Command):
reason = " ".join(args) reason = " ".join(args)
else: else:
if not args or args[0].lower() != data.my_nick: 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 return
reason = " ".join(args[1:]) reason = " ".join(args[1:])


if reason: if reason:
self.bot.stop("Stopped by {0}: {1}".format(data.nick, reason))
self.bot.stop(f"Stopped by {data.nick}: {reason}")
else: else:
self.bot.stop("Stopped by {0}".format(data.nick))
self.bot.stop(f"Stopped by {data.nick}")


def do_restart(self, data): def do_restart(self, data):
if data.args: if data.args:
msg = " ".join(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: else:
self.bot.restart("Restarted by {0}".format(data.nick))
self.bot.restart(f"Restarted by {data.nick}")


def do_reload(self, data): 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.commands.load()
self.bot.tasks.load() self.bot.tasks.load()
self.reply(data, "IRC commands and bot tasks reloaded.") 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> # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # 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 import exceptions
from earwigbot.commands import Command from earwigbot.commands import Command



class Registration(Command): class Registration(Command):
"""Return when a user registered.""" """Return when a user registered."""

name = "registration" name = "registration"
commands = ["registration", "reg", "age"] commands = ["registration", "reg", "age"]


@@ -35,7 +35,7 @@ class Registration(Command):
if not data.args: if not data.args:
name = data.nick name = data.nick
else: else:
name = ' '.join(data.args)
name = " ".join(data.args)


site = self.bot.wiki.get_site() site = self.bot.wiki.get_site()
user = site.get_user(name) user = site.get_user(name)
@@ -43,7 +43,7 @@ class Registration(Command):
try: try:
reg = user.registration reg = user.registration
except exceptions.UserNotFoundError: 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)) self.reply(data, msg.format(name))
return return


@@ -58,15 +58,16 @@ class Registration(Command):
else: else:
gender = "They're" # Singular they? 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)) self.reply(data, msg.format(name, date, gender, age))


def get_age(self, birth): def get_age(self, birth):
msg = [] msg = []

def insert(unit, num): def insert(unit, num):
if not num: if not num:
return 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() now = datetime.utcnow()
bd_passed = now.timetuple()[1:-3] < birth.timetuple()[1:-3] 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> # Copyright (C) 2009-2016 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -21,21 +19,21 @@
# SOFTWARE. # SOFTWARE.


import ast import ast
from itertools import chain
import operator import operator
import random import random
from threading import RLock, Thread
import time import time
from itertools import chain
from threading import RLock, Thread


from earwigbot.commands import Command from earwigbot.commands import Command
from earwigbot.irc import Data from earwigbot.irc import Data


DISPLAY = ["display", "show", "info", "details"] 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 = ["snooze", "delay", "reset", "adjust", "modify", "change"]
SNOOZE_ONLY = ["snooze", "delay", "reset"] SNOOZE_ONLY = ["snooze", "delay", "reset"]



def _format_time(epoch): def _format_time(epoch):
"""Format a UNIX timestamp nicely.""" """Format a UNIX timestamp nicely."""
lctime = time.localtime(epoch) lctime = time.localtime(epoch)
@@ -48,9 +46,17 @@ def _format_time(epoch):
class Remind(Command): class Remind(Command):
"""Set a message to be repeated to you in a certain amount of time. See """Set a message to be repeated to you in a certain amount of time. See
usage with !remind help.""" usage with !remind help."""

name = "remind" name = "remind"
commands = ["remind", "reminder", "reminders", "snooze", "cancel",
"unremind", "forget"]
commands = [
"remind",
"reminder",
"reminders",
"snooze",
"cancel",
"unremind",
"forget",
]


@staticmethod @staticmethod
def _normalize(command): def _normalize(command):
@@ -68,19 +74,27 @@ class Remind(Command):
def _parse_time(arg): def _parse_time(arg):
"""Parse the wait time for a reminder.""" """Parse the wait time for a reminder."""
ast_to_op = { 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 = { 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): def _evaluate(node):
"""Convert an AST node into a real number or raise an exception.""" """Convert an AST node into a real number or raise an exception."""
if isinstance(node, ast.Num): if isinstance(node, ast.Num):
if not isinstance(node.n, (int, float)):
if not isinstance(node.n, int | float):
raise ValueError(node.n) raise ValueError(node.n)
return node.n return node.n
elif isinstance(node, ast.BinOp): elif isinstance(node, ast.BinOp):
@@ -114,7 +128,7 @@ class Remind(Command):
"""Get a free ID for a new reminder.""" """Get a free ID for a new reminder."""
taken = set(robj.id for robj in chain(*list(self.reminders.values()))) taken = set(robj.id for robj in chain(*list(self.reminders.values())))
num = random.choice(list(set(range(4096)) - taken)) 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): def _start_reminder(self, reminder, user):
"""Start the given reminder object for the given user.""" """Start the given reminder object for the given user."""
@@ -129,12 +143,14 @@ class Remind(Command):
try: try:
wait = self._parse_time(data.args[0]) wait = self._parse_time(data.args[0])
except ValueError: 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])) return self.reply(data, msg.format(data.args[0]))


if wait > 1000 * 365 * 24 * 60 * 60: if wait > 1000 * 365 * 24 * 60 * 60:
# Hard to think of a good upper limit, but 1000 years works. # 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])) return self.reply(data, msg.format(data.args[0]))


message = " ".join(data.args[1:]) message = " ".join(data.args[1:])
@@ -146,14 +162,15 @@ class Remind(Command):


reminder = _Reminder(rid, data.host, wait, message, data, self) reminder = _Reminder(rid, data.host, wait, message, data, self)
self._start_reminder(reminder, data.host) 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)) self.reply(data, msg.format(rid, reminder.end_time))


def _display_reminder(self, data, reminder): def _display_reminder(self, data, reminder):
"""Display a particular reminder's information.""" """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) self.reply(data, msg)


def _cancel_reminder(self, data, reminder): def _cancel_reminder(self, data, reminder):
@@ -163,7 +180,7 @@ class Remind(Command):
self.reminders[data.host].remove(reminder) self.reminders[data.host].remove(reminder)
if not self.reminders[data.host]: if not self.reminders[data.host]:
del 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)) self.reply(data, msg.format(reminder.id))


def _snooze_reminder(self, data, reminder, arg=None): def _snooze_reminder(self, data, reminder, arg=None):
@@ -176,7 +193,7 @@ class Remind(Command):


reminder.reset(duration) reminder.reset(duration)
end = _format_time(reminder.end) 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)) self.reply(data, msg.format(reminder.id, verb, end))


def _load_reminders(self): def _load_reminders(self):
@@ -202,39 +219,52 @@ class Remind(Command):
def _show_reminders(self, data): def _show_reminders(self, data):
"""Show all of a user's current reminders.""" """Show all of a user's current reminders."""
if data.host not in self.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 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]) 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): def _show_all_reminders(self, data):
"""Show all reminders to bot admins.""" """Show all reminders to bot admins."""
if not self.config.irc["permissions"].is_admin(data): 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 return
if not self.reminders: if not self.reminders:
self.reply(data, "There are no active reminders.") self.reply(data, "There are no active reminders.")
return 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): def _show_help(self, data):
"""Reply to the user with help for all major subcommands.""" """Reply to the user with help for all major subcommands."""
@@ -245,10 +275,10 @@ class Remind(Command):
("Cancel", "!remind cancel [id]"), ("Cancel", "!remind cancel [id]"),
("Adjust", "!remind adjust [id] [time]"), ("Adjust", "!remind adjust [id] [time]"),
("Restart", "!snooze [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) self.reply(data, joined + " " + extra)


def _dispatch_command(self, data, command, args): def _dispatch_command(self, data, command, args):
@@ -259,7 +289,9 @@ class Remind(Command):
try: try:
reminder = self._get_reminder_by_id(user, args[0]) reminder = self._get_reminder_by_id(user, args[0])
except IndexError: 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])) self.reply(data, msg.format(user, args[0]))
return return
args.pop(0) args.pop(0)
@@ -292,7 +324,7 @@ class Remind(Command):
elif command in SNOOZE: elif command in SNOOZE:
self._snooze_reminder(data, reminder, args[0] if args else None) self._snooze_reminder(data, reminder, args[0] if args else None)
else: 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)) self.reply(data, msg.format(command, reminder.id))


def _process(self, data): def _process(self, data):
@@ -317,8 +349,7 @@ class Remind(Command):
return self._create_reminder(data) return self._create_reminder(data)
if len(data.args) == 1: if len(data.args) == 1:
return self._dispatch_command(data, "display", data.args) 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 @property
def lock(self): def lock(self):
@@ -431,6 +462,7 @@ class _ReminderThread:


class _Reminder: class _Reminder:
"""Represents a single reminder.""" """Represents a single reminder."""

def __init__(self, rid, user, wait, message, data, cmdobj, end=None): def __init__(self, rid, user, wait, message, data, cmdobj, end=None):
self.id = rid self.id = rid
self.wait = wait self.wait = wait
@@ -476,7 +508,7 @@ class _Reminder:
"""Return a string representing the end time of a reminder.""" """Return a string representing the end time of a reminder."""
if self._expired or self.end < time.time(): if self._expired or self.end < time.time():
return "expired" return "expired"
return "ends {0}".format(_format_time(self.end))
return f"ends {_format_time(self.end)}"


@property @property
def expired(self): 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> # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -23,8 +21,10 @@
from earwigbot import exceptions from earwigbot import exceptions
from earwigbot.commands import Command from earwigbot.commands import Command



class Rights(Command): class Rights(Command):
"""Retrieve a list of rights for a given username.""" """Retrieve a list of rights for a given username."""

name = "rights" name = "rights"
commands = ["rights", "groups", "permissions", "privileges"] commands = ["rights", "groups", "permissions", "privileges"]


@@ -32,7 +32,7 @@ class Rights(Command):
if not data.args: if not data.args:
name = data.nick name = data.nick
else: else:
name = ' '.join(data.args)
name = " ".join(data.args)


site = self.bot.wiki.get_site() site = self.bot.wiki.get_site()
user = site.get_user(name) user = site.get_user(name)
@@ -40,7 +40,7 @@ class Rights(Command):
try: try:
rights = user.groups rights = user.groups
except exceptions.UserNotFoundError: 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)) self.reply(data, msg.format(name))
return return


@@ -48,5 +48,5 @@ class Rights(Command):
rights.remove("*") # Remove the '*' group given to everyone rights.remove("*") # Remove the '*' group given to everyone
except ValueError: except ValueError:
pass 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> # Copyright (C) 2009-2021 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # 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 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE. # SOFTWARE.


from ast import literal_eval
import re import re
from ast import literal_eval


from earwigbot.commands import Command from earwigbot.commands import Command
from earwigbot.irc import RC from earwigbot.irc import RC



class Stalk(Command): class Stalk(Command):
"""Stalk a particular user (!stalk/!unstalk) or page (!watch/!unwatch) for """Stalk a particular user (!stalk/!unstalk) or page (!watch/!unwatch) for
edits. Prefix regular expressions with "re:" (uses re.match).""" edits. Prefix regular expressions with "re:" (uses re.match)."""

name = "stalk" 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"] hooks = ["msg", "rc"]
MAX_STALKS_PER_USER = 5 MAX_STALKS_PER_USER = 5


@@ -58,20 +68,29 @@ class Stalk(Command):
if data.is_admin: if data.is_admin:
self.reply(data, self._all_stalks()) self.reply(data, self._all_stalks())
else: 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 return


if data.command.endswith("all"): if data.command.endswith("all"):
if not data.is_admin: 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 return
if not data.args: 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 return


if not data.args or data.command in ["stalks", "watches"]: if not data.args or data.command in ["stalks", "watches"]:
@@ -98,9 +117,12 @@ class Stalk(Command):
if data.is_private: if data.is_private:
stalkinfo = (data.nick, None, modifiers) stalkinfo = (data.nick, None, modifiers)
elif not data.is_admin: 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 return
else: else:
stalkinfo = (data.nick, data.chan, modifiers) stalkinfo = (data.nick, data.chan, modifiers)
@@ -120,6 +142,7 @@ class Stalk(Command):


def _process_rc(self, rc): def _process_rc(self, rc):
"""Process a watcher event.""" """Process a watcher event."""

def _update_chans(items, flags): def _update_chans(items, flags):
for item in items: for item in items:
modifiers = item[2] if len(item) > 2 else {} modifiers = item[2] if len(item) > 2 else {}
@@ -167,7 +190,7 @@ class Stalk(Command):
pretty = rc.prettify(color=chan not in nocolor) pretty = rc.prettify(color=chan not in nocolor)
if users: if users:
nicks = ", ".join(sorted(users)) nicks = ", ".join(sorted(users))
msg = "\x02{0}\x0F: {1}".format(nicks, pretty)
msg = f"\x02{nicks}\x0f: {pretty}"
else: else:
msg = pretty msg = pretty
if len(msg) > 400: if len(msg) > 400:
@@ -199,8 +222,10 @@ class Stalk(Command):
if not data.is_admin: if not data.is_admin:
nstalks = len(self._get_stalks_by_nick(data.nick, table)) nstalks = len(self._get_stalks_by_nick(data.nick, table))
if nstalks >= self.MAX_STALKS_PER_USER: 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)) self.reply(data, msg.format(verb, nstalks, stalktype))
return return
if stalkinfo[1] and not stalkinfo[1].startswith("##"): if stalkinfo[1] and not stalkinfo[1].startswith("##"):
@@ -218,7 +243,7 @@ class Stalk(Command):
else: else:
table[target] = [stalkinfo] 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.reply(data, msg.format(verb, stalktype, target))
self._save_stalks() self._save_stalks()


@@ -240,11 +265,15 @@ class Stalk(Command):
to_remove.append(info) to_remove.append(info)


if not to_remove: 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: 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)) self.reply(data, msg.format(verb, stalktype, plural, target))
return return


@@ -252,7 +281,7 @@ class Stalk(Command):
table[target].remove(info) table[target].remove(info)
if not table[target]: if not table[target]:
del 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.reply(data, msg.format(verb, stalktype, target))
self._save_stalks() self._save_stalks()


@@ -270,53 +299,63 @@ class Stalk(Command):
try: try:
del table[target] del table[target]
except KeyError: 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)) self.reply(data, msg.format(verb, stalktype, plural))
else: 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.reply(data, msg.format(verb, stalktype, target))
self._save_stalks() self._save_stalks()


def _current_stalks(self, nick): def _current_stalks(self, nick):
"""Return the given user's current stalks.""" """Return the given user's current stalks."""

def _format_chans(chans): def _format_chans(chans):
if None in chans: if None in chans:
chans.remove(None) chans.remove(None)
if not chans: if not chans:
return "privately" return "privately"
if len(chans) == 1: 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) + ", and privately"
return "in " + ", ".join(chans) return "in " + ", ".join(chans)


def _format_stalks(stalks): def _format_stalks(stalks):
return ", ".join( 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) users = self._get_stalks_by_nick(nick, self._users)
pages = self._get_stalks_by_nick(nick, self._pages) pages = self._get_stalks_by_nick(nick, self._pages)
if users: if users:
uinfo = " Users: {0}.".format(_format_stalks(users))
uinfo = f" Users: {_format_stalks(users)}."
if pages: 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}" 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): def _all_stalks(self):
"""Return all existing stalks, for bot admins.""" """Return all existing stalks, for bot admins."""

def _format_info(info): def _format_info(info):
if info[1]: if info[1]:
result = "for {0} in {1}".format(info[0], info[1])
result = f"for {info[0]} in {info[1]}"
else: else:
result = "for {0} privately".format(info[0])
result = f"for {info[0]} privately"
modifiers = ", ".join(info[2]) if len(info) > 2 else "" modifiers = ", ".join(info[2]) if len(info) > 2 else ""
if modifiers: if modifiers:
result += " ({0})".format(modifiers)
result += f" ({modifiers})"
return result return result


def _format_data(data): def _format_data(data):
@@ -324,19 +363,25 @@ class Stalk(Command):


def _format_stalks(stalks): def _format_stalks(stalks):
return ", ".join( 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 users, pages = self._users, self._pages
if users: if users:
uinfo = " Users: {0}.".format(_format_stalks(users))
uinfo = f" Users: {_format_stalks(users)}."
if pages: 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}" 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): def _load_stalks(self):
"""Load saved stalks from the database.""" """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> # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -24,14 +22,16 @@ import random


from earwigbot.commands import Command from earwigbot.commands import Command



class Test(Command): class Test(Command):
"""Test the bot!""" """Test the bot!"""

name = "test" name = "test"


def process(self, data): 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) hey = random.randint(0, 1)
if hey: if hey:
self.say(data.chan, "Hey {0}!".format(user))
self.say(data.chan, f"Hey {user}!")
else: 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> # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # 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 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE. # SOFTWARE.


import threading
import re import re
import threading


from earwigbot.commands import Command from earwigbot.commands import Command



class Threads(Command): class Threads(Command):
"""Manage wiki tasks from IRC, and check on thread status.""" """Manage wiki tasks from IRC, and check on thread status."""

name = "threads" name = "threads"
commands = ["tasks", "task", "threads", "tasklist"] commands = ["tasks", "task", "threads", "tasklist"]


@@ -55,7 +55,7 @@ class Threads(Command):
self.do_listall() self.do_listall()


else: # They asked us to do something we don't know 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) self.reply(data, msg)


def do_list(self): def do_list(self):
@@ -71,34 +71,38 @@ class Threads(Command):
tname = thread.name tname = thread.name
ident = thread.ident % 10000 ident = thread.ident % 10000
if tname == "MainThread": if tname == "MainThread":
t = "\x0302main\x0F (id {0})"
t = "\x0302main\x0f (id {0})"
normal_threads.append(t.format(ident)) normal_threads.append(t.format(ident))
elif tname in self.config.components: 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)) normal_threads.append(t.format(tname, ident))
elif tname.startswith("cvworker-"): 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: else:
match = re.findall(r"^(.*?) \((.*?)\)$", tname) match = re.findall(r"^(.*?) \((.*?)\)$", tname)
if match: 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]) thread_info = t.format(match[0][0], ident, match[0][1])
daemon_threads.append(thread_info) daemon_threads.append(thread_info)
else: else:
t = "\x0302{0}\x0F (id {1})"
t = "\x0302{0}\x0f (id {1})"
daemon_threads.append(t.format(tname, ident)) daemon_threads.append(t.format(tname, ident))


if daemon_threads: if daemon_threads:
if len(daemon_threads) > 1: 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: 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: 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) self.reply(self.data, msg)


@@ -111,17 +115,17 @@ class Threads(Command):
threadlist = [t for t in threads if t.name.startswith(task)] threadlist = [t for t in threads if t.name.startswith(task)]
ids = [str(t.ident) for t in threadlist] ids = [str(t.ident) for t in threadlist]
if not ids: if not ids:
tasklist.append("\x0302{0}\x0F (idle)".format(task))
tasklist.append(f"\x0302{task}\x0f (idle)")
elif len(ids) == 1: 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])) tasklist.append(t.format(task, ids[0]))
else: 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) 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) self.reply(self.data, msg)


def do_start(self): def do_start(self):
@@ -143,8 +147,9 @@ class Threads(Command):


data.kwargs["fromIRC"] = True data.kwargs["fromIRC"] = True
data.kwargs["_IRCCallback"] = lambda: self.reply( 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) 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) 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> # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # 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") pytz = importer.new("pytz")



class Time(Command): class Time(Command):
"""Report the current time in any timezone (UTC default), UNIX epoch time, """Report the current time in any timezone (UTC default), UNIX epoch time,
or beat time.""" or beat time."""

name = "time" name = "time"
commands = ["time", "beats", "swatch", "epoch", "date"] commands = ["time", "beats", "swatch", "epoch", "date"]


@@ -54,7 +54,7 @@ class Time(Command):
def do_beats(self, data): def do_beats(self, data):
beats = ((time() + 3600) % 86400) / 86.4 beats = ((time() + 3600) % 86400) / 86.4
beats = int(floor(beats)) 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): def do_time(self, data, timezone):
try: try:
@@ -64,7 +64,7 @@ class Time(Command):
self.reply(data, msg) self.reply(data, msg)
return return
except pytz.exceptions.UnknownTimeZoneError: except pytz.exceptions.UnknownTimeZoneError:
self.reply(data, "Unknown timezone: {0}.".format(timezone))
self.reply(data, f"Unknown timezone: {timezone}.")
return return
now = pytz.utc.localize(datetime.utcnow()).astimezone(tzinfo) now = pytz.utc.localize(datetime.utcnow()).astimezone(tzinfo)
self.reply(data, now.strftime("%Y-%m-%d %H:%M:%S %Z")) 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> # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # 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 from earwigbot.commands import Command



class Trout(Command): class Trout(Command):
"""Slap someone with a trout, or related fish.""" """Slap someone with a trout, or related fish."""

name = "trout" name = "trout"
commands = ["trout", "whale"] commands = ["trout", "whale"]


@@ -44,5 +44,5 @@ class Trout(Command):
if normal in self.exceptions: if normal in self.exceptions:
self.reply(data, self.exceptions[normal]) self.reply(data, self.exceptions[normal])
else: 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)) 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> # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -22,8 +20,10 @@


from earwigbot.commands import Command from earwigbot.commands import Command



class Watchers(Command): class Watchers(Command):
"""Get the number of users watching a given page.""" """Get the number of users watching a given page."""

name = "watchers" name = "watchers"


def process(self, data): def process(self, data):
@@ -33,13 +33,14 @@ class Watchers(Command):
return return


site = self.bot.wiki.get_site() 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] page = list(query["query"]["pages"].values())[0]
title = page["title"].encode("utf8") title = page["title"].encode("utf8")


if "invalid" in page: 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)) self.reply(data, msg.format(title))
return return


@@ -48,5 +49,5 @@ class Watchers(Command):
else: else:
watchers = "<30" watchers = "<30"
plural = "" if watchers == 1 else "s" 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)) 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> # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -21,12 +19,12 @@
# SOFTWARE. # SOFTWARE.


import base64 import base64
from collections import OrderedDict
from getpass import getpass
import logging import logging
import logging.handlers import logging.handlers
from os import mkdir, path
import stat import stat
from collections import OrderedDict
from getpass import getpass
from os import mkdir, path


import yaml import yaml


@@ -44,6 +42,7 @@ pbkdf2 = importer.new("cryptography.hazmat.primitives.kdf.pbkdf2")


__all__ = ["BotConfig"] __all__ = ["BotConfig"]



class BotConfig: class BotConfig:
""" """
**EarwigBot: YAML Config File Manager** **EarwigBot: YAML Config File Manager**
@@ -89,8 +88,14 @@ class BotConfig:
self._tasks = ConfigNode() self._tasks = ConfigNode()
self._metadata = 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._decryptable_nodes = [ # Default nodes to decrypt
(self._wiki, ("password",)), (self._wiki, ("password",)),
@@ -107,7 +112,7 @@ class BotConfig:


def __str__(self): def __str__(self):
"""Return a nice string representation of the BotConfig.""" """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): def _handle_missing_config(self):
print("Config file missing or empty:", self._config_path) print("Config file missing or empty:", self._config_path)
@@ -124,11 +129,11 @@ class BotConfig:
def _load(self): def _load(self):
"""Load data from our JSON config file (config.yml) into self._data.""" """Load data from our JSON config file (config.yml) into self._data."""
filename = self._config_path filename = self._config_path
with open(filename, 'r') as fp:
with open(filename) as fp:
try: try:
self._data = yaml.load(fp, OrderedLoader) self._data = yaml.load(fp, OrderedLoader)
except yaml.YAMLError: except yaml.YAMLError:
print("Error parsing config file {0}:".format(filename))
print(f"Error parsing config file {filename}:")
raise raise


def _setup_logging(self): def _setup_logging(self):
@@ -142,11 +147,13 @@ class BotConfig:


if self.metadata.get("enableLogging"): if self.metadata.get("enableLogging"):
hand = logging.handlers.TimedRotatingFileHandler 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.isdir(log_dir):
if not path.exists(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: else:
msg = "log_dir ({0}) exists but is not a directory!" msg = "log_dir ({0}) exists but is not a directory!"
print(msg.format(log_dir)) print(msg.format(log_dir))
@@ -296,7 +303,8 @@ class BotConfig:
raise NoConfigError(e) raise NoConfigError(e)
key = getpass("Enter key to decrypt bot passwords: ") key = getpass("Enter key to decrypt bot passwords: ")
self._decryption_cipher = fernet.Fernet( 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: for node, nodes in self._decryptable_nodes:
self._decrypt(node, nodes) self._decrypt(node, nodes)


@@ -336,8 +344,13 @@ class BotConfig:
# or just the task_name: # or just the task_name:
tasks = [] 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", []) data = self._data.get("schedule", [])
for event in data: 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> # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -24,12 +22,13 @@ import logging


__all__ = ["BotFormatter"] __all__ = ["BotFormatter"]



class BotFormatter(logging.Formatter): class BotFormatter(logging.Formatter):
def __init__(self, color=False): def __init__(self, color=False):
self._format = super().format self._format = super().format
if color: if color:
fmt = "[%(asctime)s %(lvl)s] %(name)s: %(message)s" 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: else:
fmt = "[%(asctime)s %(levelname)-8s] %(name)s: %(message)s" fmt = "[%(asctime)s %(levelname)-8s] %(name)s: %(message)s"
self.format = self._format self.format = self._format
@@ -37,15 +36,15 @@ class BotFormatter(logging.Formatter):
super().__init__(fmt=fmt, datefmt=datefmt) super().__init__(fmt=fmt, datefmt=datefmt)


def format_color(self, record): def format_color(self, record):
l = record.levelname.ljust(8)
lvl = record.levelname.ljust(8)
if record.levelno == logging.DEBUG: 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: 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: 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: 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: 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 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> # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -25,6 +23,7 @@ from collections import OrderedDict


__all__ = ["ConfigNode"] __all__ = ["ConfigNode"]



class ConfigNode: class ConfigNode:
def __init__(self): def __init__(self):
self._data = OrderedDict() self._data = OrderedDict()
@@ -56,8 +55,7 @@ class ConfigNode:
self._data[key] = item self._data[key] = item


def __iter__(self): def __iter__(self):
for key in self._data:
yield key
yield from self._data


def __contains__(self, item): def __contains__(self, item):
return item in self._data 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> # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -35,6 +33,7 @@ import yaml


__all__ = ["OrderedLoader", "OrderedDumper"] __all__ = ["OrderedLoader", "OrderedDumper"]



class OrderedLoader(yaml.Loader): class OrderedLoader(yaml.Loader):
"""A YAML loader that loads mappings into ordered dictionaries.""" """A YAML loader that loads mappings into ordered dictionaries."""


@@ -54,9 +53,12 @@ class OrderedLoader(yaml.Loader):
if isinstance(node, yaml.MappingNode): if isinstance(node, yaml.MappingNode):
self.flatten_mapping(node) self.flatten_mapping(node)
else: 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() mapping = OrderedDict()
for key_node, value_node in node.value: for key_node, value_node in node.value:
@@ -65,9 +67,11 @@ class OrderedLoader(yaml.Loader):
hash(key) hash(key)
except TypeError as exc: except TypeError as exc:
raise yaml.constructor.ConstructorError( 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) value = self.construct_object(value_node, deep=deep)
mapping[key] = value mapping[key] = value
return mapping return mapping
@@ -91,11 +95,9 @@ class OrderedDumper(yaml.SafeDumper):
for item_key, item_value in mapping: for item_key, item_value in mapping:
node_key = self.represent_data(item_key) node_key = self.represent_data(item_key)
node_value = self.represent_data(item_value) 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 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 best_style = False
value.append((node_key, node_value)) value.append((node_key, node_value))
if flow_style is None: 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> # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # 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 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE. # SOFTWARE.


from fnmatch import fnmatch
import sqlite3 as sqlite import sqlite3 as sqlite
from fnmatch import fnmatch
from threading import Lock from threading import Lock


__all__ = ["PermissionsDB"] __all__ = ["PermissionsDB"]



class PermissionsDB: class PermissionsDB:
""" """
**EarwigBot: Permissions Database Manager** **EarwigBot: Permissions Database Manager**
@@ -33,6 +32,7 @@ class PermissionsDB:
Controls the :file:`permissions.db` file, which stores the bot's owners and Controls the :file:`permissions.db` file, which stores the bot's owners and
admins for the purposes of using certain dangerous IRC commands. admins for the purposes of using certain dangerous IRC commands.
""" """

ADMIN = 1 ADMIN = 1
OWNER = 2 OWNER = 2


@@ -49,7 +49,7 @@ class PermissionsDB:


def __str__(self): def __str__(self):
"""Return a nice string representation of the PermissionsDB.""" """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): def _create(self, conn):
"""Initialize the permissions database with its necessary tables.""" """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: with self._db_access_lock, sqlite.connect(self._dbfile) as conn:
conn.execute(query, (user, key)) conn.execute(query, (user, key))



class User: class User:
"""A class that represents an IRC user for the purpose of testing rules.""" """A class that represents an IRC user for the purpose of testing rules."""

def __init__(self, nick, ident, host): def __init__(self, nick, ident, host):
self.nick = nick self.nick = nick
self.ident = ident self.ident = ident
@@ -212,7 +214,7 @@ class User:


def __str__(self): def __str__(self):
"""Return a nice string representation of the User.""" """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): def __contains__(self, user):
if fnmatch(user.nick, self.nick): 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> # Copyright (C) 2009-2016 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -21,13 +19,13 @@
# SOFTWARE. # SOFTWARE.


import base64 import base64
from collections import OrderedDict
from getpass import getpass
import os import os
from os import chmod, makedirs, mkdir, path
import re import re
import stat import stat
import sys import sys
from collections import OrderedDict
from getpass import getpass
from os import chmod, makedirs, mkdir, path
from textwrap import fill, wrap from textwrap import fill, wrap


import yaml import yaml
@@ -50,23 +48,27 @@ def process(bot, rc):
pass pass
""" """



class ConfigScript: class ConfigScript:
"""A script to guide a user through the creation of a new config file.""" """A script to guide a user through the creation of a new config file."""

WIDTH = 79 WIDTH = 79
PROMPT = "\x1b[32m> \x1b[0m" PROMPT = "\x1b[32m> \x1b[0m"
PBKDF_ROUNDS = 100000 PBKDF_ROUNDS = 100000


def __init__(self, config): def __init__(self, config):
self.config = 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._cipher = None
self._wmf = False self._wmf = False
@@ -86,7 +88,7 @@ class ConfigScript:
def _ask(self, text, default=None, require=True): def _ask(self, text, default=None, require=True):
text = self.PROMPT + text text = self.PROMPT + text
if default: 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) lines = wrap(re.sub(r"\s\s+", " ", text), self.WIDTH)
if len(lines) > 1: if len(lines) > 1:
print("\n".join(lines[:-1])) print("\n".join(lines[:-1]))
@@ -157,7 +159,9 @@ class ConfigScript:
salt=salt, salt=salt,
iterations=self.PBKDF_ROUNDS, 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: except ImportError:
print(" error!") print(" error!")
self._print("""Encryption requires the 'cryptography' package: self._print("""Encryption requires the 'cryptography' package:
@@ -198,7 +202,7 @@ class ConfigScript:
front-end.""") front-end.""")
frontend = self._ask_bool("Enable the IRC front-end?") frontend = self._ask_bool("Enable the IRC front-end?")
watcher = self._ask_bool("Enable the IRC watcher?") 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_frontend"] = frontend
self.data["components"]["irc_watcher"] = watcher self.data["components"]["irc_watcher"] = watcher
self.data["components"]["wiki_scheduler"] = scheduler self.data["components"]["wiki_scheduler"] = scheduler
@@ -269,7 +273,9 @@ class ConfigScript:
self.data["wiki"]["username"] = self._ask("Bot username:") self.data["wiki"]["username"] = self._ask("Bot username:")
password = self._ask_pass("Bot password:", encrypt=False) password = self._ask_pass("Bot password:", encrypt=False)
self.data["wiki"]["password"] = password 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"]["summary"] = "([[WP:BOT|Bot]]) $2"
self.data["wiki"]["useHTTPS"] = True self.data["wiki"]["useHTTPS"] = True
self.data["wiki"]["assert"] = "user" self.data["wiki"]["assert"] = "user"
@@ -282,15 +288,17 @@ class ConfigScript:
msg = "Will this bot run from the Wikimedia Tool Labs?" msg = "Will this bot run from the Wikimedia Tool Labs?"
labs = self._ask_bool(msg, default=False) labs = self._ask_bool(msg, default=False)
if labs: 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) self.data["wiki"]["sql"] = OrderedDict(args)
else: else:
msg = "Will this bot run from the Wikimedia Toolserver?" msg = "Will this bot run from the Wikimedia Toolserver?"
toolserver = self._ask_bool(msg, default=False) toolserver = self._ask_bool(msg, default=False)
if toolserver: 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"]["sql"] = OrderedDict(args)


self.data["wiki"]["shutoff"] = {} self.data["wiki"]["shutoff"] = {}
@@ -314,11 +322,13 @@ class ConfigScript:
print() print()
frontend = self.data["irc"]["frontend"] = OrderedDict() frontend = self.data["irc"]["frontend"] = OrderedDict()
frontend["host"] = self._ask( 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["port"] = self._ask("Frontend port:", 6667)
frontend["nick"] = self._ask("Frontend bot's nickname:") frontend["nick"] = self._ask("Frontend bot's nickname:")
frontend["ident"] = self._ask( 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):" question = "Frontend bot's real name (gecos):"
frontend["realname"] = self._ask(question, "EarwigBot") frontend["realname"] = self._ask(question, "EarwigBot")
if self._ask_bool("Should the bot identify to NickServ?"): if self._ask_bool("Should the bot identify to NickServ?"):
@@ -370,7 +380,7 @@ class ConfigScript:
watcher["nickservUsername"] = ns_user watcher["nickservUsername"] = ns_user
watcher["nickservPassword"] = ns_pass watcher["nickservPassword"] = ns_pass
if self._wmf: if self._wmf:
chan = "#{0}.{1}".format(self._lang, self._proj)
chan = f"#{self._lang}.{self._proj}"
watcher["channels"] = [chan] watcher["channels"] = [chan]
else: else:
chan_question = "Watcher channels to join by default:" chan_question = "Watcher channels to join by default:"
@@ -387,14 +397,17 @@ class ConfigScript:
fp.write(RULES_TEMPLATE) fp.write(RULES_TEMPLATE)
self._pause() 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): def _set_commands(self):
print() print()
msg = """Would you like to disable the default IRC commands? You can msg = """Would you like to disable the default IRC commands? You can
fine-tune which commands are disabled later on.""" 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 self.data["commands"]["disable"] = True
print() print()
self._print("""I am now creating the 'commands/' directory, where you self._print("""I am now creating the 'commands/' directory, where you
@@ -435,8 +448,14 @@ class ConfigScript:


def _save(self): def _save(self):
with open(self.config.path, "w") as stream: 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): def make_new(self):
"""Make a new config file based on the user's input.""" """Make a new config file based on the user's input."""
@@ -447,8 +466,8 @@ class ConfigScript:
raise raise
try: try:
open(self.config.path, "w").close() 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:") print("I can't seem to write to the config file:")
raise raise
self._set_metadata() 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> # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # 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 +-- ParserExclusionError
""" """



class EarwigBotError(Exception): class EarwigBotError(Exception):
"""Base exception class for errors in EarwigBot.""" """Base exception class for errors in EarwigBot."""



class NoConfigError(EarwigBotError): class NoConfigError(EarwigBotError):
"""The bot cannot be run without a config file. """The bot cannot be run without a config file.


@@ -65,9 +65,11 @@ class NoConfigError(EarwigBotError):
one to be created. one to be created.
""" """



class IRCError(EarwigBotError): class IRCError(EarwigBotError):
"""Base exception class for errors in IRC-relation sections of the bot.""" """Base exception class for errors in IRC-relation sections of the bot."""



class BrokenSocketError(IRCError): class BrokenSocketError(IRCError):
"""A socket has broken, because it is not sending data. """A socket has broken, because it is not sending data.


@@ -75,15 +77,18 @@ class BrokenSocketError(IRCError):
<earwigbot.irc.connection.IRCConnection._get>`. <earwigbot.irc.connection.IRCConnection._get>`.
""" """



class WikiToolsetError(EarwigBotError): class WikiToolsetError(EarwigBotError):
"""Base exception class for errors in the Wiki Toolset.""" """Base exception class for errors in the Wiki Toolset."""



class SiteNotFoundError(WikiToolsetError): class SiteNotFoundError(WikiToolsetError):
"""A particular site could not be found in the sites database. """A particular site could not be found in the sites database.


Raised by :py:class:`~earwigbot.wiki.sitesdb.SitesDB`. Raised by :py:class:`~earwigbot.wiki.sitesdb.SitesDB`.
""" """



class ServiceError(WikiToolsetError): class ServiceError(WikiToolsetError):
"""Base exception class for an error within a service (the API or SQL). """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. non-functional so another, less-preferred one can be tried.
""" """



class APIError(ServiceError): class APIError(ServiceError):
"""Couldn't connect to a site's API. """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>`. Raised by :py:meth:`Site.api_query <earwigbot.wiki.site.Site.api_query>`.
""" """



class SQLError(ServiceError): class SQLError(ServiceError):
"""Some error involving SQL querying occurred. """Some error involving SQL querying occurred.


Raised by :py:meth:`Site.sql_query <earwigbot.wiki.site.Site.sql_query>`. Raised by :py:meth:`Site.sql_query <earwigbot.wiki.site.Site.sql_query>`.
""" """



class NoServiceError(WikiToolsetError): class NoServiceError(WikiToolsetError):
"""No service is functioning to handle a specific task. """No service is functioning to handle a specific task.


Raised by :py:meth:`Site.delegate <earwigbot.wiki.site.Site.delegate>`. Raised by :py:meth:`Site.delegate <earwigbot.wiki.site.Site.delegate>`.
""" """



class LoginError(WikiToolsetError): class LoginError(WikiToolsetError):
"""An error occured while trying to login. """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>`. Raised by :py:meth:`Site._login <earwigbot.wiki.site.Site._login>`.
""" """



class PermissionsError(WikiToolsetError): class PermissionsError(WikiToolsetError):
"""A permissions error ocurred. """A permissions error ocurred.


@@ -134,6 +144,7 @@ class PermissionsError(WikiToolsetError):
other API methods depending on settings. other API methods depending on settings.
""" """



class NamespaceNotFoundError(WikiToolsetError): class NamespaceNotFoundError(WikiToolsetError):
"""A requested namespace name or namespace ID does not exist. """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>`. <earwigbot.wiki.site.Site.namespace_name_to_id>`.
""" """



class PageNotFoundError(WikiToolsetError): class PageNotFoundError(WikiToolsetError):
"""Attempted to get information about a page that does not exist. """Attempted to get information about a page that does not exist.


Raised by :py:class:`~earwigbot.wiki.page.Page`. Raised by :py:class:`~earwigbot.wiki.page.Page`.
""" """



class InvalidPageError(WikiToolsetError): class InvalidPageError(WikiToolsetError):
"""Attempted to get information about a page whose title is invalid. """Attempted to get information about a page whose title is invalid.


Raised by :py:class:`~earwigbot.wiki.page.Page`. Raised by :py:class:`~earwigbot.wiki.page.Page`.
""" """



class RedirectError(WikiToolsetError): class RedirectError(WikiToolsetError):
"""A redirect-only method was called on a malformed or non-redirect page. """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>`. <earwigbot.wiki.page.Page.get_redirect_target>`.
""" """



class UserNotFoundError(WikiToolsetError): class UserNotFoundError(WikiToolsetError):
"""Attempted to get certain information about a user that does not exist. """Attempted to get certain information about a user that does not exist.


Raised by :py:class:`~earwigbot.wiki.user.User`. Raised by :py:class:`~earwigbot.wiki.user.User`.
""" """



class EditError(WikiToolsetError): class EditError(WikiToolsetError):
"""An error occured while editing. """An error occured while editing.


@@ -178,6 +194,7 @@ class EditError(WikiToolsetError):
:py:meth:`Page.add_section <earwigbot.wiki.page.Page.add_section>`. :py:meth:`Page.add_section <earwigbot.wiki.page.Page.add_section>`.
""" """



class EditConflictError(EditError): class EditConflictError(EditError):
"""We gotten an edit conflict or a (rarer) delete/recreate conflict. """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>`. :py:meth:`Page.add_section <earwigbot.wiki.page.Page.add_section>`.
""" """



class NoContentError(EditError): class NoContentError(EditError):
"""We tried to create a page or new section with no content. """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>`. :py:meth:`Page.add_section <earwigbot.wiki.page.Page.add_section>`.
""" """



class ContentTooBigError(EditError): class ContentTooBigError(EditError):
"""The edit we tried to push exceeded the article size limit. """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>`. :py:meth:`Page.add_section <earwigbot.wiki.page.Page.add_section>`.
""" """



class SpamDetectedError(EditError): class SpamDetectedError(EditError):
"""The spam filter refused our edit. """The spam filter refused our edit.


@@ -206,6 +226,7 @@ class SpamDetectedError(EditError):
:py:meth:`Page.add_section <earwigbot.wiki.page.Page.add_section>`. :py:meth:`Page.add_section <earwigbot.wiki.page.Page.add_section>`.
""" """



class FilteredError(EditError): class FilteredError(EditError):
"""The edit filter refused our edit. """The edit filter refused our edit.


@@ -213,6 +234,7 @@ class FilteredError(EditError):
:py:meth:`Page.add_section <earwigbot.wiki.page.Page.add_section>`. :py:meth:`Page.add_section <earwigbot.wiki.page.Page.add_section>`.
""" """



class CopyvioCheckError(WikiToolsetError): class CopyvioCheckError(WikiToolsetError):
"""An error occured when checking a page for copyright violations. """An error occured when checking a page for copyright violations.


@@ -225,6 +247,7 @@ class CopyvioCheckError(WikiToolsetError):
<earwigbot.wiki.copyvios.CopyvioMixIn.copyvio_compare>`. <earwigbot.wiki.copyvios.CopyvioMixIn.copyvio_compare>`.
""" """



class UnknownSearchEngineError(CopyvioCheckError): class UnknownSearchEngineError(CopyvioCheckError):
"""Attempted to do a copyvio check with an unknown search engine. """Attempted to do a copyvio check with an unknown search engine.


@@ -235,6 +258,7 @@ class UnknownSearchEngineError(CopyvioCheckError):
<earwigbot.wiki.copyvios.CopyvioMixIn.copyvio_check>`. <earwigbot.wiki.copyvios.CopyvioMixIn.copyvio_check>`.
""" """



class UnsupportedSearchEngineError(CopyvioCheckError): class UnsupportedSearchEngineError(CopyvioCheckError):
"""Attmpted to do a copyvio check using an unavailable engine. """Attmpted to do a copyvio check using an unavailable engine.


@@ -245,6 +269,7 @@ class UnsupportedSearchEngineError(CopyvioCheckError):
<earwigbot.wiki.copyvios.CopyvioMixIn.copyvio_check>`. <earwigbot.wiki.copyvios.CopyvioMixIn.copyvio_check>`.
""" """



class SearchQueryError(CopyvioCheckError): class SearchQueryError(CopyvioCheckError):
"""Some error ocurred while doing a search query. """Some error ocurred while doing a search query.


@@ -252,6 +277,7 @@ class SearchQueryError(CopyvioCheckError):
<earwigbot.wiki.copyvios.CopyvioMixIn.copyvio_check>`. <earwigbot.wiki.copyvios.CopyvioMixIn.copyvio_check>`.
""" """



class ParserExclusionError(CopyvioCheckError): class ParserExclusionError(CopyvioCheckError):
"""A content parser detected that the given source should be excluded. """A content parser detected that the given source should be excluded.


@@ -260,6 +286,7 @@ class ParserExclusionError(CopyvioCheckError):
exposed in client code. exposed in client code.
""" """



class ParserRedirectError(CopyvioCheckError): class ParserRedirectError(CopyvioCheckError):
"""A content parser detected that a redirect should be followed. """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 <earwigbot.wiki.copyvios.CopyvioMixIn.copyvio_check>`; should not be
exposed in client code. exposed in client code.
""" """

def __init__(self, url): def __init__(self, url):
super().__init__() super().__init__()
self.url = url 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> # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # 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> # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -28,6 +26,7 @@ from earwigbot.exceptions import BrokenSocketError


__all__ = ["IRCConnection"] __all__ = ["IRCConnection"]



class IRCConnection: class IRCConnection:
"""Interface with an IRC server.""" """Interface with an IRC server."""


@@ -50,8 +49,7 @@ class IRCConnection:
def __repr__(self): def __repr__(self):
"""Return the canonical string representation of the IRCConnection.""" """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})" 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): def __str__(self):
"""Return a nice string representation of the IRCConnection.""" """Return a nice string representation of the IRCConnection."""
@@ -63,18 +61,18 @@ class IRCConnection:
self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try: try:
self._sock.connect((self.host, self.port)) self._sock.connect((self.host, self.port))
except socket.error:
except OSError:
self.logger.exception("Couldn't connect to IRC server; retrying") self.logger.exception("Couldn't connect to IRC server; retrying")
sleep(8) sleep(8)
self._connect() 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): def _close(self):
"""Completely close our connection with the IRC server.""" """Completely close our connection with the IRC server."""
try: try:
self._sock.shutdown(socket.SHUT_RDWR) # Shut down connection first self._sock.shutdown(socket.SHUT_RDWR) # Shut down connection first
except socket.error:
except OSError:
pass # Ignore if the socket is already down pass # Ignore if the socket is already down
self._sock.close() self._sock.close()


@@ -94,7 +92,7 @@ class IRCConnection:
sleep(0.75 - time_since_last) sleep(0.75 - time_since_last)
try: try:
self._sock.sendall(msg.encode() + b"\r\n") self._sock.sendall(msg.encode() + b"\r\n")
except socket.error:
except OSError:
self._is_running = False self._is_running = False
else: else:
if not hidelog: if not hidelog:
@@ -131,7 +129,7 @@ class IRCConnection:
def _quit(self, msg=None): def _quit(self, msg=None):
"""Issue a quit message to the server. Doesn't close the connection.""" """Issue a quit message to the server. Doesn't close the connection."""
if msg: if msg:
self._send("QUIT :{0}".format(msg))
self._send(f"QUIT :{msg}")
else: else:
self._send("QUIT") self._send("QUIT")


@@ -142,11 +140,10 @@ class IRCConnection:
self.pong(line[1][1:]) self.pong(line[1][1:])
elif line[1] == "001": # Update nickname on startup elif line[1] == "001": # Update nickname on startup
if line[2] != self.nick: 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] self._nick = line[2]
elif line[1] == "376": # After sign-on, get our userhost 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 elif line[1] == "311": # Receiving WHOIS result
if line[2] == self.nick: if line[2] == self.nick:
self._ident = line[4] self._ident = line[4]
@@ -189,7 +186,7 @@ class IRCConnection:
def say(self, target, msg, hidelog=False): def say(self, target, msg, hidelog=False):
"""Send a private message to a target on the server.""" """Send a private message to a target on the server."""
for msg in self._split(msg, len(target) + 10): 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) self._send(msg, hidelog)


def reply(self, data, msg, hidelog=False): def reply(self, data, msg, hidelog=False):
@@ -197,45 +194,45 @@ class IRCConnection:
if data.is_private: if data.is_private:
self.say(data.chan, msg, hidelog) self.say(data.chan, msg, hidelog)
else: 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) self.say(data.chan, msg, hidelog)


def action(self, target, msg, hidelog=False): def action(self, target, msg, hidelog=False):
"""Send a private message to a target on the server as an action.""" """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) self.say(target, msg, hidelog)


def notice(self, target, msg, hidelog=False): def notice(self, target, msg, hidelog=False):
"""Send a notice to a target on the server.""" """Send a notice to a target on the server."""
for msg in self._split(msg, len(target) + 9): 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) self._send(msg, hidelog)


def join(self, chan, hidelog=False): def join(self, chan, hidelog=False):
"""Join a channel on the server.""" """Join a channel on the server."""
msg = "JOIN {0}".format(chan)
msg = f"JOIN {chan}"
self._send(msg, hidelog) self._send(msg, hidelog)


def part(self, chan, msg=None, hidelog=False): def part(self, chan, msg=None, hidelog=False):
"""Part from a channel on the server, optionally using an message.""" """Part from a channel on the server, optionally using an message."""
if msg: if msg:
self._send("PART {0} :{1}".format(chan, msg), hidelog)
self._send(f"PART {chan} :{msg}", hidelog)
else: else:
self._send("PART {0}".format(chan), hidelog)
self._send(f"PART {chan}", hidelog)


def mode(self, target, level, msg, hidelog=False): def mode(self, target, level, msg, hidelog=False):
"""Send a mode message to the server.""" """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) self._send(msg, hidelog)


def ping(self, target, hidelog=False): def ping(self, target, hidelog=False):
"""Ping another entity on the server.""" """Ping another entity on the server."""
msg = "PING {0}".format(target)
msg = f"PING {target}"
self._send(msg, hidelog) self._send(msg, hidelog)


def pong(self, target, hidelog=False): def pong(self, target, hidelog=False):
"""Pong another entity on the server.""" """Pong another entity on the server."""
msg = "PONG {0}".format(target)
msg = f"PONG {target}"
self._send(msg, hidelog) self._send(msg, hidelog)


def loop(self): 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> # Copyright (C) 2009-2016 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -24,6 +22,7 @@ import re


__all__ = ["Data"] __all__ = ["Data"]



class Data: class Data:
"""Store data from an individual line received on IRC.""" """Store data from an individual line received on IRC."""


@@ -46,7 +45,7 @@ class Data:


def __str__(self): def __str__(self):
"""Return a nice string representation of the Data.""" """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): def _parse(self):
"""Parse a line from IRC into its components as instance attributes.""" """Parse a line from IRC into its components as instance attributes."""
@@ -97,8 +96,7 @@ class Data:
self._is_command = True self._is_command = True
self._trigger = self.command[0] self._trigger = self.command[0]
self._command = self.command[1:] # Strip the "!" or "." 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" # e.g. "EarwigBot, command arg1 arg2"
self._is_command = True self._is_command = True
self._trigger = self.my_nick 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> # Copyright (C) 2009-2021 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -22,10 +20,11 @@


from time import sleep from time import sleep


from earwigbot.irc import IRCConnection, Data
from earwigbot.irc import Data, IRCConnection


__all__ = ["Frontend"] __all__ = ["Frontend"]



class Frontend(IRCConnection): class Frontend(IRCConnection):
""" """
**EarwigBot: IRC Frontend Component** **EarwigBot: IRC Frontend Component**
@@ -38,13 +37,20 @@ class Frontend(IRCConnection):
:py:mod:`earwigbot.commands` or the bot's custom command directory :py:mod:`earwigbot.commands` or the bot's custom command directory
(explained in the :doc:`documentation </customizing>`). (explained in the :doc:`documentation </customizing>`).
""" """

NICK_SERVICES = "NickServ" NICK_SERVICES = "NickServ"


def __init__(self, bot): def __init__(self, bot):
self.bot = bot self.bot = bot
cf = bot.config.irc["frontend"] 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._auth_wait = False
self._channels = set() self._channels = set()
@@ -53,8 +59,9 @@ class Frontend(IRCConnection):
def __repr__(self): def __repr__(self):
"""Return the canonical string representation of the Frontend.""" """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})" 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): def __str__(self):
"""Return a nice string representation of the Frontend.""" """Return a nice string representation of the Frontend."""
@@ -133,7 +140,7 @@ class Frontend(IRCConnection):
self._join_channels() self._join_channels()
else: else:
self.logger.debug("Identifying with services") 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.say(self.NICK_SERVICES, msg, hidelog=True)
self._auth_wait = 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> # Copyright (C) 2009-2021 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -24,14 +22,18 @@ import re


__all__ = ["RC"] __all__ = ["RC"]



class RC: class RC:
"""Store data from an event received from our IRC watcher.""" """Store data from an event received from our IRC watcher."""

re_color = re.compile("\x03([0-9]{1,2}(,[0-9]{1,2})?)?") 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") 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_edit = "New {0}: [[{1}]] * {2} * {3} * {4}"
plain_log = "New {0}: {1} * {2} * {3}" plain_log = "New {0}: {1} * {2} * {3}"


@@ -41,11 +43,11 @@ class RC:


def __repr__(self): def __repr__(self):
"""Return the canonical string representation of the RC.""" """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): def __str__(self):
"""Return a nice string representation of the RC.""" """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): def parse(self):
"""Parse a recent change event into some variables.""" """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 # We're probably missing the https:// part, because it's a log
# entry, which lacks a URL: # entry, which lacks a URL:
page, flags, user, comment = self.re_log.findall(msg)[0] 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 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> # Copyright (C) 2009-2024 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -23,7 +21,7 @@
import importlib.machinery import importlib.machinery
import importlib.util import importlib.util


from earwigbot.irc import IRCConnection, RC
from earwigbot.irc import RC, IRCConnection


__all__ = ["Watcher"] __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> # Copyright (C) 2009-2024 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # 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 #! /usr/bin/env python
# -*- coding: utf-8 -*-
#
# Copyright (C) 2009-2024 Ben Kurtovic <ben.kurtovic@gmail.com> # Copyright (C) 2009-2024 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -69,12 +67,11 @@ class _ResourceManager:


def __str__(self): def __str__(self):
"""Return a nice string representation of the manager.""" """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): def __iter__(self):
with self.lock: with self.lock:
for resource in self._resources.values():
yield resource
yield from self._resources.values()


def _is_disabled(self, name): def _is_disabled(self, name):
"""Check whether a resource should be disabled.""" """Check whether a resource should be disabled."""
@@ -99,7 +96,7 @@ class _ResourceManager:
self.logger.exception(e.format(res_type, name, path)) self.logger.exception(e.format(res_type, name, path))
else: else:
self._resources[resource.name] = resource 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): def _load_module(self, name, path):
"""Load a specific resource from a module, identified by name and 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(): for obj in vars(module).values():
if type(obj) is type: if type(obj) is type:
isresource = issubclass(obj, self._resource_base) 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) self._load_resource(name, path, obj)


def _load_directory(self, dir): def _load_directory(self, dir):
"""Load all valid resources in a given directory.""" """Load all valid resources in a given directory."""
self.logger.debug("Loading directory {0}".format(dir))
self.logger.debug(f"Loading directory {dir}")
processed = [] processed = []
for name in listdir(dir): for name in listdir(dir):
if not name.endswith(".py") and not name.endswith(".pyc"): if not name.endswith(".py") and not name.endswith(".pyc"):
@@ -141,7 +138,7 @@ class _ResourceManager:
continue continue
processed.append(modname) processed.append(modname)
if self._is_disabled(modname): if self._is_disabled(modname):
log = "Skipping disabled module {0}".format(modname)
log = f"Skipping disabled module {modname}"
self.logger.debug(log) self.logger.debug(log)
continue continue
self._load_module(modname, dir) self._load_module(modname, dir)
@@ -188,7 +185,7 @@ class _ResourceManager:
resources = ", ".join(self._resources.keys()) resources = ", ".join(self._resources.keys())
self.logger.info(msg.format(len(self._resources), name, resources)) self.logger.info(msg.format(len(self._resources), name, resources))
else: else:
self.logger.info("Loaded 0 {0}".format(name))
self.logger.info(f"Loaded 0 {name}")


def get(self, key): def get(self, key):
"""Return the class instance associated with a certain resource. """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): if hook in command.hooks and self._wrap_check(command, data):
thread = Thread(target=self._wrap_process, args=(command, data)) thread = Thread(target=self._wrap_process, args=(command, data))
start_time = strftime("%b %d %H:%M:%S") 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.daemon = True
thread.start() thread.start()
return return
@@ -287,7 +284,7 @@ class TaskManager(_ResourceManager):


task_thread = Thread(target=self._wrapper, args=(task,), kwargs=kwargs) task_thread = Thread(target=self._wrapper, args=(task,), kwargs=kwargs)
start_time = strftime("%b %d %H:%M:%S") 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.daemon = True
task_thread.start() task_thread.start()
return task_thread 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> # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -21,10 +19,10 @@
# SOFTWARE. # SOFTWARE.


from earwigbot import exceptions from earwigbot import exceptions
from earwigbot import wiki


__all__ = ["Task"] __all__ = ["Task"]



class Task: class Task:
""" """
**EarwigBot: Base Bot Task** **EarwigBot: Base Bot Task**
@@ -39,6 +37,7 @@ class Task:
<earwigbot.managers.TaskManager.start>`. ``**kwargs`` get passed to the <earwigbot.managers.TaskManager.start>`. ``**kwargs`` get passed to the
Task's :meth:`run` method. Task's :meth:`run` method.
""" """

name = None name = None
number = 0 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> # Copyright (C) 2009-2024 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # 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) self.process_category(site.get_page(title), job, recursive)


if "file" in kwargs: if "file" in kwargs:
with open(kwargs["file"], "r") as fileobj:
with open(kwargs["file"]) as fileobj:
for line in fileobj: for line in fileobj:
if line.strip(): if line.strip():
if line.startswith("[[") and line.endswith("]]"): if line.startswith("[[") and line.endswith("]]"):
@@ -344,9 +342,9 @@ class WikiProjectTagger(Task):


def update_banner(self, banner, job, code): def update_banner(self, banner, job, code):
"""Update an existing *banner* based on a *job* and a page's *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 updated = False
if job.autoassess is not False: if job.autoassess is not False:


+ 41
- 19
earwigbot/util.py View File

@@ -1,6 +1,4 @@
#! /usr/bin/env python #! /usr/bin/env python
# -*- coding: utf-8 -*-
#
# Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com> # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # 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 import logging
from argparse import REMAINDER, Action, ArgumentParser
from os import path from os import path
from time import sleep from time import sleep


@@ -59,8 +57,10 @@ from earwigbot.bot import Bot


__all__ = ["main"] __all__ = ["main"]



class _StoreTaskArg(Action): class _StoreTaskArg(Action):
"""A custom argparse action to read remaining command-line arguments.""" """A custom argparse action to read remaining command-line arguments."""

def __call__(self, parser, namespace, values, option_string=None): def __call__(self, parser, namespace, values, option_string=None):
kwargs = {} kwargs = {}
name = None name = None
@@ -96,32 +96,53 @@ class _StoreTaskArg(Action):


def main(): def main():
"""Main entry point for the command-line utility.""" """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 desc = """This is EarwigBot's command-line utility, enabling you to easily
start the bot or run specific tasks.""" start the bot or run specific tasks."""
parser = ArgumentParser(description=desc) 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 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) parser.add_argument("-v", "--version", action="version", version=version)
logger = parser.add_mutually_exclusive_group() 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() args = parser.parse_args()


if not args.task and args.task_args: if not args.task and args.task_args:
unrecognized = " ".join(args.task_args) unrecognized = " ".join(args.task_args)
parser.error("unrecognized arguments: {0}".format(unrecognized))
parser.error(f"unrecognized arguments: {unrecognized}")


level = logging.INFO level = logging.INFO
if args.debug: if args.debug:
@@ -153,5 +174,6 @@ def main():
if bot.is_running: if bot.is_running:
bot.stop() bot.stop()



if __name__ == "__main__": if __name__ == "__main__":
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> # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # 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> # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # 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"] __all__ = ["Category"]



class Category(Page): class Category(Page):
""" """
**EarwigBot: Wiki Toolset: Category** **EarwigBot: Wiki Toolset: Category**
@@ -56,7 +55,7 @@ class Category(Page):


def __str__(self): def __str__(self):
"""Return a nice string representation of the Category.""" """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): def __iter__(self):
"""Iterate over all members of the category.""" """Iterate over all members of the category."""
@@ -64,8 +63,12 @@ class Category(Page):


def _get_members_via_api(self, limit, follow): def _get_members_via_api(self, limit, follow):
"""Iterate over Pages in the category using the API.""" """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: while 1:
params["cmlimit"] = limit if limit else "max" params["cmlimit"] = limit if limit else "max"
@@ -102,13 +105,13 @@ class Category(Page):
title = ":".join((namespace, base)) title = ":".join((namespace, base))
else: # Avoid doing a silly (albeit valid) ":Pagename" thing else: # Avoid doing a silly (albeit valid) ":Pagename" thing
title = base 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): def _get_size_via_api(self, member_type):
"""Return the size of the category using the API.""" """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"] info = list(result["query"]["pages"].values())[0]["categoryinfo"]
return info[member_type] return info[member_type]


@@ -127,7 +130,7 @@ class Category(Page):
"""Return the size of the category.""" """Return the size of the category."""
services = { services = {
self.site.SERVICE_API: self._get_size_via_api, 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,)) return self.site.delegate(services, (member_type,))


@@ -201,7 +204,7 @@ class Category(Page):
""" """
services = { services = {
self.site.SERVICE_API: self._get_members_via_api, 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: if follow_redirects is None:
follow_redirects = self._follow_redirects 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> # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # 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: # Default User Agent when making API queries:
from earwigbot import __version__ as _v
from platform import python_version as _p 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 = "EarwigBot/{0} (Python/{1}; https://github.com/earwig/earwigbot)"
USER_AGENT = USER_AGENT.format(_v, _p()) USER_AGENT = USER_AGENT.format(_v, _p())
del _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> # Copyright (C) 2009-2016 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # 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 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE. # SOFTWARE.


from time import sleep, time
from time import sleep
from urllib.request import build_opener from urllib.request import build_opener


from earwigbot import exceptions from earwigbot import exceptions
from earwigbot.wiki.copyvios.markov import MarkovChain from earwigbot.wiki.copyvios.markov import MarkovChain
from earwigbot.wiki.copyvios.parsers import ArticleTextParser from earwigbot.wiki.copyvios.parsers import ArticleTextParser
from earwigbot.wiki.copyvios.search import SEARCH_ENGINES 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"] __all__ = ["CopyvioMixIn", "globalize", "localize"]



class CopyvioMixIn: class CopyvioMixIn:
""" """
**EarwigBot: Wiki Toolset: Copyright Violation MixIn** **EarwigBot: Wiki Toolset: Copyright Violation MixIn**
@@ -46,8 +44,10 @@ class CopyvioMixIn:
def __init__(self, site): def __init__(self, site):
self._search_config = site._search_config self._search_config = site._search_config
self._exclusions_db = self._search_config.get("exclusions_db") 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): def _get_search_engine(self):
"""Return a function that can be called to do web searches. """Return a function that can be called to do web searches.
@@ -80,8 +80,15 @@ class CopyvioMixIn:


return klass(credentials, opener) 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. """Check the page for copyright violations.


Returns a :class:`.CopyvioCheckResult` object with information on the Returns a :class:`.CopyvioCheckResult` object with information on the
@@ -117,25 +124,34 @@ class CopyvioMixIn:
log = "Starting copyvio check for [[{0}]]" log = "Starting copyvio check for [[{0}]]"
self._logger.info(log.format(self.title)) self._logger.info(log.format(self.title))
searcher = self._get_search_engine() 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()) article = MarkovChain(parser.strip())
parser_args = {} parser_args = {}


if self._exclusions_db: if self._exclusions_db:
self._exclusions_db.sync(self.site.name) 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: else:
exclude = None exclude = None


workspace = CopyvioWorkspace( 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 if article.size < 20: # Auto-fail very small articles
result = workspace.get_result() result = workspace.get_result()
@@ -187,8 +203,15 @@ class CopyvioMixIn:
self._logger.info(log.format(self.title, url)) self._logger.info(log.format(self.title, url))
article = MarkovChain(ArticleTextParser(self.get()).strip()) article = MarkovChain(ArticleTextParser(self.get()).strip())
workspace = CopyvioWorkspace( 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.enqueue([url])
workspace.wait() workspace.wait()
result = workspace.get_result() 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> # Copyright (C) 2009-2016 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -33,18 +31,23 @@ __all__ = ["ExclusionsDB"]
DEFAULT_SOURCES = { DEFAULT_SOURCES = {
"all": [ # Applies to all, but located on enwiki "all": [ # Applies to all, but located on enwiki
"User:EarwigBot/Copyvios/Exclusions", "User:EarwigBot/Copyvios/Exclusions",
"User:EranBot/Copyright/Blacklist"
"User:EranBot/Copyright/Blacklist",
], ],
"enwiki": [ "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\.)?" _RE_STRIP_PREFIX = r"^https?://(www\.)?"



class ExclusionsDB: class ExclusionsDB:
""" """
**EarwigBot: Wiki Toolset: Exclusions Database Manager** **EarwigBot: Wiki Toolset: Exclusions Database Manager**
@@ -66,7 +69,7 @@ class ExclusionsDB:


def __str__(self): def __str__(self):
"""Return a nice string representation of the ExclusionsDB.""" """Return a nice string representation of the ExclusionsDB."""
return "<ExclusionsDB at {0}>".format(self._dbfile)
return f"<ExclusionsDB at {self._dbfile}>"


def _create(self): def _create(self):
"""Initialize the exclusions database with its necessary tables.""" """Initialize the exclusions database with its necessary tables."""
@@ -95,7 +98,10 @@ class ExclusionsDB:


if source == "User:EarwigBot/Copyvios/Exclusions": if source == "User:EarwigBot/Copyvios/Exclusions":
for line in data.splitlines(): 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: if match:
url = re.sub(_RE_STRIP_PREFIX, "", match.group(1)) url = re.sub(_RE_STRIP_PREFIX, "", match.group(1))
if url: if url:
@@ -121,7 +127,9 @@ class ExclusionsDB:
"""Update the database from listed sources in the index.""" """Update the database from listed sources in the index."""
query1 = "SELECT source_page FROM sources WHERE source_sitename = ?" query1 = "SELECT source_page FROM sources WHERE source_sitename = ?"
query2 = "SELECT exclusion_url FROM exclusions WHERE exclusion_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 (?, ?)" query4 = "INSERT INTO exclusions VALUES (?, ?)"
query5 = "SELECT 1 FROM updates WHERE update_sitename = ?" query5 = "SELECT 1 FROM updates WHERE update_sitename = ?"
query6 = "UPDATE updates SET update_time = ? 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)) self._logger.debug(log.format(sitename, url))
return True return True


log = "No exclusions in {0} for {1}".format(sitename, url)
log = f"No exclusions in {sitename} for {url}"
self._logger.debug(log) self._logger.debug(log)
return False return False


@@ -224,9 +232,12 @@ class ExclusionsDB:
if try_mobile: if try_mobile:
fragments = re.search(r"^([\w]+)\.([\w]+).([\w]+)$", site.domain) fragments = re.search(r"^([\w]+)\.([\w]+).([\w]+)$", site.domain)
if fragments: 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] specific = [root + path for root in roots]
return general + specific 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> # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # 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 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE. # 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: class MarkovChain:
"""Implements a basic ngram Markov chain of words.""" """Implements a basic ngram Markov chain of words."""

START = -1 START = -1
END = -2 END = -2
degree = 5 # 2 for bigrams, 3 for trigrams, etc. degree = 5 # 2 for bigrams, 3 for trigrams, etc.
@@ -44,7 +43,7 @@ class MarkovChain:
chain = {} chain = {}


for i in range(len(words) - self.degree + 1): 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: if phrase in chain:
chain[phrase] += 1 chain[phrase] += 1
else: else:
@@ -57,11 +56,11 @@ class MarkovChain:


def __repr__(self): def __repr__(self):
"""Return the canonical string representation of the MarkovChain.""" """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): def __str__(self):
"""Return a nice string representation of the MarkovChain.""" """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): 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> # Copyright (C) 2009-2019 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -21,11 +19,11 @@
# SOFTWARE. # SOFTWARE.


import json import json
from os import path
import re import re
from io import StringIO
import urllib.parse import urllib.parse
import urllib.request import urllib.request
from io import StringIO
from os import path


import mwparserfromhell import mwparserfromhell


@@ -40,8 +38,10 @@ pdfpage = importer.new("pdfminer.pdfpage")


__all__ = ["ArticleTextParser", "get_parser"] __all__ = ["ArticleTextParser", "get_parser"]



class _BaseTextParser: class _BaseTextParser:
"""Base class for a parser that handles text.""" """Base class for a parser that handles text."""

TYPE = None TYPE = None


def __init__(self, text, url=None, args=None): def __init__(self, text, url=None, args=None):
@@ -51,16 +51,17 @@ class _BaseTextParser:


def __repr__(self): def __repr__(self):
"""Return the canonical string representation of the text parser.""" """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): def __str__(self):
"""Return a nice string representation of the text parser.""" """Return a nice string representation of the text parser."""
name = self.__class__.__name__ 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): class ArticleTextParser(_BaseTextParser):
"""A parser that can strip and chunk wikicode article text.""" """A parser that can strip and chunk wikicode article text."""

TYPE = "Article" TYPE = "Article"
TEMPLATE_MERGE_THRESHOLD = 35 TEMPLATE_MERGE_THRESHOLD = 35
NLTK_DEFAULT = "english" NLTK_DEFAULT = "english"
@@ -81,7 +82,7 @@ class ArticleTextParser(_BaseTextParser):
"pt": "portuguese", "pt": "portuguese",
"sl": "slovene", "sl": "slovene",
"sv": "swedish", "sv": "swedish",
"tr": "turkish"
"tr": "turkish",
} }


def _merge_templates(self, code): def _merge_templates(self, code):
@@ -100,8 +101,11 @@ class ArticleTextParser(_BaseTextParser):


def _get_tokenizer(self): def _get_tokenizer(self):
"""Return a NLTK punctuation tokenizer for the article's language.""" """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) lang = self.NLTK_LANGS.get(self._args.get("lang"), self.NLTK_DEFAULT)
try: try:
@@ -112,6 +116,7 @@ class ArticleTextParser(_BaseTextParser):


def _get_sentences(self, min_query, max_query, split_thresh): def _get_sentences(self, min_query, max_query, split_thresh):
"""Split the article text into sentences of a certain length.""" """Split the article text into sentences of a certain length."""

def cut_sentence(words): def cut_sentence(words):
div = len(words) div = len(words)
if div == 0: if div == 0:
@@ -125,7 +130,7 @@ class ArticleTextParser(_BaseTextParser):
result = [] result = []
if length >= split_thresh: if length >= split_thresh:
result.append(" ".join(words[:div])) result.append(" ".join(words[:div]))
return result + cut_sentence(words[div + 1:])
return result + cut_sentence(words[div + 1 :])


tokenizer = self._get_tokenizer() tokenizer = self._get_tokenizer()
sentences = [] sentences = []
@@ -150,6 +155,7 @@ class ArticleTextParser(_BaseTextParser):


The actual stripping is handled by :py:mod:`mwparserfromhell`. The actual stripping is handled by :py:mod:`mwparserfromhell`.
""" """

def remove(code, node): def remove(code, node):
"""Remove a node from a code object, ignoring ValueError. """Remove a node from a code object, ignoring ValueError.


@@ -223,16 +229,14 @@ class ArticleTextParser(_BaseTextParser):
""" """
schemes = ("http://", "https://") schemes = ("http://", "https://")
links = mwparserfromhell.parse(self.text).ifilter_external_links() 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): class _HTMLParser(_BaseTextParser):
"""A parser that can extract the text from an HTML document.""" """A parser that can extract the text from an HTML document."""

TYPE = "HTML" TYPE = "HTML"
hidden_tags = [
"script", "style"
]
hidden_tags = ["script", "style"]


def _fail_if_mirror(self, soup): def _fail_if_mirror(self, soup):
"""Look for obvious signs that the given soup is a wiki mirror. """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: if "mirror_hints" not in self._args:
return 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): if soup.find_all(href=func) or soup.find_all(src=func):
raise ParserExclusionError() raise ParserExclusionError()


@@ -258,7 +263,10 @@ class _HTMLParser(_BaseTextParser):


def _clean_soup(self, soup): def _clean_soup(self, soup):
"""Clean a BeautifulSoup tree of invisible tags.""" """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): for comment in soup.find_all(text=is_comment):
comment.extract() comment.extract()
for tag in self.hidden_tags: for tag in self.hidden_tags:
@@ -281,15 +289,17 @@ class _HTMLParser(_BaseTextParser):
if not match: if not match:
return "" return ""
post_id = match.group(1) 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 = { params = {
"alt": "json", "alt": "json",
"v": "2", "v": "2",
"dynamicviews": "1", "dynamicviews": "1",
"rewriteforssl": "true", "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: if raw is None:
return "" return ""
try: try:
@@ -334,6 +344,7 @@ class _HTMLParser(_BaseTextParser):


class _PDFParser(_BaseTextParser): class _PDFParser(_BaseTextParser):
"""A parser that can extract text from a PDF file.""" """A parser that can extract text from a PDF file."""

TYPE = "PDF" TYPE = "PDF"
substitutions = [ substitutions = [
("\x0c", "\n"), ("\x0c", "\n"),
@@ -364,6 +375,7 @@ class _PDFParser(_BaseTextParser):


class _PlainTextParser(_BaseTextParser): class _PlainTextParser(_BaseTextParser):
"""A parser that can unicode-ify and strip text from a plain text page.""" """A parser that can unicode-ify and strip text from a plain text page."""

TYPE = "Text" TYPE = "Text"


def parse(self): def parse(self):
@@ -377,9 +389,10 @@ _CONTENT_TYPES = {
"application/xhtml+xml": _HTMLParser, "application/xhtml+xml": _HTMLParser,
"application/pdf": _PDFParser, "application/pdf": _PDFParser,
"application/x-pdf": _PDFParser, "application/x-pdf": _PDFParser,
"text/plain": _PlainTextParser
"text/plain": _PlainTextParser,
} }



def get_parser(content_type): def get_parser(content_type):
"""Return the parser most able to handle a given content type, or None.""" """Return the parser most able to handle a given content type, or None."""
return _CONTENT_TYPES.get(content_type) 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> # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # 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"] __all__ = ["CopyvioSource", "CopyvioCheckResult"]



class CopyvioSource: class CopyvioSource:
""" """
**EarwigBot: Wiki Toolset: Copyvio Source** **EarwigBot: Wiki Toolset: Copyvio Source**
@@ -43,8 +42,15 @@ class CopyvioSource:
- :py:attr:`excluded`: whether this URL was in the exclusions list - :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.workspace = workspace
self.url = url self.url = url
self.headers = headers self.headers = headers
@@ -63,17 +69,18 @@ class CopyvioSource:


def __repr__(self): def __repr__(self):
"""Return the canonical string representation of the source.""" """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): def __str__(self):
"""Return a nice string representation of the source.""" """Return a nice string representation of the source."""
if self.excluded: if self.excluded:
return "<CopyvioSource ({0}, excluded)>".format(self.url)
return f"<CopyvioSource ({self.url}, excluded)>"
if self.skipped: if self.skipped:
return "<CopyvioSource ({0}, skipped)>".format(self.url)
return f"<CopyvioSource ({self.url}, skipped)>"
res = "<CopyvioSource ({0} with {1} conf)>" res = "<CopyvioSource ({0} with {1} conf)>"
return res.format(self.url, self.confidence) return res.format(self.url, self.confidence)


@@ -129,8 +136,9 @@ class CopyvioCheckResult:
- :py:attr:`possible_miss`: whether some URLs might have been missed - :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.violation = violation
self.sources = sources self.sources = sources
self.queries = queries self.queries = queries
@@ -141,8 +149,7 @@ class CopyvioCheckResult:
def __repr__(self): def __repr__(self):
"""Return the canonical string representation of the result.""" """Return the canonical string representation of the result."""
res = "CopyvioCheckResult(violation={0!r}, sources={1!r}, queries={2!r}, time={3!r})" 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): def __str__(self):
"""Return a nice string representation of the result.""" """Return a nice string representation of the result."""
@@ -171,5 +178,12 @@ class CopyvioCheckResult:
return log.format(title, self.queries, self.time) return log.format(title, self.queries, self.time)
log = "{0} for [[{1}]] (best: {2} ({3} confidence); {4} sources; {5} queries; {6} seconds)" log = "{0} for [[{1}]] (best: {2} ({3} confidence); {4} sources; {5} queries; {6} seconds)"
is_vio = "Violation detected" if self.violation else "No violation" 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> # Copyright (C) 2009-2016 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -21,22 +19,28 @@
# SOFTWARE. # SOFTWARE.


from gzip import GzipFile from gzip import GzipFile
from io import StringIO
from json import loads from json import loads
from re import sub as re_sub 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.error import URLError
from urllib.parse import urlencode


from earwigbot import importer from earwigbot import importer
from earwigbot.exceptions import SearchQueryError from earwigbot.exceptions import SearchQueryError


lxml = importer.new("lxml") lxml = importer.new("lxml")


__all__ = ["BingSearchEngine", "GoogleSearchEngine", "YandexSearchEngine", "SEARCH_ENGINES"]
__all__ = [
"BingSearchEngine",
"GoogleSearchEngine",
"YandexSearchEngine",
"SEARCH_ENGINES",
]



class _BaseSearchEngine: class _BaseSearchEngine:
"""Base class for a simple search engine interface.""" """Base class for a simple search engine interface."""

name = "Base" name = "Base"


def __init__(self, cred, opener): def __init__(self, cred, opener):
@@ -47,19 +51,19 @@ class _BaseSearchEngine:


def __repr__(self): def __repr__(self):
"""Return the canonical string representation of the search engine.""" """Return the canonical string representation of the search engine."""
return "{0}()".format(self.__class__.__name__)
return f"{self.__class__.__name__}()"


def __str__(self): def __str__(self):
"""Return a nice string representation of the search engine.""" """Return a nice string representation of the search engine."""
return "<{0}>".format(self.__class__.__name__)
return f"<{self.__class__.__name__}>"


def _open(self, *args): def _open(self, *args):
"""Open a URL (like urlopen) and try to return its contents.""" """Open a URL (like urlopen) and try to return its contents."""
try: try:
response = self.opener.open(*args) response = self.opener.open(*args)
result = response.read() 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 err.cause = exc
raise err raise err


@@ -90,6 +94,7 @@ class _BaseSearchEngine:


class BingSearchEngine(_BaseSearchEngine): class BingSearchEngine(_BaseSearchEngine):
"""A search engine interface with Bing Search (via Azure Marketplace).""" """A search engine interface with Bing Search (via Azure Marketplace)."""

name = "Bing" name = "Bing"


def __init__(self, cred, opener): def __init__(self, cred, opener):
@@ -106,7 +111,7 @@ class BingSearchEngine(_BaseSearchEngine):
Raises :py:exc:`~earwigbot.exceptions.SearchQueryError` on errors. Raises :py:exc:`~earwigbot.exceptions.SearchQueryError` on errors.
""" """
service = "SearchWeb" if self.cred["type"] == "searchweb" else "Search" 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 = { params = {
"$format": "json", "$format": "json",
"$top": str(self.count), "$top": str(self.count),
@@ -114,7 +119,7 @@ class BingSearchEngine(_BaseSearchEngine):
"Market": "'en-US'", "Market": "'en-US'",
"Adult": "'Off'", "Adult": "'Off'",
"Options": "'DisableLocationDetection'", "Options": "'DisableLocationDetection'",
"WebSearchOptions": "'DisableHostCollapsing+DisableQueryAlterations'"
"WebSearchOptions": "'DisableHostCollapsing+DisableQueryAlterations'",
} }


result = self._open(url + urlencode(params)) result = self._open(url + urlencode(params))
@@ -134,6 +139,7 @@ class BingSearchEngine(_BaseSearchEngine):


class GoogleSearchEngine(_BaseSearchEngine): class GoogleSearchEngine(_BaseSearchEngine):
"""A search engine interface with Google Search.""" """A search engine interface with Google Search."""

name = "Google" name = "Google"


def search(self, query): def search(self, query):
@@ -143,7 +149,7 @@ class GoogleSearchEngine(_BaseSearchEngine):
Raises :py:exc:`~earwigbot.exceptions.SearchQueryError` on errors. Raises :py:exc:`~earwigbot.exceptions.SearchQueryError` on errors.
""" """
domain = self.cred.get("proxy", "www.googleapis.com") domain = self.cred.get("proxy", "www.googleapis.com")
url = "https://{0}/customsearch/v1?".format(domain)
url = f"https://{domain}/customsearch/v1?"
params = { params = {
"cx": self.cred["id"], "cx": self.cred["id"],
"key": self.cred["key"], "key": self.cred["key"],
@@ -151,7 +157,7 @@ class GoogleSearchEngine(_BaseSearchEngine):
"alt": "json", "alt": "json",
"num": str(self.count), "num": str(self.count),
"safe": "off", "safe": "off",
"fields": "items(link)"
"fields": "items(link)",
} }


result = self._open(url + urlencode(params)) result = self._open(url + urlencode(params))
@@ -170,6 +176,7 @@ class GoogleSearchEngine(_BaseSearchEngine):


class YandexSearchEngine(_BaseSearchEngine): class YandexSearchEngine(_BaseSearchEngine):
"""A search engine interface with Yandex Search.""" """A search engine interface with Yandex Search."""

name = "Yandex" name = "Yandex"


@staticmethod @staticmethod
@@ -183,7 +190,7 @@ class YandexSearchEngine(_BaseSearchEngine):
Raises :py:exc:`~earwigbot.exceptions.SearchQueryError` on errors. Raises :py:exc:`~earwigbot.exceptions.SearchQueryError` on errors.
""" """
domain = self.cred.get("proxy", "yandex.com") 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") query = re_sub(r"[^a-zA-Z0-9 ]", "", query).encode("utf8")
params = { params = {
"user": self.cred["user"], "user": self.cred["user"],
@@ -192,7 +199,7 @@ class YandexSearchEngine(_BaseSearchEngine):
"l10n": "en", "l10n": "en",
"filter": "none", "filter": "none",
"maxpassages": "1", "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)) result = self._open(url + urlencode(params))
@@ -207,5 +214,5 @@ class YandexSearchEngine(_BaseSearchEngine):
SEARCH_ENGINES = { SEARCH_ENGINES = {
"Bing": BingSearchEngine, "Bing": BingSearchEngine,
"Google": GoogleSearchEngine, "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> # Copyright (C) 2009-2019 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -22,21 +20,20 @@


import base64 import base64
import collections import collections
from collections import deque
import functools import functools
import time
import urllib.parse
from collections import deque
from gzip import GzipFile from gzip import GzipFile
from http.client import HTTPException from http.client import HTTPException
from io import StringIO
from logging import getLogger from logging import getLogger
from math import log from math import log
from queue import Empty, Queue from queue import Empty, Queue
from socket import error as socket_error
from io import StringIO
from struct import error as struct_error from struct import error as struct_error
from threading import Lock, Thread from threading import Lock, Thread
import time
from urllib.error import URLError 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 import importer
from earwigbot.exceptions import ParserExclusionError, ParserRedirectError from earwigbot.exceptions import ParserExclusionError, ParserRedirectError
@@ -49,13 +46,14 @@ tldextract = importer.new("tldextract")
__all__ = ["globalize", "localize", "CopyvioWorkspace"] __all__ = ["globalize", "localize", "CopyvioWorkspace"]


_MAX_REDIRECTS = 3 _MAX_REDIRECTS = 3
_MAX_RAW_SIZE = 20 * 1024 ** 2
_MAX_RAW_SIZE = 20 * 1024**2


_is_globalized = False _is_globalized = False
_global_queues = None _global_queues = None
_global_workers = [] _global_workers = []


_OpenedURL = collections.namedtuple('_OpenedURL', ['content', 'parser_class'])
_OpenedURL = collections.namedtuple("_OpenedURL", ["content", "parser_class"])



def globalize(num_workers=8): def globalize(num_workers=8):
"""Cause all copyvio checks to be done by one global set of workers. """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() _global_queues = _CopyvioQueues()
for i in range(num_workers): for i in range(num_workers):
worker = _CopyvioWorker("global-{0}".format(i), _global_queues)
worker = _CopyvioWorker(f"global-{i}", _global_queues)
worker.start() worker.start()
_global_workers.append(worker) _global_workers.append(worker)
_is_globalized = True _is_globalized = True



def localize(): def localize():
"""Return to using page-specific workers for copyvio checks. """Return to using page-specific workers for copyvio checks.


@@ -137,11 +136,12 @@ class _CopyvioWorker:
if "path" in proxy_info: if "path" in proxy_info:
if not parsed.path.startswith(proxy_info["path"]): if not parsed.path.startswith(proxy_info["path"]):
continue continue
path = path[len(proxy_info["path"]):]
path = path[len(proxy_info["path"]) :]
url = proxy_info["target"] + path url = proxy_info["target"] + path
if "auth" in proxy_info: if "auth" in proxy_info:
extra_headers["Authorization"] = "Basic %s" % ( extra_headers["Authorization"] = "Basic %s" % (
base64.b64encode(proxy_info["auth"]))
base64.b64encode(proxy_info["auth"])
)
return url, True return url, True
return url, False return url, False


@@ -158,8 +158,10 @@ class _CopyvioWorker:
request = Request(url, headers=extra_headers) request = Request(url, headers=extra_headers)
try: try:
response = self._opener.open(request, timeout=timeout) 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: if not remapped:
self._logger.exception("Failed to fetch URL: %s", url) self._logger.exception("Failed to fetch URL: %s", url)
return None return None
@@ -167,7 +169,7 @@ class _CopyvioWorker:
request = Request(url, headers=extra_headers) request = Request(url, headers=extra_headers)
try: try:
response = self._opener.open(request, timeout=timeout) 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) self._logger.exception("Failed to fetch URL after proxy remap: %s", url)
return None return None


@@ -180,18 +182,19 @@ class _CopyvioWorker:
content_type = content_type.split(";", 1)[0] content_type = content_type.split(";", 1)[0]
parser_class = get_parser(content_type) parser_class = get_parser(content_type)
if not parser_class and ( 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 return None
if not parser_class: if not parser_class:
parser_class = get_parser("text/plain") 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 return None


try: try:
# Additional safety check for pages using Transfer-Encoding: chunked # Additional safety check for pages using Transfer-Encoding: chunked
# where we can't read the Content-Length # where we can't read the Content-Length
content = response.read(_MAX_RAW_SIZE + 1) content = response.read(_MAX_RAW_SIZE + 1)
except (URLError, socket_error):
except (OSError, URLError):
return None return None
if len(content) > _MAX_RAW_SIZE: if len(content) > _MAX_RAW_SIZE:
return None return None
@@ -201,7 +204,7 @@ class _CopyvioWorker:
gzipper = GzipFile(fileobj=stream) gzipper = GzipFile(fileobj=stream)
try: try:
content = gzipper.read() content = gzipper.read()
except (IOError, struct_error):
except (OSError, struct_error):
return None return None


if len(content) > _MAX_RAW_SIZE: if len(content) > _MAX_RAW_SIZE:
@@ -252,7 +255,7 @@ class _CopyvioWorker:
site, queue = self._queues.unassigned.get(timeout=timeout) site, queue = self._queues.unassigned.get(timeout=timeout)
if site is StopIteration: if site is StopIteration:
raise 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._site = site
self._queue = queue self._queue = queue


@@ -274,7 +277,7 @@ class _CopyvioWorker:
self._queues.lock.release() self._queues.lock.release()
return self._dequeue() 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: if source.skipped:
self._logger.debug("Source has been skipped") self._logger.debug("Source has been skipped")
self._queues.lock.release() self._queues.lock.release()
@@ -335,9 +338,20 @@ class _CopyvioWorker:
class CopyvioWorkspace: class CopyvioWorkspace:
"""Manages a single copyvio check distributed across threads.""" """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.sources = []
self.finished = False self.finished = False
self.possible_miss = False self.possible_miss = False
@@ -351,8 +365,12 @@ class CopyvioWorkspace:
self._finish_lock = Lock() self._finish_lock = Lock()
self._short_circuit = short_circuit self._short_circuit = short_circuit
self._source_args = { 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 self._exclude_check = exclude_check


if _is_globalized: if _is_globalized:
@@ -361,11 +379,12 @@ class CopyvioWorkspace:
self._queues = _CopyvioQueues() self._queues = _CopyvioQueues()
self._num_workers = num_workers self._num_workers = num_workers
for i in range(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() _CopyvioWorker(name, self._queues, self._until).start()


def _calculate_confidence(self, delta): def _calculate_confidence(self, delta):
"""Return the confidence of a violation as a float between 0 and 1.""" """Return the confidence of a violation as a float between 0 and 1."""

def conf_with_article_and_delta(article, delta): def conf_with_article_and_delta(article, delta):
"""Calculate confidence using the article and delta chain sizes.""" """Calculate confidence using the article and delta chain sizes."""
# This piecewise function exhibits exponential growth until it # This piecewise function exhibits exponential growth until it
@@ -377,7 +396,7 @@ class CopyvioWorkspace:
if ratio <= 0.52763: if ratio <= 0.52763:
return -log(1 - ratio) return -log(1 - ratio)
else: 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): def conf_with_delta(delta):
"""Calculate confidence using just the delta chain size.""" """Calculate confidence using just the delta chain size."""
@@ -395,8 +414,12 @@ class CopyvioWorkspace:
return (delta - 50) / delta return (delta - 50) / delta


d_size = float(delta.size) 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): def _finish_early(self):
"""Finish handling links prematurely (if we've hit min_confidence).""" """Finish handling links prematurely (if we've hit min_confidence)."""
@@ -418,12 +441,12 @@ class CopyvioWorkspace:
self.sources.append(source) self.sources.append(source)


if self._exclude_check and self._exclude_check(url): 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.excluded = True
source.skip() source.skip()
continue continue
if self._short_circuit and self.finished: 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() source.skip()
continue continue


@@ -431,6 +454,7 @@ class CopyvioWorkspace:
key = tldextract.extract(url).registered_domain key = tldextract.extract(url).registered_domain
except ImportError: # Fall back on very naive method except ImportError: # Fall back on very naive method
from urllib.parse import urlparse from urllib.parse import urlparse

key = ".".join(urlparse(url).netloc.split(".")[-2:]) key = ".".join(urlparse(url).netloc.split(".")[-2:])


logmsg = "enqueue(): {0} {1} -> {2}" logmsg = "enqueue(): {0} {1} -> {2}"
@@ -450,7 +474,7 @@ class CopyvioWorkspace:
conf = self._calculate_confidence(delta) conf = self._calculate_confidence(delta)
else: else:
conf = 0.0 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: with self._finish_lock:
if source_chain: if source_chain:
source.update(conf, source_chain, delta) source.update(conf, source_chain, delta)
@@ -463,7 +487,7 @@ class CopyvioWorkspace:


def wait(self): def wait(self):
"""Wait for the workers to finish handling the sources.""" """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: for source in self.sources:
source.join(self._until) source.join(self._until)
with self._finish_lock: with self._finish_lock:
@@ -474,6 +498,7 @@ class CopyvioWorkspace:


def get_result(self, num_queries=0): def get_result(self, num_queries=0):
"""Return a CopyvioCheckResult containing the results of this check.""" """Return a CopyvioCheckResult containing the results of this check."""

def cmpfunc(s1, s2): def cmpfunc(s1, s2):
if s2.confidence != s1.confidence: if s2.confidence != s1.confidence:
return 1 if s2.confidence > s1.confidence else -1 return 1 if s2.confidence > s1.confidence else -1
@@ -482,6 +507,11 @@ class CopyvioWorkspace:
return int(s1.skipped) - int(s2.skipped) return int(s1.skipped) - int(s2.skipped)


self.sources.sort(cmpfunc) 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> # Copyright (C) 2009-2019 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # 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 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE. # SOFTWARE.


from hashlib import md5
from logging import getLogger, NullHandler
import re import re
from hashlib import md5
from logging import NullHandler, getLogger
from time import gmtime, strftime from time import gmtime, strftime
from urllib.parse import quote from urllib.parse import quote


@@ -33,6 +31,7 @@ from earwigbot.wiki.copyvios import CopyvioMixIn


__all__ = ["Page"] __all__ = ["Page"]



class Page(CopyvioMixIn): class Page(CopyvioMixIn):
""" """
**EarwigBot: Wiki Toolset: Page** **EarwigBot: Wiki Toolset: Page**
@@ -75,13 +74,13 @@ class Page(CopyvioMixIn):
- :py:meth:`~earwigbot.wiki.copyvios.CopyrightMixIn.copyvio_compare`: - :py:meth:`~earwigbot.wiki.copyvios.CopyrightMixIn.copyvio_compare`:
checks the page like :py:meth:`copyvio_check`, but against a specific URL checks the page like :py:meth:`copyvio_check`, but against a specific URL
""" """

PAGE_UNKNOWN = 0 PAGE_UNKNOWN = 0
PAGE_INVALID = 1 PAGE_INVALID = 1
PAGE_MISSING = 2 PAGE_MISSING = 2
PAGE_EXISTS = 3 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. """Constructor for new Page instances.


Takes four arguments: a Site object, the Page's title (or pagename), Takes four arguments: a Site object, the Page's title (or pagename),
@@ -145,7 +144,7 @@ class Page(CopyvioMixIn):


def __str__(self): def __str__(self):
"""Return a nice string representation of the Page.""" """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): def _assert_validity(self):
"""Used to ensure that our page's title is valid. """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. contains "[") it will always be invalid, and cannot be edited.
""" """
if self._exists == self.PAGE_INVALID: 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) raise exceptions.InvalidPageError(e)


def _assert_existence(self): def _assert_existence(self):
@@ -169,7 +168,7 @@ class Page(CopyvioMixIn):
""" """
self._assert_validity() self._assert_validity()
if self._exists == self.PAGE_MISSING: 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) raise exceptions.PageNotFoundError(e)


def _load(self): def _load(self):
@@ -208,9 +207,15 @@ class Page(CopyvioMixIn):
""" """
if not result: if not result:
query = self.site.api_query 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"]: if "interwiki" in result["query"]:
self._title = result["query"]["interwiki"][0]["title"] 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: # These last two fields will only be specified if the page exists:
self._lastrevid = res.get("lastrevid") self._lastrevid = res.get("lastrevid")
try: try:
self._creator = res['revisions'][0]['user']
self._creator = res["revisions"][0]["user"]
except KeyError: except KeyError:
pass pass


@@ -263,9 +268,14 @@ class Page(CopyvioMixIn):
""" """
if not result: if not result:
query = self.site.api_query 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] res = list(result["query"]["pages"].values())[0]
try: try:
@@ -279,8 +289,19 @@ class Page(CopyvioMixIn):
self._load_attributes() self._load_attributes()
self._assert_existence() 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! """Edit the page!


If *params* is given, we'll use it as our API query parameters. 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: # Build our API query string:
if not params: 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() params["token"] = self.site.get_token()


# Try the API query, catching most errors with our handler: # 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: # Otherwise, there was some kind of problem. Throw an exception:
raise exceptions.EditError(result["edit"]) 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.""" """Given some keyword arguments, build an API edit query string."""
unitxt = text.encode("utf8") if isinstance(text, str) else text unitxt = text.encode("utf8") if isinstance(text, str) else text
hashed = md5(unitxt).hexdigest() # Checksum to ensure text is correct hashed = md5(unitxt).hexdigest() # Checksum to ensure text is correct
params = { 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: if section:
params["section"] = 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 is protected), or we'll try to fix it (for example, if the token is
invalid, we'll try to get a new one). 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: if error.code in perms:
raise exceptions.PermissionsError(error.info) raise exceptions.PermissionsError(error.info)
elif error.code in ["editconflict", "pagedeleted", "articleexists"]: elif error.code in ["editconflict", "pagedeleted", "articleexists"]:
@@ -551,7 +603,7 @@ class Page(CopyvioMixIn):
""" """
if self._namespace < 0: if self._namespace < 0:
ns = self.site.namespace_id_to_name(self._namespace) 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) raise exceptions.InvalidPageError(e)


if self._is_talkpage: 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 # Kill two birds with one stone by doing an API query for both our
# attributes and our page content: # attributes and our page content:
query = self.site.api_query 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._load_attributes(result=result)
self._assert_existence() self._assert_existence()
self._load_content(result=result) self._load_content(result=result)
@@ -674,8 +732,9 @@ class Page(CopyvioMixIn):
the page was deleted/recreated between getting our edit token and the page was deleted/recreated between getting our edit token and
editing our page. Be careful with this! 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): def add_section(self, text, title, minor=False, bot=True, force=False, **kwargs):
"""Add a new section to the bottom of the page. """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 This should create the page if it does not already exist, with just the
new section as content. 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): def check_exclusion(self, username=None, optouts=None):
"""Check whether or not we are allowed to edit the page. """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=all}}``, but `True` on
``{{bots|optout=orfud,norationale,replaceable}}``. ``{{bots|optout=orfud,norationale,replaceable}}``.
""" """

def parse_param(template, param): def parse_param(template, param):
value = template.get(param).value value = template.get(param).value
return [item.strip().lower() for item in value.split(",")] 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> # Copyright (C) 2009-2024 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -22,7 +20,7 @@


from http.cookiejar import CookieJar from http.cookiejar import CookieJar
from json import dumps from json import dumps
from logging import getLogger, NullHandler
from logging import NullHandler, getLogger
from os.path import expanduser from os.path import expanduser
from threading import RLock from threading import RLock
from time import sleep, time from time import sleep, time
@@ -228,11 +226,11 @@ class Site:
) )
) )
name, password = self._login_info 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 oauth = "hidden" if self._oauth else None
cookies = self._cookiejar.__class__.__name__ cookies = self._cookiejar.__class__.__name__
if hasattr(self._cookiejar, "filename"): if hasattr(self._cookiejar, "filename"):
cookies += "({0!r})".format(getattr(self._cookiejar, "filename"))
cookies += "({!r})".format(getattr(self._cookiejar, "filename"))
else: else:
cookies += "()" cookies += "()"
agent = self.user_agent agent = self.user_agent
@@ -267,26 +265,26 @@ class Site:
since_last_query = time() - self._last_query_time # Throttling support since_last_query = time() - self._last_query_time # Throttling support
if since_last_query < self._wait_between_queries: if since_last_query < self._wait_between_queries:
wait_time = self._wait_between_queries - since_last_query 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) self._logger.debug(log)
sleep(wait_time) sleep(wait_time)
self._last_query_time = time() self._last_query_time = time()


url, params = self._build_api_query(params, ignore_maxlag, no_assert) url, params = self._build_api_query(params, ignore_maxlag, no_assert)
if "lgpassword" in params: if "lgpassword" in params:
self._logger.debug("{0} -> <hidden>".format(url))
self._logger.debug(f"{url} -> <hidden>")
else: else:
data = dumps(params) data = dumps(params)
if len(data) > 1000: if len(data) > 1000:
self._logger.debug("{0} -> {1}...".format(url, data[:997]))
self._logger.debug(f"{url} -> {data[:997]}...")
else: else:
self._logger.debug("{0} -> {1}".format(url, data))
self._logger.debug(f"{url} -> {data}")


try: try:
response = self._session.post(url, data=params) response = self._session.post(url, data=params)
response.raise_for_status() response.raise_for_status()
except requests.RequestException as exc: 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) return self._handle_api_result(response, params, tries, wait, ae_retry)


@@ -602,7 +600,7 @@ class Site:
elif res == "WrongPass" or res == "WrongPluginPass": elif res == "WrongPass" or res == "WrongPluginPass":
e = "The given password is incorrect." e = "The given password is incorrect."
else: else:
e = "Couldn't login; server says '{0}'.".format(res)
e = f"Couldn't login; server says '{res}'."
raise exceptions.LoginError(e) raise exceptions.LoginError(e)


def _logout(self): def _logout(self):
@@ -915,7 +913,7 @@ class Site:
else: else:
return self._namespaces[ns_id][0] return self._namespaces[ns_id][0]
except KeyError: 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) raise exceptions.NamespaceNotFoundError(e)


def namespace_name_to_id(self, name): def namespace_name_to_id(self, name):
@@ -933,7 +931,7 @@ class Site:
if lname in lnames: if lname in lnames:
return ns_id 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) raise exceptions.NamespaceNotFoundError(e)


def get_page(self, title, follow_redirects=False, pageid=None): 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> # Copyright (C) 2009-2024 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # 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 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE. # SOFTWARE.


from collections import OrderedDict
import errno 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 os import chmod, path
from platform import python_version from platform import python_version
import stat
import sqlite3 as sqlite


from earwigbot import __version__ from earwigbot import __version__
from earwigbot.exceptions import SiteNotFoundError from earwigbot.exceptions import SiteNotFoundError
@@ -78,7 +76,7 @@ class SitesDB:


def __str__(self): def __str__(self):
"""Return a nice string representation of the SitesDB.""" """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): def _get_cookiejar(self):
"""Return a LWPCookieJar object loaded from our .cookies file. """Return a LWPCookieJar object loaded from our .cookies file.
@@ -102,7 +100,7 @@ class SitesDB:
self._cookiejar.load() self._cookiejar.load()
except LoadError: except LoadError:
pass # File contains bad data, so ignore it completely 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" if e.errno == errno.ENOENT: # "No such file or directory"
# Create the file and restrict reading/writing only to the # Create the file and restrict reading/writing only to the
# owner, so others can't peak at our cookies: # owner, so others can't peak at our cookies:
@@ -149,7 +147,7 @@ class SitesDB:
query1 = "SELECT * FROM sites WHERE site_name = ?" query1 = "SELECT * FROM sites WHERE site_name = ?"
query2 = "SELECT sql_data_key, sql_data_value FROM sql_data WHERE sql_site = ?" 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 = ?" 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: with sqlite.connect(self._sitesdb) as conn:
try: try:
site_data = conn.execute(query1, (name,)).fetchone() site_data = conn.execute(query1, (name,)).fetchone()
@@ -263,7 +261,7 @@ class SitesDB:
if site: if site:
return site[0] return site[0]
else: else:
url = "//{0}.{1}.%".format(lang, project)
url = f"//{lang}.{project}.%"
site = conn.execute(query2, (url,)).fetchone() site = conn.execute(query2, (url,)).fetchone()
return site[0] if site else None return site[0] if site else None
except sqlite.OperationalError: except sqlite.OperationalError:
@@ -322,7 +320,7 @@ class SitesDB:
else: else:
conn.execute("DELETE FROM sql_data WHERE sql_site = ?", (name,)) conn.execute("DELETE FROM sql_data WHERE sql_site = ?", (name,))
conn.execute("DELETE FROM namespaces WHERE ns_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 return True


def get_site(self, name=None, project=None, lang=None): 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) name = self._get_site_name_from_sitesdb(project, lang)
if name: if name:
return self._get_site_object(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) raise SiteNotFoundError(e)


def add_site( def add_site(
@@ -412,7 +410,7 @@ class SitesDB:
if not project or not lang: if not project or not lang:
e = "Without a base_url, both a project and a lang must be given." e = "Without a base_url, both a project and a lang must be given."
raise SiteNotFoundError(e) raise SiteNotFoundError(e)
base_url = "//{0}.{1}.org".format(lang, project)
base_url = f"//{lang}.{project}.org"
cookiejar = self._get_cookiejar() cookiejar = self._get_cookiejar()


config = self.config config = self.config
@@ -443,7 +441,7 @@ class SitesDB:
wait_between_queries=wait_between_queries, 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) self._add_site_to_sitesdb(site)
return self._get_site_object(site.name) 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> # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # 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 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE. # 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 time import gmtime, strptime
from socket import AF_INET, AF_INET6, error as socket_error, inet_pton


from earwigbot.exceptions import UserNotFoundError from earwigbot.exceptions import UserNotFoundError
from earwigbot.wiki import constants from earwigbot.wiki import constants
@@ -30,6 +28,7 @@ from earwigbot.wiki.page import Page


__all__ = ["User"] __all__ = ["User"]



class User: class User:
""" """
**EarwigBot: Wiki Toolset: User** **EarwigBot: Wiki Toolset: User**
@@ -88,11 +87,11 @@ class User:


def __repr__(self): def __repr__(self):
"""Return the canonical string representation of the User.""" """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): def __str__(self):
"""Return a nice string representation of the User.""" """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): def _get_attribute(self, attr):
"""Internally used to get an attribute by name. """Internally used to get an attribute by name.
@@ -106,7 +105,7 @@ class User:
if not hasattr(self, attr): if not hasattr(self, attr):
self._load_attributes() self._load_attributes()
if not self._exists: if not self._exists:
e = "User '{0}' does not exist.".format(self._name)
e = f"User '{self._name}' does not exist."
raise UserNotFoundError(e) raise UserNotFoundError(e)
return getattr(self, attr) return getattr(self, attr)


@@ -117,8 +116,9 @@ class User:
is not defined. This defines it. is not defined. This defines it.
""" """
props = "blockinfo|groups|rights|editcount|registration|emailable|gender" 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] res = result["query"]["users"][0]


# normalize our username in case it was entered oddly # normalize our username in case it was entered oddly
@@ -136,7 +136,7 @@ class User:
self._blockinfo = { self._blockinfo = {
"by": res["blockedby"], "by": res["blockedby"],
"reason": res["blockreason"], "reason": res["blockreason"],
"expiry": res["blockexpiry"]
"expiry": res["blockexpiry"],
} }
except KeyError: except KeyError:
self._blockinfo = False self._blockinfo = False
@@ -280,10 +280,10 @@ class User:
""" """
try: try:
inet_pton(AF_INET, self.name) inet_pton(AF_INET, self.name)
except socket_error:
except OSError:
try: try:
inet_pton(AF_INET6, self.name) inet_pton(AF_INET6, self.name)
except socket_error:
except OSError:
return False return False
return True return True


@@ -302,7 +302,7 @@ class User:
conventions are followed. conventions are followed.
""" """
prefix = self.site.namespace_id_to_name(constants.NS_USER) 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) return Page(self.site, pagename)


def get_talkpage(self): def get_talkpage(self):
@@ -312,5 +312,5 @@ class User:
conventions are followed. conventions are followed.
""" """
prefix = self.site.namespace_id_to_name(constants.NS_USER_TALK) 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) 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 #! /usr/bin/env python
# -*- coding: utf-8 -*-
#
# Copyright (C) 2009-2024 Ben Kurtovic <ben.kurtovic@gmail.com> # Copyright (C) 2009-2024 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # 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 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE. # SOFTWARE.


from setuptools import setup, find_packages
from setuptools import find_packages, setup


from earwigbot import __version__ from earwigbot import __version__


@@ -69,7 +67,7 @@ setup(
url="https://github.com/earwig/earwigbot", url="https://github.com/earwig/earwigbot",
description="EarwigBot is a Python robot that edits Wikipedia and interacts with people over IRC.", description="EarwigBot is a Python robot that edits Wikipedia and interacts with people over IRC.",
long_description=long_docs, 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", keywords="earwig earwigbot irc wikipedia wiki mediawiki",
license="MIT License", license="MIT License",
classifiers=[ 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> # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -39,18 +37,19 @@ Fake objects:
""" """


import logging import logging
from os import path
import re import re
from os import path
from threading import Lock from threading import Lock
from unittest import TestCase from unittest import TestCase


from earwigbot.bot import Bot from earwigbot.bot import Bot
from earwigbot.commands import CommandManager from earwigbot.commands import CommandManager
from earwigbot.config import BotConfig from earwigbot.config import BotConfig
from earwigbot.irc import IRCConnection, Data
from earwigbot.irc import Data, IRCConnection
from earwigbot.tasks import TaskManager from earwigbot.tasks import TaskManager
from earwigbot.wiki import SitesDB from earwigbot.wiki import SitesDB



class CommandTestCase(TestCase): class CommandTestCase(TestCase):
re_sender = re.compile(":(.*?)!(.*?)@(.*?)\Z") re_sender = re.compile(":(.*?)!(.*?)@(.*?)\Z")


@@ -75,17 +74,17 @@ class CommandTestCase(TestCase):
self.assertIn(line, msgs) self.assertIn(line, msgs)


def assertSaid(self, msg): def assertSaid(self, msg):
self.assertSent("PRIVMSG #channel :{0}".format(msg))
self.assertSent(f"PRIVMSG #channel :{msg}")


def assertSaidIn(self, msgs): 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) self.assertSentIn(msgs)


def assertReply(self, msg): def assertReply(self, msg):
self.assertSaid("\x02Foo\x0F: {0}".format(msg))
self.assertSaid(f"\x02Foo\x0f: {msg}")


def assertReplyIn(self, msgs): 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) self.assertSaidIn(msgs)


def maker(self, line, chan, msg=None): def maker(self, line, chan, msg=None):
@@ -98,7 +97,7 @@ class CommandTestCase(TestCase):
return data return data


def make_msg(self, command, *args): 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 = line.strip().split()
line.extend(args) line.extend(args)
return self.maker(line, line[2], " ".join(line[3:])[1:]) 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> # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # 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 earwigbot.commands.calc import Command
from tests import CommandTestCase from tests import CommandTestCase


class TestCalc(CommandTestCase):


class TestCalc(CommandTestCase):
def setUp(self): def setUp(self):
super().setUp(Command) super().setUp(Command)


@@ -55,5 +53,6 @@ class TestCalc(CommandTestCase):
self.command.process(self.make_msg("calc", *q)) self.command.process(self.make_msg("calc", *q))
self.assertReply(test[1]) self.assertReply(test[1])



if __name__ == "__main__": if __name__ == "__main__":
unittest.main(verbosity=2) 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> # Copyright (C) 2009-2015 Ben Kurtovic <ben.kurtovic@gmail.com>
# #
# Permission is hereby granted, free of charge, to any person obtaining a copy # 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 earwigbot.commands.test import Command
from tests import CommandTestCase from tests import CommandTestCase


class TestTest(CommandTestCase):


class TestTest(CommandTestCase):
def setUp(self): def setUp(self):
super().setUp(Command) super().setUp(Command)


@@ -40,10 +38,11 @@ class TestTest(CommandTestCase):
def test_process(self): def test_process(self):
def test(): def test():
self.command.process(self.make_msg("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): for i in range(64):
test() test()



if __name__ == "__main__": if __name__ == "__main__":
unittest.main(verbosity=2) unittest.main(verbosity=2)

Loading…
Cancel
Save