Merge branch 'backend-frameworkification' of bss/dr.botzo into master

This commit is contained in:
Brian S. Stephan 2019-10-11 09:00:37 -05:00 committed by GitLab
commit 42a1efed79
61 changed files with 3040 additions and 780 deletions

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
dr_botzo/_version.py export-subst

25
.gitignore vendored
View File

@ -1,14 +1,8 @@
*.facts .idea/
*.json build/
*.log dist/
*.pyc tags/
*.sqlite3 *.egg-info/
*.swo
*.swp
*.urls
*~
.idea
tags
dr.botzo.data dr.botzo.data
dr.botzo.cfg dr.botzo.cfg
localsettings.py localsettings.py
@ -18,3 +12,12 @@ parsetab.py
megahal.* megahal.*
dr.botzo.log dr.botzo.log
dr.botzo.markov dr.botzo.markov
*.facts
*.json
*.log
*.pyc
*.sqlite3
*.swo
*.swp
*.urls
*~

2
MANIFEST.in Normal file
View File

@ -0,0 +1,2 @@
include versioneer.py
include dr_botzo/_version.py

View File

@ -1,7 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Generated by Django 1.10.5 on 2017-02-23 02:25 # Generated by Django 1.10.5 on 2017-02-23 02:25
from __future__ import unicode_literals
from django.db import migrations, models from django.db import migrations, models

View File

@ -1,7 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Generated by Django 1.10.5 on 2017-02-24 01:06 # Generated by Django 1.10.5 on 2017-02-24 01:06
from __future__ import unicode_literals
from django.db import migrations, models from django.db import migrations, models

View File

@ -1,7 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Generated by Django 1.10.5 on 2017-02-24 01:12 # Generated by Django 1.10.5 on 2017-02-24 01:12
from __future__ import unicode_literals
from django.db import migrations, models from django.db import migrations, models

View File

@ -1,36 +1,28 @@
"""Roll dice when asked, intended for RPGs.""" """Roll dice when asked, intended for RPGs."""
# this breaks yacc, but ply might be happy in py3
#from __future__ import unicode_literals
import logging import logging
import math
import re import re
import random import random
from irc.client import NickMask from irc.client import NickMask
import ply.lex as lex from dice.roller import DiceRoller
import ply.yacc as yacc
from ircbot.lib import Plugin from ircbot.lib import Plugin
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class Dice(Plugin): class Dice(Plugin):
"""Roll simple or complex dice strings.""" """Roll simple or complex dice strings."""
def __init__(self):
"""Set up the plugin."""
super(Dice, self).__init__()
self.roller = DiceRoller()
def start(self): def start(self):
"""Set up the handlers.""" """Set up the handlers."""
self.roller = DiceRoller()
self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!roll\s+(.*)$', self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!roll\s+(.*)$',
self.handle_roll, -20) self.handle_roll, -20)
self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!ctech\s+(.*)$',
self.handle_ctech, -20)
self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!random\s+(.*)$', self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!random\s+(.*)$',
self.handle_random, -20) self.handle_random, -20)
@ -38,9 +30,7 @@ class Dice(Plugin):
def stop(self): def stop(self):
"""Tear down handlers.""" """Tear down handlers."""
self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_roll) self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_roll)
self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_ctech)
self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_random) self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_random)
super(Dice, self).stop() super(Dice, self).stop()
@ -68,368 +58,17 @@ class Dice(Plugin):
dicestr = match.group(1) dicestr = match.group(1)
logger.debug(event.recursing) logger.debug(event.recursing)
try:
reply_str = self.roller.do_roll(dicestr)
except AssertionError as aex:
reply_str = f"Could not roll dice: {aex}"
except ValueError:
reply_str = "Unable to parse roll"
if event.recursing: if event.recursing:
reply = "{0:s}".format(self.roller.do_roll(dicestr)) reply = "{0:s}".format(reply_str)
else: else:
reply = "{0:s}: {1:s}".format(nick, self.roller.do_roll(dicestr)) reply = "{0:s}: {1:s}".format(nick, reply_str)
return self.bot.reply(event, re.sub(r'(\d+)(.*?\s+)(\(.*?\))', r'\1\214\3', reply)) return self.bot.reply(event, re.sub(r'(\d+)(.*?\s+)(\(.*?\))', r'\1\214\3', reply))
def handle_ctech(self, connection, event, match):
"""Handle cthulhutech dice rolls."""
nick = NickMask(event.source).nick
rollitrs = re.split(';\s*', match.group(1))
reply = ""
for count, roll in enumerate(rollitrs):
pattern = '^(\d+)d(?:(\+|\-)(\d+))?(?:\s+(.*))?'
regex = re.compile(pattern)
matches = regex.search(roll)
if matches is not None:
dice = int(matches.group(1))
modifier = 0
if matches.group(2) is not None and matches.group(3) is not None:
if str(matches.group(2)) == '-':
modifier = -1 * int(matches.group(3))
else:
modifier = int(matches.group(3))
result = roll + ': '
rolls = []
for d in range(dice):
rolls.append(random.randint(1, 10))
rolls.sort()
rolls.reverse()
# highest single die method
method1 = rolls[0]
# highest set method
method2 = 0
rolling_sum = 0
for i, r in enumerate(rolls):
# if next roll is same as current, sum and continue, else see if sum is best so far
if i+1 < len(rolls) and rolls[i+1] == r:
if rolling_sum == 0:
rolling_sum = r
rolling_sum += r
else:
if rolling_sum > method2:
method2 = rolling_sum
rolling_sum = 0
# check for set in progress (e.g. lots of 1s)
if rolling_sum > method2:
method2 = rolling_sum
# straight method
method3 = 0
rolling_sum = 0
count = 0
for i, r in enumerate(rolls):
# if next roll is one less as current, sum and continue, else check len and see if sum is best so far
if i+1 < len(rolls) and rolls[i+1] == r-1:
if rolling_sum == 0:
rolling_sum = r
count += 1
rolling_sum += r-1
count += 1
else:
if count >= 3 and rolling_sum > method3:
method3 = rolling_sum
rolling_sum = 0
# check for straight in progress (e.g. straight ending in 1)
if count >= 3 and rolling_sum > method3:
method3 = rolling_sum
# get best roll
best = max([method1, method2, method3])
# check for critical failure
botch = False
ones = 0
for r in rolls:
if r == 1:
ones += 1
if ones >= math.ceil(float(len(rolls))/2):
botch = True
if botch:
result += 'BOTCH'
else:
result += str(best + modifier)
rollres = ''
for i,r in enumerate(rolls):
rollres += str(r)
if i is not len(rolls)-1:
rollres += ','
result += ' [' + rollres
if modifier != 0:
if modifier > 0:
result += ' +' + str(modifier)
else:
result += ' -' + str(modifier * -1)
result += ']'
reply += result
if count is not len(rollitrs)-1:
reply += "; "
if reply is not "":
msg = "{0:s}: {1:s}".format(nick, reply)
return self.bot.reply(event, msg)
class DiceRoller(object):
tokens = ['NUMBER', 'TEXT', 'ROLLSEP']
literals = ['#', '/', '+', '-', 'd']
t_TEXT = r'\s+[^;]+'
t_ROLLSEP = r';\s*'
def build(self):
lex.lex(module=self)
yacc.yacc(module=self)
def t_NUMBER(self, t):
r'\d+'
t.value = int(t.value)
return t
def t_error(self, t):
t.lexer.skip(1)
precedence = (
('left', 'ROLLSEP'),
('left', '+', '-'),
('right', 'd'),
('left', '#'),
('left', '/')
)
output = ""
def roll_dice(self, keep, dice, size):
"""Takes the parsed dice string for a single roll (eg 3/4d20) and performs
the actual roll. Returns a string representing the result.
"""
a = list(range(dice))
for i in range(dice):
a[i] = random.randint(1, size)
if keep != dice:
b = sorted(a, reverse=True)
b = b[0:keep]
else:
b = a
total = sum(b)
outstr = "[" + ",".join(str(i) for i in a) + "]"
return (total, outstr)
def process_roll(self, trials, mods, comment):
"""Processes rolls coming from the parser.
This generates the inputs for the roll_dice() command, and returns
the full string representing the whole current dice string (the part
up to a semicolon or end of line).
"""
output = ""
repeat = 1
if trials != None:
repeat = trials
for i in range(repeat):
mode = 1
total = 0
curr_str = ""
if i > 0:
output += ", "
for m in mods:
keep = 0
dice = 1
res = 0
# if m is a tuple, then it is a die roll
# m[0] = (keep, num dice)
# m[1] = num faces on the die
if type(m) == tuple:
if m[0] != None:
if m[0][0] != None:
keep = m[0][0]
dice = m[0][1]
size = m[1]
if keep > dice or keep == 0:
keep = dice
if size < 1:
output = "# of sides for die is incorrect: %d" % size
return output
if dice < 1:
output = "# of dice is incorrect: %d" % dice
return output
res = self.roll_dice(keep, dice, size)
curr_str += "%d%s" % (res[0], res[1])
res = res[0]
elif m == "+":
mode = 1
curr_str += "+"
elif m == "-":
mode = -1
curr_str += "-"
else:
res = m
curr_str += str(m)
total += mode * res
if repeat == 1:
if comment != None:
output = "%d %s (%s)" % (total, comment.strip(), curr_str)
else:
output = "%d (%s)" % (total, curr_str)
else:
output += "%d (%s)" % (total, curr_str)
if i == repeat - 1:
if comment != None:
output += " (%s)" % (comment.strip())
return output
def p_roll_r(self, p):
# Chain rolls together.
# General idea I had when creating this grammar: A roll string is a chain
# of modifiers, which may be repeated for a certain number of trials. It can
# have a comment that describes the roll
# Multiple roll strings can be chained with semicolon
'roll : roll ROLLSEP roll'
global output
p[0] = p[1] + "; " + p[3]
output = p[0]
def p_roll(self, p):
# Parse a basic roll string.
'roll : trial modifier comment'
global output
mods = []
if type(p[2]) == list:
mods = p[2]
else:
mods = [p[2]]
p[0] = self.process_roll(p[1], mods, p[3])
output = p[0]
def p_roll_no_trials(self, p):
# Parse a roll string without trials.
'roll : modifier comment'
global output
mods = []
if type(p[1]) == list:
mods = p[1]
else:
mods = [p[1]]
p[0] = self.process_roll(None, mods, p[2])
output = p[0]
def p_comment(self, p):
# Parse a comment.
'''comment : TEXT
|'''
if len(p) == 2:
p[0] = p[1]
else:
p[0] = None
def p_modifier(self, p):
# Parse a modifier on a roll string.
'''modifier : modifier "+" modifier
| modifier "-" modifier'''
# Use append to prevent nested lists (makes dealing with this easier)
if type(p[1]) == list:
p[1].append(p[2])
p[1].append(p[3])
p[0] = p[1]
elif type(p[3]) == list:
p[3].insert(0, p[2])
p[3].insert(0, p[1])
p[0] = p[3]
else:
p[0] = [p[1], p[2], p[3]]
def p_die(self, p):
# Return the left side before the "d", and the number of faces.
'modifier : left NUMBER'
p[0] = (p[1], p[2])
def p_die_num(self, p):
'modifier : NUMBER'
p[0] = p[1]
def p_left(self, p):
# Parse the number of dice we are rolling, and how many we are keeping.
'left : keep dice'
if p[1] == None:
p[0] = [None, p[2]]
else:
p[0] = [p[1], p[2]]
def p_left_all(self, p):
'left : dice'
p[0] = [None, p[1]]
def p_left_e(self, p):
'left :'
p[0] = None
def p_total(self, p):
'trial : NUMBER "#"'
if len(p) > 1:
p[0] = p[1]
else:
p[0] = None
def p_keep(self, p):
'keep : NUMBER "/"'
if p[1] != None:
p[0] = p[1]
else:
p[0] = None
def p_dice(self, p):
'dice : NUMBER "d"'
p[0] = p[1]
def p_dice_one(self, p):
'dice : "d"'
p[0] = 1
def p_error(self, p):
# Provide the user with something (albeit not much) when the roll can't be parsed.
global output
output = "Unable to parse roll"
def get_result(self):
global output
return output
def do_roll(self, dicestr):
"""
Roll some dice and get the result (with broken out rolls).
Keyword arguments:
dicestr - format:
N#X/YdS+M label
N#: do the following roll N times (optional)
X/: take the top X rolls of the Y times rolled (optional)
Y : roll the die specified Y times (optional, defaults to 1)
dS: roll a S-sided die
+M: add M to the result (-M for subtraction) (optional)
"""
self.build()
yacc.parse(dicestr)
return self.get_result()
plugin = Dice plugin = Dice

260
dice/roller.py Normal file
View File

@ -0,0 +1,260 @@
"""Dice rollers used by the views, bots, etc."""
import logging
import random
import ply.lex as lex
import ply.yacc as yacc
logger = logging.getLogger(__name__)
class DiceRoller(object):
tokens = ['NUMBER', 'TEXT', 'ROLLSEP']
literals = ['#', '/', '+', '-', 'd']
t_TEXT = r'\s+[^;]+'
t_ROLLSEP = r';\s*'
def build(self):
lex.lex(module=self)
yacc.yacc(module=self)
def t_NUMBER(self, t):
r'\d+'
t.value = int(t.value)
return t
def t_error(self, t):
t.lexer.skip(1)
precedence = (
('left', 'ROLLSEP'),
('left', '+', '-'),
('right', 'd'),
('left', '#'),
('left', '/')
)
output = ""
def roll_dice(self, keep, dice, size):
"""Takes the parsed dice string for a single roll (eg 3/4d20) and performs
the actual roll. Returns a string representing the result.
"""
a = list(range(dice))
for i in range(dice):
a[i] = random.randint(1, size)
if keep != dice:
b = sorted(a, reverse=True)
b = b[0:keep]
else:
b = a
total = sum(b)
outstr = "[" + ",".join(str(i) for i in a) + "]"
return (total, outstr)
def process_roll(self, trials, mods, comment):
"""Processes rolls coming from the parser.
This generates the inputs for the roll_dice() command, and returns
the full string representing the whole current dice string (the part
up to a semicolon or end of line).
"""
rolls = mods[0][0][1] if mods[0][0][1] else 1
if trials:
assert trials <= 10, "Too many rolls (max: 10)."
assert rolls <= 50, "Too many dice (max: 50) in roll."
output = ""
repeat = 1
if trials is not None:
repeat = trials
for i in range(repeat):
mode = 1
total = 0
curr_str = ""
if i > 0:
output += ", "
for m in mods:
keep = 0
dice = 1
res = 0
# if m is a tuple, then it is a die roll
# m[0] = (keep, num dice)
# m[1] = num faces on the die
if type(m) == tuple:
if m[0] is not None:
if m[0][0] is not None:
keep = m[0][0]
dice = m[0][1]
size = m[1]
if keep > dice or keep == 0:
keep = dice
assert size >= 1, f"Die must have at least one side."
assert dice >= 1, f"At least one die must be rolled."
res = self.roll_dice(keep, dice, size)
curr_str += "%d%s" % (res[0], res[1])
res = res[0]
elif m == "+":
mode = 1
curr_str += "+"
elif m == "-":
mode = -1
curr_str += "-"
else:
res = m
curr_str += str(m)
total += mode * res
if repeat == 1:
if comment is not None:
output = "%d %s (%s)" % (total, comment.strip(), curr_str)
else:
output = "%d (%s)" % (total, curr_str)
else:
output += "%d (%s)" % (total, curr_str)
if i == repeat - 1:
if comment is not None:
output += " (%s)" % (comment.strip())
return output
def p_roll_r(self, p):
# Chain rolls together.
# General idea I had when creating this grammar: A roll string is a chain
# of modifiers, which may be repeated for a certain number of trials. It can
# have a comment that describes the roll
# Multiple roll strings can be chained with semicolon
'roll : roll ROLLSEP roll'
global output
p[0] = p[1] + "; " + p[3]
output = p[0]
def p_roll(self, p):
# Parse a basic roll string.
'roll : trial modifier comment'
global output
mods = []
if type(p[2]) == list:
mods = p[2]
else:
mods = [p[2]]
p[0] = self.process_roll(p[1], mods, p[3])
output = p[0]
def p_roll_no_trials(self, p):
# Parse a roll string without trials.
'roll : modifier comment'
global output
mods = []
if type(p[1]) == list:
mods = p[1]
else:
mods = [p[1]]
p[0] = self.process_roll(None, mods, p[2])
output = p[0]
def p_comment(self, p):
# Parse a comment.
'''comment : TEXT
|'''
if len(p) == 2:
p[0] = p[1]
else:
p[0] = None
def p_modifier(self, p):
# Parse a modifier on a roll string.
'''modifier : modifier "+" modifier
| modifier "-" modifier'''
# Use append to prevent nested lists (makes dealing with this easier)
if type(p[1]) == list:
p[1].append(p[2])
p[1].append(p[3])
p[0] = p[1]
elif type(p[3]) == list:
p[3].insert(0, p[2])
p[3].insert(0, p[1])
p[0] = p[3]
else:
p[0] = [p[1], p[2], p[3]]
def p_die(self, p):
# Return the left side before the "d", and the number of faces.
'modifier : left NUMBER'
p[0] = (p[1], p[2])
def p_die_num(self, p):
'modifier : NUMBER'
p[0] = p[1]
def p_left(self, p):
# Parse the number of dice we are rolling, and how many we are keeping.
'left : keep dice'
if p[1] == None:
p[0] = [None, p[2]]
else:
p[0] = [p[1], p[2]]
def p_left_all(self, p):
'left : dice'
p[0] = [None, p[1]]
def p_left_e(self, p):
'left :'
p[0] = None
def p_total(self, p):
'trial : NUMBER "#"'
if len(p) > 1:
p[0] = p[1]
else:
p[0] = None
def p_keep(self, p):
'keep : NUMBER "/"'
if p[1] != None:
p[0] = p[1]
else:
p[0] = None
def p_dice(self, p):
'dice : NUMBER "d"'
p[0] = p[1]
def p_dice_one(self, p):
'dice : "d"'
p[0] = 1
def p_error(self, p):
"""Raise ValueError on unparseable strings."""
raise ValueError("Error occurred in parser")
def get_result(self):
global output
return output
def do_roll(self, dicestr):
"""
Roll some dice and get the result (with broken out rolls).
Keyword arguments:
dicestr - format:
N#X/YdS+M label
N#: do the following roll N times (optional)
X/: take the top X rolls of the Y times rolled (optional)
Y : roll the die specified Y times (optional, defaults to 1)
dS: roll a S-sided die
+M: add M to the result (-M for subtraction) (optional)
"""
self.build()
yacc.parse(dicestr)
return self.get_result()

8
dice/urls.py Normal file
View File

@ -0,0 +1,8 @@
"""URL patterns for the dice views."""
from django.urls import path
from dice.views import rpc_roll_dice
urlpatterns = [
path('rpc/roll/', rpc_roll_dice, name='dice_rpc_roll_dice'),
]

36
dice/views.py Normal file
View File

@ -0,0 +1,36 @@
"""Views for rolling dice."""
import json
import logging
from rest_framework.authentication import BasicAuthentication
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.decorators import api_view, authentication_classes, permission_classes
from dice.roller import DiceRoller
logger = logging.getLogger(__name__)
roller = DiceRoller()
@api_view(['POST'])
@authentication_classes((BasicAuthentication, ))
@permission_classes((IsAuthenticated, ))
def rpc_roll_dice(request):
"""Get a dice string from the client, roll the dice, and give the result."""
if request.method != 'POST':
return Response({'detail': "Supported method: POST."}, status=405)
try:
roll_data = json.loads(request.body)
dice_str = roll_data['dice']
except (json.decoder.JSONDecodeError, KeyError):
return Response({'detail': "Request must be JSON with a 'dice' parameter."}, status=400)
try:
result_str = roller.do_roll(dice_str)
return Response({'dice': dice_str, 'result': result_str})
except AssertionError as aex:
return Response({'detail': f"Could not roll dice: {aex}", 'dice': dice_str}, status=400)
except ValueError:
return Response({'detail': f"Could not parse requested dice '{dice_str}'.", 'dice': dice_str}, status=400)

View File

@ -29,6 +29,6 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='dispatcheraction', model_name='dispatcheraction',
name='dispatcher', name='dispatcher',
field=models.ForeignKey(to='dispatch.Dispatcher'), field=models.ForeignKey(to='dispatch.Dispatcher', on_delete=models.CASCADE),
), ),
] ]

View File

@ -17,6 +17,6 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='dispatcheraction', model_name='dispatcheraction',
name='dispatcher', name='dispatcher',
field=models.ForeignKey(related_name='actions', to='dispatch.Dispatcher'), field=models.ForeignKey(related_name='actions', to='dispatch.Dispatcher', on_delete=models.CASCADE),
), ),
] ]

View File

@ -1,6 +1,4 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models from django.db import migrations, models

View File

@ -36,7 +36,7 @@ class DispatcherAction(models.Model):
(FILE_TYPE, "Write to file"), (FILE_TYPE, "Write to file"),
) )
dispatcher = models.ForeignKey('Dispatcher', related_name='actions') dispatcher = models.ForeignKey('Dispatcher', related_name='actions', on_delete=models.CASCADE)
type = models.CharField(max_length=16, choices=TYPE_CHOICES) type = models.CharField(max_length=16, choices=TYPE_CHOICES)
destination = models.CharField(max_length=200) destination = models.CharField(max_length=200)
include_key = models.BooleanField(default=False) include_key = models.BooleanField(default=False)

View File

@ -0,0 +1,4 @@
"""Set up the version number of the bot."""
from ._version import get_versions
__version__ = get_versions()['version']
del get_versions

520
dr_botzo/_version.py Normal file
View File

@ -0,0 +1,520 @@
# This file helps to compute a version number in source trees obtained from
# git-archive tarball (such as those provided by githubs download-from-tag
# feature). Distribution tarballs (built by setup.py sdist) and build
# directories (produced by setup.py build) will contain a much shorter file
# that just contains the computed version number.
# This file is released into the public domain. Generated by
# versioneer-0.18 (https://github.com/warner/python-versioneer)
"""Git implementation of _version.py."""
import errno
import os
import re
import subprocess
import sys
def get_keywords():
"""Get the keywords needed to look up the version information."""
# these strings will be replaced by git during git-archive.
# setup.py/versioneer.py will grep for the variable names, so they must
# each be defined on a line of their own. _version.py will just call
# get_keywords().
git_refnames = "$Format:%d$"
git_full = "$Format:%H$"
git_date = "$Format:%ci$"
keywords = {"refnames": git_refnames, "full": git_full, "date": git_date}
return keywords
class VersioneerConfig:
"""Container for Versioneer configuration parameters."""
def get_config():
"""Create, populate and return the VersioneerConfig() object."""
# these strings are filled in when 'setup.py versioneer' creates
# _version.py
cfg = VersioneerConfig()
cfg.VCS = "git"
cfg.style = "pep440"
cfg.tag_prefix = "v"
cfg.parentdir_prefix = "None"
cfg.versionfile_source = "dr_botzo/_version.py"
cfg.verbose = False
return cfg
class NotThisMethod(Exception):
"""Exception raised if a method is not valid for the current scenario."""
LONG_VERSION_PY = {}
HANDLERS = {}
def register_vcs_handler(vcs, method): # decorator
"""Decorator to mark a method as the handler for a particular VCS."""
def decorate(f):
"""Store f in HANDLERS[vcs][method]."""
if vcs not in HANDLERS:
HANDLERS[vcs] = {}
HANDLERS[vcs][method] = f
return f
return decorate
def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False,
env=None):
"""Call the given command(s)."""
assert isinstance(commands, list)
p = None
for c in commands:
try:
dispcmd = str([c] + args)
# remember shell=False, so use git.cmd on windows, not just git
p = subprocess.Popen([c] + args, cwd=cwd, env=env,
stdout=subprocess.PIPE,
stderr=(subprocess.PIPE if hide_stderr
else None))
break
except EnvironmentError:
e = sys.exc_info()[1]
if e.errno == errno.ENOENT:
continue
if verbose:
print("unable to run %s" % dispcmd)
print(e)
return None, None
else:
if verbose:
print("unable to find command, tried %s" % (commands,))
return None, None
stdout = p.communicate()[0].strip()
if sys.version_info[0] >= 3:
stdout = stdout.decode()
if p.returncode != 0:
if verbose:
print("unable to run %s (error)" % dispcmd)
print("stdout was %s" % stdout)
return None, p.returncode
return stdout, p.returncode
def versions_from_parentdir(parentdir_prefix, root, verbose):
"""Try to determine the version from the parent directory name.
Source tarballs conventionally unpack into a directory that includes both
the project name and a version string. We will also support searching up
two directory levels for an appropriately named parent directory
"""
rootdirs = []
for i in range(3):
dirname = os.path.basename(root)
if dirname.startswith(parentdir_prefix):
return {"version": dirname[len(parentdir_prefix):],
"full-revisionid": None,
"dirty": False, "error": None, "date": None}
else:
rootdirs.append(root)
root = os.path.dirname(root) # up a level
if verbose:
print("Tried directories %s but none started with prefix %s" %
(str(rootdirs), parentdir_prefix))
raise NotThisMethod("rootdir doesn't start with parentdir_prefix")
@register_vcs_handler("git", "get_keywords")
def git_get_keywords(versionfile_abs):
"""Extract version information from the given file."""
# the code embedded in _version.py can just fetch the value of these
# keywords. When used from setup.py, we don't want to import _version.py,
# so we do it with a regexp instead. This function is not used from
# _version.py.
keywords = {}
try:
f = open(versionfile_abs, "r")
for line in f.readlines():
if line.strip().startswith("git_refnames ="):
mo = re.search(r'=\s*"(.*)"', line)
if mo:
keywords["refnames"] = mo.group(1)
if line.strip().startswith("git_full ="):
mo = re.search(r'=\s*"(.*)"', line)
if mo:
keywords["full"] = mo.group(1)
if line.strip().startswith("git_date ="):
mo = re.search(r'=\s*"(.*)"', line)
if mo:
keywords["date"] = mo.group(1)
f.close()
except EnvironmentError:
pass
return keywords
@register_vcs_handler("git", "keywords")
def git_versions_from_keywords(keywords, tag_prefix, verbose):
"""Get version information from git keywords."""
if not keywords:
raise NotThisMethod("no keywords at all, weird")
date = keywords.get("date")
if date is not None:
# git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant
# datestamp. However we prefer "%ci" (which expands to an "ISO-8601
# -like" string, which we must then edit to make compliant), because
# it's been around since git-1.5.3, and it's too difficult to
# discover which version we're using, or to work around using an
# older one.
date = date.strip().replace(" ", "T", 1).replace(" ", "", 1)
refnames = keywords["refnames"].strip()
if refnames.startswith("$Format"):
if verbose:
print("keywords are unexpanded, not using")
raise NotThisMethod("unexpanded keywords, not a git-archive tarball")
refs = set([r.strip() for r in refnames.strip("()").split(",")])
# starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of
# just "foo-1.0". If we see a "tag: " prefix, prefer those.
TAG = "tag: "
tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)])
if not tags:
# Either we're using git < 1.8.3, or there really are no tags. We use
# a heuristic: assume all version tags have a digit. The old git %d
# expansion behaves like git log --decorate=short and strips out the
# refs/heads/ and refs/tags/ prefixes that would let us distinguish
# between branches and tags. By ignoring refnames without digits, we
# filter out many common branch names like "release" and
# "stabilization", as well as "HEAD" and "master".
tags = set([r for r in refs if re.search(r'\d', r)])
if verbose:
print("discarding '%s', no digits" % ",".join(refs - tags))
if verbose:
print("likely tags: %s" % ",".join(sorted(tags)))
for ref in sorted(tags):
# sorting will prefer e.g. "2.0" over "2.0rc1"
if ref.startswith(tag_prefix):
r = ref[len(tag_prefix):]
if verbose:
print("picking %s" % r)
return {"version": r,
"full-revisionid": keywords["full"].strip(),
"dirty": False, "error": None,
"date": date}
# no suitable tags, so version is "0+unknown", but full hex is still there
if verbose:
print("no suitable tags, using unknown + full revision id")
return {"version": "0+unknown",
"full-revisionid": keywords["full"].strip(),
"dirty": False, "error": "no suitable tags", "date": None}
@register_vcs_handler("git", "pieces_from_vcs")
def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command):
"""Get version from 'git describe' in the root of the source tree.
This only gets called if the git-archive 'subst' keywords were *not*
expanded, and _version.py hasn't already been rewritten with a short
version string, meaning we're inside a checked out source tree.
"""
GITS = ["git"]
if sys.platform == "win32":
GITS = ["git.cmd", "git.exe"]
out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root,
hide_stderr=True)
if rc != 0:
if verbose:
print("Directory %s not under git control" % root)
raise NotThisMethod("'git rev-parse --git-dir' returned error")
# if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty]
# if there isn't one, this yields HEX[-dirty] (no NUM)
describe_out, rc = run_command(GITS, ["describe", "--tags", "--dirty",
"--always", "--long",
"--match", "%s*" % tag_prefix],
cwd=root)
# --long was added in git-1.5.5
if describe_out is None:
raise NotThisMethod("'git describe' failed")
describe_out = describe_out.strip()
full_out, rc = run_command(GITS, ["rev-parse", "HEAD"], cwd=root)
if full_out is None:
raise NotThisMethod("'git rev-parse' failed")
full_out = full_out.strip()
pieces = {}
pieces["long"] = full_out
pieces["short"] = full_out[:7] # maybe improved later
pieces["error"] = None
# parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty]
# TAG might have hyphens.
git_describe = describe_out
# look for -dirty suffix
dirty = git_describe.endswith("-dirty")
pieces["dirty"] = dirty
if dirty:
git_describe = git_describe[:git_describe.rindex("-dirty")]
# now we have TAG-NUM-gHEX or HEX
if "-" in git_describe:
# TAG-NUM-gHEX
mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe)
if not mo:
# unparseable. Maybe git-describe is misbehaving?
pieces["error"] = ("unable to parse git-describe output: '%s'"
% describe_out)
return pieces
# tag
full_tag = mo.group(1)
if not full_tag.startswith(tag_prefix):
if verbose:
fmt = "tag '%s' doesn't start with prefix '%s'"
print(fmt % (full_tag, tag_prefix))
pieces["error"] = ("tag '%s' doesn't start with prefix '%s'"
% (full_tag, tag_prefix))
return pieces
pieces["closest-tag"] = full_tag[len(tag_prefix):]
# distance: number of commits since tag
pieces["distance"] = int(mo.group(2))
# commit: short hex revision ID
pieces["short"] = mo.group(3)
else:
# HEX: no tags
pieces["closest-tag"] = None
count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"],
cwd=root)
pieces["distance"] = int(count_out) # total number of commits
# commit date: see ISO-8601 comment in git_versions_from_keywords()
date = run_command(GITS, ["show", "-s", "--format=%ci", "HEAD"],
cwd=root)[0].strip()
pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1)
return pieces
def plus_or_dot(pieces):
"""Return a + if we don't already have one, else return a ."""
if "+" in pieces.get("closest-tag", ""):
return "."
return "+"
def render_pep440(pieces):
"""Build up version string, with post-release "local version identifier".
Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you
get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty
Exceptions:
1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty]
"""
if pieces["closest-tag"]:
rendered = pieces["closest-tag"]
if pieces["distance"] or pieces["dirty"]:
rendered += plus_or_dot(pieces)
rendered += "%d.g%s" % (pieces["distance"], pieces["short"])
if pieces["dirty"]:
rendered += ".dirty"
else:
# exception #1
rendered = "0+untagged.%d.g%s" % (pieces["distance"],
pieces["short"])
if pieces["dirty"]:
rendered += ".dirty"
return rendered
def render_pep440_pre(pieces):
"""TAG[.post.devDISTANCE] -- No -dirty.
Exceptions:
1: no tags. 0.post.devDISTANCE
"""
if pieces["closest-tag"]:
rendered = pieces["closest-tag"]
if pieces["distance"]:
rendered += ".post.dev%d" % pieces["distance"]
else:
# exception #1
rendered = "0.post.dev%d" % pieces["distance"]
return rendered
def render_pep440_post(pieces):
"""TAG[.postDISTANCE[.dev0]+gHEX] .
The ".dev0" means dirty. Note that .dev0 sorts backwards
(a dirty tree will appear "older" than the corresponding clean one),
but you shouldn't be releasing software with -dirty anyways.
Exceptions:
1: no tags. 0.postDISTANCE[.dev0]
"""
if pieces["closest-tag"]:
rendered = pieces["closest-tag"]
if pieces["distance"] or pieces["dirty"]:
rendered += ".post%d" % pieces["distance"]
if pieces["dirty"]:
rendered += ".dev0"
rendered += plus_or_dot(pieces)
rendered += "g%s" % pieces["short"]
else:
# exception #1
rendered = "0.post%d" % pieces["distance"]
if pieces["dirty"]:
rendered += ".dev0"
rendered += "+g%s" % pieces["short"]
return rendered
def render_pep440_old(pieces):
"""TAG[.postDISTANCE[.dev0]] .
The ".dev0" means dirty.
Eexceptions:
1: no tags. 0.postDISTANCE[.dev0]
"""
if pieces["closest-tag"]:
rendered = pieces["closest-tag"]
if pieces["distance"] or pieces["dirty"]:
rendered += ".post%d" % pieces["distance"]
if pieces["dirty"]:
rendered += ".dev0"
else:
# exception #1
rendered = "0.post%d" % pieces["distance"]
if pieces["dirty"]:
rendered += ".dev0"
return rendered
def render_git_describe(pieces):
"""TAG[-DISTANCE-gHEX][-dirty].
Like 'git describe --tags --dirty --always'.
Exceptions:
1: no tags. HEX[-dirty] (note: no 'g' prefix)
"""
if pieces["closest-tag"]:
rendered = pieces["closest-tag"]
if pieces["distance"]:
rendered += "-%d-g%s" % (pieces["distance"], pieces["short"])
else:
# exception #1
rendered = pieces["short"]
if pieces["dirty"]:
rendered += "-dirty"
return rendered
def render_git_describe_long(pieces):
"""TAG-DISTANCE-gHEX[-dirty].
Like 'git describe --tags --dirty --always -long'.
The distance/hash is unconditional.
Exceptions:
1: no tags. HEX[-dirty] (note: no 'g' prefix)
"""
if pieces["closest-tag"]:
rendered = pieces["closest-tag"]
rendered += "-%d-g%s" % (pieces["distance"], pieces["short"])
else:
# exception #1
rendered = pieces["short"]
if pieces["dirty"]:
rendered += "-dirty"
return rendered
def render(pieces, style):
"""Render the given version pieces into the requested style."""
if pieces["error"]:
return {"version": "unknown",
"full-revisionid": pieces.get("long"),
"dirty": None,
"error": pieces["error"],
"date": None}
if not style or style == "default":
style = "pep440" # the default
if style == "pep440":
rendered = render_pep440(pieces)
elif style == "pep440-pre":
rendered = render_pep440_pre(pieces)
elif style == "pep440-post":
rendered = render_pep440_post(pieces)
elif style == "pep440-old":
rendered = render_pep440_old(pieces)
elif style == "git-describe":
rendered = render_git_describe(pieces)
elif style == "git-describe-long":
rendered = render_git_describe_long(pieces)
else:
raise ValueError("unknown style '%s'" % style)
return {"version": rendered, "full-revisionid": pieces["long"],
"dirty": pieces["dirty"], "error": None,
"date": pieces.get("date")}
def get_versions():
"""Get version information or return default if unable to do so."""
# I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have
# __file__, we can work backwards from there to the root. Some
# py2exe/bbfreeze/non-CPython implementations don't do __file__, in which
# case we can only use expanded keywords.
cfg = get_config()
verbose = cfg.verbose
try:
return git_versions_from_keywords(get_keywords(), cfg.tag_prefix,
verbose)
except NotThisMethod:
pass
try:
root = os.path.realpath(__file__)
# versionfile_source is the relative path from the top of the source
# tree (where the .git directory might live) to this file. Invert
# this to find the root from __file__.
for i in cfg.versionfile_source.split('/'):
root = os.path.dirname(root)
except NameError:
return {"version": "0+unknown", "full-revisionid": None,
"dirty": None,
"error": "unable to find root of source tree",
"date": None}
try:
pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose)
return render(pieces, cfg.style)
except NotThisMethod:
pass
try:
if cfg.parentdir_prefix:
return versions_from_parentdir(cfg.parentdir_prefix, root, verbose)
except NotThisMethod:
pass
return {"version": "0+unknown", "full-revisionid": None,
"dirty": None,
"error": "unable to compute version", "date": None}

View File

@ -11,7 +11,7 @@ https://docs.djangoproject.com/en/1.6/ref/settings/
# Build paths inside the project like this: os.path.join(BASE_DIR, ...) # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
import os import os
from django.core.urlresolvers import reverse_lazy from django.urls import reverse_lazy
BASE_DIR = os.path.dirname(os.path.dirname(__file__)) BASE_DIR = os.path.dirname(os.path.dirname(__file__))
@ -57,12 +57,11 @@ INSTALLED_APPS = (
'twitter', 'twitter',
) )
MIDDLEWARE_CLASSES = ( MIDDLEWARE = (
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',

View File

@ -11,14 +11,16 @@ admin.sites.site = admin.site
admin.autodiscover() admin.autodiscover()
urlpatterns = [ urlpatterns = [
url(r'^$', TemplateView.as_view(template_name='index.html'), name='home'), url(r'^$', TemplateView.as_view(template_name='index.html'), name='index'),
url(r'^dice/', include('dice.urls')),
url(r'^dispatch/', include('dispatch.urls')), url(r'^dispatch/', include('dispatch.urls')),
url(r'^itemsets/', include('facts.urls')), url(r'^itemsets/', include('facts.urls')),
url(r'^karma/', include('karma.urls')), url(r'^karma/', include('karma.urls')),
url(r'^markov/', include('markov.urls')), url(r'^markov/', include('markov.urls')),
url(r'^races/', include('races.urls')), url(r'^races/', include('races.urls')),
url(r'^weather/', include('weather.urls')),
url(r'^accounts/', include('registration.backends.default.urls')), url(r'^accounts/', include('registration.backends.default.urls')),
url(r'^admin/', include(admin.site.urls)), url(r'^admin/', admin.site.urls),
] ]

View File

@ -26,6 +26,6 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='fact', model_name='fact',
name='category', name='category',
field=models.ForeignKey(to='facts.FactCategory'), field=models.ForeignKey(to='facts.FactCategory', on_delete=models.CASCADE),
), ),
] ]

View File

@ -1,7 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Generated by Django 1.10.5 on 2017-02-11 15:00 # Generated by Django 1.10.5 on 2017-02-11 15:00
from __future__ import unicode_literals
from django.db import migrations, models from django.db import migrations, models

View File

@ -52,7 +52,7 @@ class Fact(models.Model):
"""Define facts.""" """Define facts."""
fact = models.TextField() fact = models.TextField()
category = models.ForeignKey(FactCategory) category = models.ForeignKey(FactCategory, on_delete=models.CASCADE)
nickmask = models.CharField(max_length=200, default='', blank=True) nickmask = models.CharField(max_length=200, default='', blank=True)
time = models.DateTimeField(auto_now_add=True) time = models.DateTimeField(auto_now_add=True)

View File

@ -1,6 +1,4 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models from django.db import migrations, models
@ -27,7 +25,7 @@ class Migration(migrations.Migration):
('code_reviews_necessary', models.PositiveSmallIntegerField(default=0)), ('code_reviews_necessary', models.PositiveSmallIntegerField(default=0)),
('code_reviewers', models.TextField(blank=True, default='')), ('code_reviewers', models.TextField(blank=True, default='')),
('code_review_final_merge_assignees', models.TextField(blank=True, default='')), ('code_review_final_merge_assignees', models.TextField(blank=True, default='')),
('gitlab_config', models.ForeignKey(to='gitlab_bot.GitlabConfig', null=True)), ('gitlab_config', models.ForeignKey(to='gitlab_bot.GitlabConfig', null=True, on_delete=models.CASCADE)),
], ],
), ),
] ]

View File

@ -1,6 +1,4 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models from django.db import migrations, models

View File

@ -23,7 +23,7 @@ class GitlabProjectConfig(models.Model):
"""Maintain settings for a particular project in GitLab.""" """Maintain settings for a particular project in GitLab."""
gitlab_config = models.ForeignKey('GitlabConfig', null=True) gitlab_config = models.ForeignKey('GitlabConfig', null=True, on_delete=models.CASCADE)
project_id = models.CharField(max_length=64, unique=True) project_id = models.CharField(max_length=64, unique=True)
manage_merge_request_code_reviews = models.BooleanField(default=False) manage_merge_request_code_reviews = models.BooleanField(default=False)

View File

@ -15,7 +15,7 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='botadmin', model_name='botadmin',
name='user', name='user',
field=models.ForeignKey(default=1, to=settings.AUTH_USER_MODEL), field=models.ForeignKey(default=1, to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE),
preserve_default=False, preserve_default=False,
), ),
] ]

View File

@ -1,6 +1,4 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models from django.db import migrations, models

View File

@ -47,7 +47,7 @@ class BotUser(models.Model):
"""Configure bot users, which can do things through the bot and standard Django auth.""" """Configure bot users, which can do things through the bot and standard Django auth."""
nickmask = models.CharField(max_length=200, unique=True) nickmask = models.CharField(max_length=200, unique=True)
user = models.ForeignKey(settings.AUTH_USER_MODEL) user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
class Meta: class Meta:
permissions = ( permissions = (

View File

@ -23,7 +23,7 @@ class Migration(migrations.Migration):
('delta', models.SmallIntegerField()), ('delta', models.SmallIntegerField()),
('nickmask', models.CharField(default='', max_length=200, blank=True)), ('nickmask', models.CharField(default='', max_length=200, blank=True)),
('created', models.DateTimeField(auto_now_add=True)), ('created', models.DateTimeField(auto_now_add=True)),
('key', models.ForeignKey(to='karma.KarmaKey')), ('key', models.ForeignKey(to='karma.KarmaKey', on_delete=models.CASCADE)),
], ],
), ),
] ]

View File

@ -115,7 +115,7 @@ class KarmaLogEntryManager(models.Manager):
class KarmaLogEntry(models.Model): class KarmaLogEntry(models.Model):
"""Track each karma increment/decrement.""" """Track each karma increment/decrement."""
key = models.ForeignKey('KarmaKey') key = models.ForeignKey('KarmaKey', on_delete=models.CASCADE)
delta = models.SmallIntegerField() delta = models.SmallIntegerField()
nickmask = models.CharField(max_length=200, default='', blank=True) nickmask = models.CharField(max_length=200, default='', blank=True)
created = models.DateTimeField(auto_now_add=True) created = models.DateTimeField(auto_now_add=True)

View File

@ -164,6 +164,10 @@ def get_word_out_of_states(states, backwards=False):
new_word = '' new_word = ''
running = 0 running = 0
count_sum = states.aggregate(Sum('count'))['count__sum'] count_sum = states.aggregate(Sum('count'))['count__sum']
if not count_sum:
# this being None probably means there's no data for this context
raise ValueError("no markov states to generate from")
hit = random.randint(0, count_sum) hit = random.randint(0, count_sum)
log.debug("sum: {0:d} hit: {1:d}".format(count_sum, hit)) log.debug("sum: {0:d} hit: {1:d}".format(count_sum, hit))

View File

@ -27,7 +27,7 @@ class Migration(migrations.Migration):
('k2', models.CharField(max_length=128)), ('k2', models.CharField(max_length=128)),
('v', models.CharField(max_length=128)), ('v', models.CharField(max_length=128)),
('count', models.IntegerField(default=0)), ('count', models.IntegerField(default=0)),
('context', models.ForeignKey(to='markov.MarkovContext')), ('context', models.ForeignKey(to='markov.MarkovContext', on_delete=models.CASCADE)),
], ],
options={ options={
'permissions': set([('teach_line', 'Can teach lines'), ('import_log_file', 'Can import states from a log file')]), 'permissions': set([('teach_line', 'Can teach lines'), ('import_log_file', 'Can import states from a log file')]),
@ -40,7 +40,7 @@ class Migration(migrations.Migration):
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('name', models.CharField(unique=True, max_length=64)), ('name', models.CharField(unique=True, max_length=64)),
('chatter_chance', models.IntegerField(default=0)), ('chatter_chance', models.IntegerField(default=0)),
('context', models.ForeignKey(to='markov.MarkovContext')), ('context', models.ForeignKey(to='markov.MarkovContext', on_delete=models.CASCADE)),
], ],
options={ options={
}, },

View File

@ -1,6 +1,4 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models from django.db import migrations, models

View File

@ -28,7 +28,7 @@ class MarkovTarget(models.Model):
"""Define IRC targets that relate to a context, and can occasionally be talked to.""" """Define IRC targets that relate to a context, and can occasionally be talked to."""
name = models.CharField(max_length=200, unique=True) name = models.CharField(max_length=200, unique=True)
context = models.ForeignKey(MarkovContext) context = models.ForeignKey(MarkovContext, on_delete=models.CASCADE)
chatter_chance = models.IntegerField(default=0) chatter_chance = models.IntegerField(default=0)
@ -51,7 +51,7 @@ class MarkovState(models.Model):
v = models.CharField(max_length=128) v = models.CharField(max_length=128)
count = models.IntegerField(default=0) count = models.IntegerField(default=0)
context = models.ForeignKey(MarkovContext) context = models.ForeignKey(MarkovContext, on_delete=models.CASCADE)
class Meta: class Meta:
index_together = [ index_together = [

View File

@ -1,11 +1,13 @@
"""URL patterns for markov stuff.""" """URL patterns for markov stuff."""
from django.urls import path
from django.conf.urls import url
from django.views.generic import TemplateView from django.views.generic import TemplateView
from markov.views import context_index from markov.views import context_index, rpc_generate_line_for_context, rpc_learn_line_for_context
urlpatterns = [ urlpatterns = [
url(r'^$', TemplateView.as_view(template_name='index.html'), name='markov_index'), path('', TemplateView.as_view(template_name='index.html'), name='markov_index'),
url(r'^context/(?P<context_id>\d+)/$', context_index, name='markov_context_index'), path('context/<int:context_id>/', context_index, name='markov_context_index'),
path('rpc/context/<context>/generate/', rpc_generate_line_for_context, name='markov_rpc_generate_line'),
path('rpc/context/<context>/learn/', rpc_learn_line_for_context, name='markov_rpc_learn_line'),
] ]

View File

@ -1,16 +1,19 @@
"""Manipulate Markov data via the Django site.""" """Manipulate Markov data via the Django site."""
import json
import logging import logging
import time import time
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import get_object_or_404, render from django.shortcuts import get_object_or_404, render
from rest_framework.authentication import BasicAuthentication
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.decorators import api_view, authentication_classes, permission_classes
import markov.lib as markovlib import markov.lib as markovlib
from markov.models import MarkovContext from markov.models import MarkovContext
logger = logging.getLogger(__name__)
log = logging.getLogger('markov.views')
def index(request): def index(request):
@ -28,3 +31,52 @@ def context_index(request, context_id):
end_t = time.time() end_t = time.time()
return render(request, 'markov/context.html', {'chain': chain, 'context': context, 'elapsed': end_t - start_t}) return render(request, 'markov/context.html', {'chain': chain, 'context': context, 'elapsed': end_t - start_t})
@api_view(['POST'])
@authentication_classes((BasicAuthentication, ))
@permission_classes((IsAuthenticated, ))
def rpc_generate_line_for_context(request, context):
"""Generate a line from a given context, with optional topics included."""
if request.method != 'POST':
return Response({'detail': "Supported method: POST."}, status=405)
topics = None
try:
if request.body:
markov_data = json.loads(request.body)
topics = markov_data.get('topics', [])
except (json.decoder.JSONDecodeError, KeyError):
return Response({'detail': "Request body, if provided, must be JSON with an optional 'topics' parameter."},
status=400)
context_id = markovlib.get_or_create_target_context(context)
try:
generated_words = markovlib.generate_line(context_id, topics)
except ValueError as vex:
return Response({'detail': f"Could not generate line: {vex}", 'context': context, 'topics': topics},
status=400)
else:
return Response({
'context': context, 'topics': topics,
'generated_line': ' '.join(generated_words), 'generated_words': generated_words
})
@api_view(['POST'])
@authentication_classes((BasicAuthentication, ))
@permission_classes((IsAuthenticated, ))
def rpc_learn_line_for_context(request, context):
"""Learn a line for a given context."""
if request.method != 'POST':
return Response({'detail': "Supported method: POST."}, status=405)
try:
markov_data = json.loads(request.body)
line = markov_data.get('line', [])
except (json.decoder.JSONDecodeError, KeyError):
return Response({'detail': "Request body must be JSON with a 'line' parameter."}, status=400)
context_id = markovlib.get_or_create_target_context(context)
markovlib.learn_line(line, context_id)
return Response({'status': "OK", 'context': context, 'line': line})

View File

@ -29,7 +29,7 @@ class Migration(migrations.Migration):
('joined', models.BooleanField(default=False)), ('joined', models.BooleanField(default=False)),
('started', models.BooleanField(default=False)), ('started', models.BooleanField(default=False)),
('finished', models.BooleanField(default=False)), ('finished', models.BooleanField(default=False)),
('race', models.ForeignKey(to='races.Race')), ('race', models.ForeignKey(to='races.Race', on_delete=models.CASCADE)),
], ],
options={ options={
}, },
@ -41,8 +41,8 @@ class Migration(migrations.Migration):
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('update', models.TextField()), ('update', models.TextField()),
('event_time', models.DateTimeField(default=django.utils.timezone.now)), ('event_time', models.DateTimeField(default=django.utils.timezone.now)),
('race', models.ForeignKey(to='races.Race')), ('race', models.ForeignKey(to='races.Race', on_delete=models.CASCADE)),
('racer', models.ForeignKey(to='races.Racer')), ('racer', models.ForeignKey(to='races.Racer', on_delete=models.CASCADE)),
], ],
options={ options={
'ordering': ['event_time'], 'ordering': ['event_time'],

View File

@ -28,7 +28,7 @@ class Racer(models.Model):
"""Track a racer in a race.""" """Track a racer in a race."""
nick = models.CharField(max_length=64) nick = models.CharField(max_length=64)
race = models.ForeignKey(Race) race = models.ForeignKey(Race, on_delete=models.CASCADE)
joined = models.BooleanField(default=False) joined = models.BooleanField(default=False)
started = models.BooleanField(default=False) started = models.BooleanField(default=False)
@ -47,8 +47,8 @@ class RaceUpdate(models.Model):
"""Periodic updates for a racer.""" """Periodic updates for a racer."""
race = models.ForeignKey(Race) race = models.ForeignKey(Race, on_delete=models.CASCADE)
racer = models.ForeignKey(Racer) racer = models.ForeignKey(Racer, on_delete=models.CASCADE)
update = models.TextField() update = models.TextField()
event_time = models.DateTimeField(default=timezone.now) event_time = models.DateTimeField(default=timezone.now)

View File

@ -3,3 +3,4 @@
logilab-common # prospector thing, i guess logilab-common # prospector thing, i guess
pip-tools # pip-compile pip-tools # pip-compile
prospector # code quality prospector # code quality
versioneer # auto-generate version numbers

View File

@ -2,64 +2,67 @@
# This file is autogenerated by pip-compile # This file is autogenerated by pip-compile
# To update, run: # To update, run:
# #
# pip-compile --output-file requirements-dev.txt requirements-dev.in # pip-compile --output-file=requirements-dev.txt requirements-dev.in
# #
appdirs==1.4.0 # via setuptools astroid==2.2.5 # via pylint, pylint-celery, pylint-flask, requirements-detector
astroid==1.4.9 # via pylint, pylint-celery, pylint-flask, pylint-plugin-utils, requirements-detector certifi==2019.6.16 # via requests
click==6.7 # via pip-tools chardet==3.0.4 # via requests
click==7.0 # via pip-tools
django-adminplus==0.5 django-adminplus==0.5
django-bootstrap3==8.1.0 django-bootstrap3==11.0.0
django-extensions==1.7.6 django-extensions==2.1.9
django-registration-redux==1.4 django-registration-redux==2.6
django==1.10.5 django==2.2.2
djangorestframework==3.5.3 djangorestframework==3.9.4
dodgy==0.1.9 # via prospector dodgy==0.1.9 # via prospector
first==2.0.1 # via pip-tools future==0.17.1 # via parsedatetime
future==0.16.0 # via parsedatetime idna==2.8 # via requests
inflect==0.2.5 # via jaraco.itertools inflect==2.1.0 # via jaraco.itertools
irc==15.0.6 irc==15.0.6
isort==4.2.5 # via pylint isort==4.3.20 # via pylint
jaraco.classes==1.4 # via jaraco.collections jaraco.classes==2.0 # via jaraco.collections
jaraco.collections==1.5 # via irc, jaraco.text jaraco.collections==2.0 # via irc
jaraco.functools==1.15.1 # via irc, jaraco.text jaraco.functools==2.0 # via irc, jaraco.text, tempora
jaraco.itertools==2.0 # via irc jaraco.itertools==4.4.2 # via irc
jaraco.logging==1.5 # via irc jaraco.logging==2.0 # via irc
jaraco.stream==1.1.1 # via irc jaraco.stream==2.0 # via irc
jaraco.text==1.9 # via irc, jaraco.collections jaraco.text==3.0 # via irc, jaraco.collections
lazy-object-proxy==1.2.2 # via astroid lazy-object-proxy==1.4.1 # via astroid
logilab-common==1.3.0 logilab-common==1.4.2
mccabe==0.6.1 # via prospector, pylint mccabe==0.6.1 # via prospector, pylint
more-itertools==2.5.0 # via irc, jaraco.functools, jaraco.itertools more-itertools==7.0.0 # via irc, jaraco.functools, jaraco.itertools
oauthlib==2.0.1 # via requests-oauthlib oauthlib==3.0.1 # via requests-oauthlib
packaging==16.8 # via setuptools parsedatetime==2.4
parsedatetime==2.2
pep8-naming==0.4.1 # via prospector pep8-naming==0.4.1 # via prospector
pip-tools==1.8.0 pip-tools==4.1.0
ply==3.10 ply==3.11
prospector==0.12.4 prospector==1.1.6.4
pycodestyle==2.0.0 # via prospector pycodestyle==2.4.0 # via prospector
pydocstyle==1.0.0 # via prospector pydocstyle==3.0.0 # via prospector
pyflakes==1.5.0 # via prospector pyflakes==1.6.0 # via prospector
pylint-celery==0.3 # via prospector pylint-celery==0.3 # via prospector
pylint-common==0.2.2 # via prospector pylint-django==2.0.9 # via prospector
pylint-django==0.7.2 # via prospector pylint-flask==0.6 # via prospector
pylint-flask==0.5 # via prospector pylint-plugin-utils==0.5 # via prospector, pylint-celery, pylint-django, pylint-flask
pylint-plugin-utils==0.2.4 # via prospector, pylint-celery, pylint-django, pylint-flask pylint==2.3.1 # via prospector, pylint-celery, pylint-django, pylint-flask, pylint-plugin-utils
pylint==1.6.5 # via prospector, pylint-celery, pylint-common, pylint-django, pylint-flask, pylint-plugin-utils python-dateutil==2.8.0
pyparsing==2.1.10 # via packaging python-gitlab==1.9.0
python-dateutil==2.6.0 python-mpd2==1.0.0
python-gitlab==0.18 pytz==2019.1
python-mpd2==0.5.5 pyyaml==5.1.1 # via prospector
pytz==2016.10 requests-oauthlib==1.2.0 # via twython
pyyaml==3.12 # via prospector requests==2.22.0 # via python-gitlab, requests-oauthlib, twython
requests-oauthlib==0.7.0 # via twython requirements-detector==0.6 # via prospector
requests==2.13.0 # via python-gitlab, requests-oauthlib, twython
requirements-detector==0.5.2 # via prospector
setoptconf==0.2.0 # via prospector setoptconf==0.2.0 # via prospector
six==1.10.0 # via astroid, django-extensions, irc, jaraco.classes, jaraco.collections, jaraco.itertools, jaraco.logging, jaraco.stream, logilab-common, more-itertools, packaging, pip-tools, pylint, python-dateutil, python-gitlab, setuptools, tempora six==1.12.0 # via astroid, django-extensions, irc, jaraco.classes, jaraco.collections, jaraco.itertools, jaraco.logging, jaraco.stream, logilab-common, pip-tools, pydocstyle, python-dateutil, python-gitlab, tempora
tempora==1.6.1 # via irc, jaraco.logging snowballstemmer==1.2.1 # via pydocstyle
twython==3.4.0 sqlparse==0.3.0 # via django
wrapt==1.10.8 # via astroid tempora==1.14.1 # via irc, jaraco.logging
twython==3.7.0
typed-ast==1.4.0 # via astroid
urllib3==1.25.3 # via requests
versioneer==0.18
wrapt==1.11.2 # via astroid
# The following packages are considered to be unsafe in a requirements file: # The following packages are considered to be unsafe in a requirements file:
# setuptools # via logilab-common # setuptools==41.4.0 # via logilab-common

View File

@ -1,3 +0,0 @@
-r requirements.in
psycopg2

View File

@ -1,36 +0,0 @@
#
# This file is autogenerated by pip-compile
# To update, run:
#
# pip-compile --output-file requirements-server.txt requirements-server.in
#
django-adminplus==0.5
django-bootstrap3==8.1.0
django-extensions==1.7.6
django-registration-redux==1.4
django==1.10.5
djangorestframework==3.5.3
future==0.16.0 # via parsedatetime
inflect==0.2.5 # via jaraco.itertools
irc==15.0.6
jaraco.classes==1.4 # via jaraco.collections
jaraco.collections==1.5 # via irc, jaraco.text
jaraco.functools==1.15.1 # via irc, jaraco.text
jaraco.itertools==2.0 # via irc
jaraco.logging==1.5 # via irc
jaraco.stream==1.1.1 # via irc
jaraco.text==1.9 # via irc, jaraco.collections
more-itertools==2.5.0 # via irc, jaraco.functools, jaraco.itertools
oauthlib==2.0.1 # via requests-oauthlib
parsedatetime==2.2
ply==3.10
psycopg2==2.6.2
python-dateutil==2.6.0
python-gitlab==0.18
python-mpd2==0.5.5
pytz==2016.10
requests-oauthlib==0.7.0 # via twython
requests==2.13.0 # via python-gitlab, requests-oauthlib, twython
six==1.10.0 # via django-extensions, irc, jaraco.classes, jaraco.collections, jaraco.itertools, jaraco.logging, jaraco.stream, more-itertools, python-dateutil, python-gitlab, tempora
tempora==1.6.1 # via irc, jaraco.logging
twython==3.4.0

View File

@ -4,7 +4,7 @@ django-bootstrap3 # bootstrap layout
django-extensions # more commands django-extensions # more commands
django-registration-redux # registration views/forms django-registration-redux # registration views/forms
djangorestframework # dispatch WS API djangorestframework # dispatch WS API
irc # core irc==15.0.6 # core, pinned until I can bother to update --- 17.x has API changes
parsedatetime # relative date stuff in countdown parsedatetime # relative date stuff in countdown
ply # dice lex/yacc compiler ply # dice lex/yacc compiler
python-dateutil # countdown relative math python-dateutil # countdown relative math

View File

@ -2,34 +2,39 @@
# This file is autogenerated by pip-compile # This file is autogenerated by pip-compile
# To update, run: # To update, run:
# #
# pip-compile --output-file requirements.txt requirements.in # pip-compile --output-file=requirements.txt requirements.in
# #
certifi==2019.6.16 # via requests
chardet==3.0.4 # via requests
django-adminplus==0.5 django-adminplus==0.5
django-bootstrap3==8.1.0 django-bootstrap3==11.0.0
django-extensions==1.7.6 django-extensions==2.1.9
django-registration-redux==1.4 django-registration-redux==2.6
django==1.10.5 django==2.2.2
djangorestframework==3.5.3 djangorestframework==3.9.4
future==0.16.0 # via parsedatetime future==0.17.1 # via parsedatetime
inflect==0.2.5 # via jaraco.itertools idna==2.8 # via requests
inflect==2.1.0 # via jaraco.itertools
irc==15.0.6 irc==15.0.6
jaraco.classes==1.4 # via jaraco.collections jaraco.classes==2.0 # via jaraco.collections
jaraco.collections==1.5 # via irc, jaraco.text jaraco.collections==2.0 # via irc
jaraco.functools==1.15.1 # via irc, jaraco.text jaraco.functools==2.0 # via irc, jaraco.text, tempora
jaraco.itertools==2.0 # via irc jaraco.itertools==4.4.2 # via irc
jaraco.logging==1.5 # via irc jaraco.logging==2.0 # via irc
jaraco.stream==1.1.1 # via irc jaraco.stream==2.0 # via irc
jaraco.text==1.9 # via irc, jaraco.collections jaraco.text==3.0 # via irc, jaraco.collections
more-itertools==2.5.0 # via irc, jaraco.functools, jaraco.itertools more-itertools==7.0.0 # via irc, jaraco.functools, jaraco.itertools
oauthlib==2.0.1 # via requests-oauthlib oauthlib==3.0.1 # via requests-oauthlib
parsedatetime==2.2 parsedatetime==2.4
ply==3.10 ply==3.11
python-dateutil==2.6.0 python-dateutil==2.8.0
python-gitlab==0.18 python-gitlab==1.9.0
python-mpd2==0.5.5 python-mpd2==1.0.0
pytz==2016.10 pytz==2019.1
requests-oauthlib==0.7.0 # via twython requests-oauthlib==1.2.0 # via twython
requests==2.13.0 # via python-gitlab, requests-oauthlib, twython requests==2.22.0 # via python-gitlab, requests-oauthlib, twython
six==1.10.0 # via django-extensions, irc, jaraco.classes, jaraco.collections, jaraco.itertools, jaraco.logging, jaraco.stream, more-itertools, python-dateutil, python-gitlab, tempora six==1.12.0 # via django-extensions, irc, jaraco.classes, jaraco.collections, jaraco.itertools, jaraco.logging, jaraco.stream, python-dateutil, python-gitlab, tempora
tempora==1.6.1 # via irc, jaraco.logging sqlparse==0.3.0 # via django
twython==3.4.0 tempora==1.14.1 # via irc, jaraco.logging
twython==3.7.0
urllib3==1.25.3 # via requests

View File

@ -1,7 +1,4 @@
"""Use dr.botzo's Dispatch to send mpd notifications.""" """Use dr.botzo's Dispatch to send mpd notifications."""
from __future__ import unicode_literals
import argparse import argparse
import getpass import getpass
import logging import logging

6
setup.cfg Normal file
View File

@ -0,0 +1,6 @@
[versioneer]
VCS = git
style = pep440
versionfile_source = dr_botzo/_version.py
versionfile_build = dr_botzo/_version.py
tag_prefix = v

28
setup.py Normal file
View File

@ -0,0 +1,28 @@
"""Setuptools configuration."""
import os
from setuptools import find_packages, setup
import versioneer
HERE = os.path.dirname(os.path.abspath(__file__))
def extract_requires():
with open(os.path.join(HERE, 'requirements.in'), 'r') as reqs:
return [line.split(' ')[0] for line in reqs if not line[0] == '-']
setup(
name="dr.botzo",
description="A Django-backed IRC bot that also provides a WS framework for other bots to call.",
url="https://git.incorporeal.org/bss/dr.botzo",
license='GPLv3',
author="Brian S. Stephan",
author_email="bss@incorporeal.org",
version=versioneer.get_version(),
cmdclass=versioneer.get_cmdclass(),
packages=find_packages(),
include_package_data=True,
zip_safe=False,
install_requires=extract_requires(),
)

View File

@ -29,7 +29,7 @@ class Migration(migrations.Migration):
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('line', models.TextField(default='')), ('line', models.TextField(default='')),
('time', models.DateTimeField(auto_now_add=True)), ('time', models.DateTimeField(auto_now_add=True)),
('game', models.ForeignKey(to='storycraft.StorycraftGame')), ('game', models.ForeignKey(to='storycraft.StorycraftGame', on_delete=models.CASCADE)),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
@ -38,12 +38,12 @@ class Migration(migrations.Migration):
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('nick', models.CharField(max_length=64)), ('nick', models.CharField(max_length=64)),
('nickmask', models.CharField(max_length=200)), ('nickmask', models.CharField(max_length=200)),
('game', models.ForeignKey(to='storycraft.StorycraftGame')), ('game', models.ForeignKey(to='storycraft.StorycraftGame', on_delete=models.CASCADE)),
], ],
), ),
migrations.AddField( migrations.AddField(
model_name='storycraftline', model_name='storycraftline',
name='player', name='player',
field=models.ForeignKey(to='storycraft.StorycraftPlayer'), field=models.ForeignKey(to='storycraft.StorycraftPlayer', on_delete=models.CASCADE),
), ),
] ]

View File

@ -13,16 +13,16 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='storycraftline', model_name='storycraftline',
name='game', name='game',
field=models.ForeignKey(related_name='lines', to='storycraft.StorycraftGame'), field=models.ForeignKey(related_name='lines', to='storycraft.StorycraftGame', on_delete=models.CASCADE),
), ),
migrations.AlterField( migrations.AlterField(
model_name='storycraftline', model_name='storycraftline',
name='player', name='player',
field=models.ForeignKey(related_name='lines', to='storycraft.StorycraftPlayer'), field=models.ForeignKey(related_name='lines', to='storycraft.StorycraftPlayer', on_delete=models.CASCADE),
), ),
migrations.AlterField( migrations.AlterField(
model_name='storycraftplayer', model_name='storycraftplayer',
name='game', name='game',
field=models.ForeignKey(related_name='players', to='storycraft.StorycraftGame'), field=models.ForeignKey(related_name='players', to='storycraft.StorycraftGame', on_delete=models.CASCADE),
), ),
] ]

View File

@ -89,7 +89,7 @@ class StorycraftPlayer(models.Model):
"""Contain entire games of storycraft.""" """Contain entire games of storycraft."""
game = models.ForeignKey('StorycraftGame', related_name='players') game = models.ForeignKey('StorycraftGame', related_name='players', on_delete=models.CASCADE)
nick = models.CharField(max_length=64) nick = models.CharField(max_length=64)
nickmask = models.CharField(max_length=200) nickmask = models.CharField(max_length=200)
@ -103,8 +103,8 @@ class StorycraftLine(models.Model):
"""Handle requests to dispatchers and do something with them.""" """Handle requests to dispatchers and do something with them."""
game = models.ForeignKey('StorycraftGame', related_name='lines') game = models.ForeignKey('StorycraftGame', related_name='lines', on_delete=models.CASCADE)
player = models.ForeignKey('StorycraftPlayer', related_name='lines') player = models.ForeignKey('StorycraftPlayer', related_name='lines', on_delete=models.CASCADE)
line = models.TextField(default="") line = models.TextField(default="")
time = models.DateTimeField(auto_now_add=True) time = models.DateTimeField(auto_now_add=True)

View File

@ -15,7 +15,7 @@
<div class="container"> <div class="container">
<div class="navbar-header"> <div class="navbar-header">
{% block navbarbrand %} {% block navbarbrand %}
<a class="navbar-brand" href="{% url 'home' %}">{{ site.domain }}</a> <a class="navbar-brand" href="{% url 'index' %}">{{ site.domain }}</a>
{% endblock %} {% endblock %}
<!-- .navbar-toggle is used as the toggle for collapsed navbar content --> <!-- .navbar-toggle is used as the toggle for collapsed navbar content -->

View File

@ -3,7 +3,7 @@
{% block title %}{{ site.domain }}{% endblock %} {% block title %}{{ site.domain }}{% endblock %}
{% block navbarbrand %} {% block navbarbrand %}
<a class="navbar-brand navbar-brand-active" href="{% url 'home' %}">{{ site.domain }}</a> <a class="navbar-brand navbar-brand-active" href="{% url 'index' %}">{{ site.domain }}</a>
{% endblock %} {% endblock %}
{% block content %} {% block content %}

View File

@ -5,7 +5,7 @@
{% endblock %} {% endblock %}
{% block navbarbrand %} {% block navbarbrand %}
<a class="navbar-brand navbar-brand-active" href="{% url 'home' %}">{{ site.domain }}</a> <a class="navbar-brand navbar-brand-active" href="{% url 'index' %}">{{ site.domain }}</a>
{% endblock %} {% endblock %}
{% block content %} {% block content %}

View File

@ -1,6 +1,4 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models from django.db import migrations, models
@ -19,6 +17,6 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='twitterclient', model_name='twitterclient',
name='mentions_output_channel', name='mentions_output_channel',
field=models.ForeignKey(blank=True, related_name='mentions_twitter_client', null=True, to='ircbot.IrcChannel', default=None), field=models.ForeignKey(blank=True, related_name='mentions_twitter_client', null=True, to='ircbot.IrcChannel', default=None, on_delete=models.CASCADE),
), ),
] ]

View File

@ -1,6 +1,4 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models from django.db import migrations, models

View File

@ -1,6 +1,4 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models from django.db import migrations, models

View File

@ -18,7 +18,7 @@ class TwitterClient(models.Model):
oauth_token_secret = models.CharField(max_length=256, default='', blank=True) oauth_token_secret = models.CharField(max_length=256, default='', blank=True)
mentions_output_channel = models.ForeignKey(IrcChannel, related_name='mentions_twitter_client', default=None, mentions_output_channel = models.ForeignKey(IrcChannel, related_name='mentions_twitter_client', default=None,
null=True, blank=True) null=True, blank=True, on_delete=models.CASCADE)
mentions_since_id = models.PositiveIntegerField(default=1, blank=True) mentions_since_id = models.PositiveIntegerField(default=1, blank=True)
class Meta: class Meta:

1822
versioneer.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@ import logging
from ircbot.lib import Plugin from ircbot.lib import Plugin
from weather.lib import get_conditions_for_query, get_forecast_for_query from weather.lib import weather_summary
log = logging.getLogger('weather.ircplugin') log = logging.getLogger('weather.ircplugin')
@ -28,27 +28,36 @@ class Weather(Plugin):
super(Weather, self).stop() super(Weather, self).stop()
def handle_weather(self, connection, event, match): def handle_weather(self, connection, event, match):
"""Handle IRC input for a weather queries."""
# see if there was a specific command given in the query, first
query = match.group(1) query = match.group(1)
queryitems = query.split(" ") queryitems = query.split(" ")
if len(queryitems) <= 0: if len(queryitems) <= 0:
return return
# search for commands weather = weather_summary(queryitems[0])
if queryitems[0] == 'conditions': weather_output = (f"Weather in {weather['location']}: {weather['current']['description']}. "
# current weather query f"{weather['current']['temp_F']}/{weather['current']['temp_C']}, "
results = get_conditions_for_query(queryitems[1:]) f"feels like {weather['current']['feels_like_temp_F']}/"
return self.bot.reply(event, results) f"{weather['current']['feels_like_temp_C']}. "
elif queryitems[0] == 'forecast': f"Wind {weather['current']['wind_speed']} "
# forecast query f"from the {weather['current']['wind_direction']}. "
results = get_forecast_for_query(queryitems[1:]) f"Visibility {weather['current']['visibility']}. "
return self.bot.reply(event, results) f"Precipitation {weather['current']['precipitation']}. "
else: f"Pressure {weather['current']['pressure']}. "
# assume they wanted current weather f"Cloud cover {weather['current']['cloud_cover']}. "
results = get_conditions_for_query(queryitems) f"UV index {weather['current']['uv_index']}. "
return self.bot.reply(event, results) f"Today: {weather['today_forecast']['noteworthy']}, "
f"High {weather['today_forecast']['high_F']}/{weather['today_forecast']['high_C']}, "
f"Low {weather['today_forecast']['low_F']}/{weather['today_forecast']['low_C']}. "
f"Tomorrow: {weather['tomorrow_forecast']['noteworthy']}, "
f"High {weather['tomorrow_forecast']['high_F']}/{weather['tomorrow_forecast']['high_C']}, "
f"Low {weather['tomorrow_forecast']['low_F']}/{weather['tomorrow_forecast']['low_C']}. "
f"Day after tomorrow: {weather['day_after_tomorrow_forecast']['noteworthy']}, "
f"High {weather['day_after_tomorrow_forecast']['high_F']}/"
f"{weather['day_after_tomorrow_forecast']['high_C']}, "
f"Low {weather['day_after_tomorrow_forecast']['low_F']}/"
f"{weather['day_after_tomorrow_forecast']['low_C']}.")
return self.bot.reply(event, weather_output)
plugin = Weather plugin = Weather

View File

@ -1,196 +1,85 @@
# coding: utf-8 # coding: utf-8
"""Get results of weather queries."""
import logging import logging
import re
import requests import requests
from django.conf import settings logger = logging.getLogger(__name__)
wu_base_url = 'http://api.wunderground.com/api/{0:s}/'.format(settings.WEATHER_WEATHER_UNDERGROUND_API_KEY) def query_wttr_in(query):
log = logging.getLogger('weather.lib') """Hit the wttr.in JSON API with the provided query."""
logger.info(f"about to query wttr.in with '{query}'")
response = requests.get(f'http://wttr.in/{query}?format=j1')
response.raise_for_status()
weather_info = response.json()
logger.debug(f"results: {weather_info}")
return weather_info
def get_conditions_for_query(queryitems): def weather_summary(query):
"""Make a wunderground conditions call, return as string.""" """Create a more consumable version of the weather report."""
logger.info(f"assembling weather summary for '{query}'")
weather_info = query_wttr_in(query)
# recombine the query into a string # get some common/nested stuff once now
query = ' '.join(queryitems) current = weather_info['current_condition'][0]
query = query.replace(' ', '_') weather_desc = current['weatherDesc'][0] if 'weatherDesc' in current else {}
today_forecast = weather_info['weather'][0]
tomorrow_forecast = weather_info['weather'][1]
day_after_tomorrow_forecast = weather_info['weather'][2]
try: today_notes = [{'code': int(item['weatherCode']), 'desc': item['weatherDesc'][0]['value'] }
url = wu_base_url + ('{0:s}/q/{1:s}.json'.format('conditions', query)) for item in today_forecast['hourly']]
log.debug("calling %s", url) today_noteworthy = sorted(today_notes, key=lambda i: i['code'], reverse=True)[0]['desc']
resp = requests.get(url)
condition_data = resp.json()
except IOError as e:
log.error("error while making conditions query")
log.exception(e)
raise
# condition data is loaded. the rest of this is obviously specific to tomorrow_notes = [{'code': int(item['weatherCode']), 'desc': item['weatherDesc'][0]['value'] }
# http://www.wunderground.com/weather/api/d/docs?d=data/conditions for item in tomorrow_forecast['hourly']]
log.debug(condition_data) tomorrow_noteworthy = sorted(tomorrow_notes, key=lambda i: i['code'], reverse=True)[0]['desc']
try: day_after_tomorrow_notes = [{'code': int(item['weatherCode']), 'desc': item['weatherDesc'][0]['value'] }
# just see if we have current_observation data for item in day_after_tomorrow_forecast['hourly']]
current = condition_data['current_observation'] day_after_tomorrow_noteworthy = sorted(day_after_tomorrow_notes, key=lambda i: i['code'], reverse=True)[0]['desc']
except KeyError as e:
# ok, try to see if the ambiguous results stuff will help
log.debug(e)
log.debug("potentially ambiguous results, checking")
try:
results = condition_data['response']['results']
reply = "Multiple results, try one of the following zmw codes:"
for res in results[:-1]:
q = res['l'].strip('/q/')
reply += " {0:s} ({1:s}, {2:s}),".format(q, res['name'], res['country_name'])
q = results[-1]['l'].strip('/q/')
reply += " or {0:s} ({1:s}, {2:s}).".format(q, results[-1]['name'], results[-1]['country_name'])
return reply
except KeyError as e:
# now we really know something is wrong
log.error("error or bad query in conditions lookup")
log.exception(e)
return "No results."
else:
try:
location = current['display_location']['full']
reply = "Conditions for {0:s}: ".format(location)
weather_str = current['weather'] summary = {
if weather_str != '': 'location': query,
reply += "{0:s}, ".format(weather_str) 'current': {
'description': weather_desc.get('value'),
'temp_C': f"{current.get('temp_C')}°C",
'temp_F': f"{current.get('temp_F')}°F",
'feels_like_temp_C': f"{current.get('FeelsLikeC')}°C",
'feels_like_temp_F': f"{current.get('FeelsLikeF')}°F",
'cloud_cover': f"{current.get('cloudcover')}%",
'humidity': f"{current.get('humidity')}%",
'precipitation': f"{current.get('precipMM')} mm",
'visibility': f"{current.get('visibility')} mi",
'wind_speed': f"{current.get('windspeedMiles')} MPH",
'wind_direction': current.get('winddir16Point'),
'pressure': f"{current.get('pressure')} mb",
'uv_index': current.get('uvIndex'),
},
'today_forecast': {
'high_C': f"{today_forecast.get('maxtempC')}°C",
'high_F': f"{today_forecast.get('maxtempF')}°F",
'low_C': f"{today_forecast.get('mintempC')}°C",
'low_F': f"{today_forecast.get('mintempF')}°F",
'noteworthy': today_noteworthy,
},
'tomorrow_forecast': {
'high_C': f"{tomorrow_forecast.get('maxtempC')}°C",
'high_F': f"{tomorrow_forecast.get('maxtempF')}°F",
'low_C': f"{tomorrow_forecast.get('mintempC')}°C",
'low_F': f"{tomorrow_forecast.get('mintempF')}°F",
'noteworthy': tomorrow_noteworthy,
},
'day_after_tomorrow_forecast': {
'high_C': f"{day_after_tomorrow_forecast.get('maxtempC')}°C",
'high_F': f"{day_after_tomorrow_forecast.get('maxtempF')}°F",
'low_C': f"{day_after_tomorrow_forecast.get('mintempC')}°C",
'low_F': f"{day_after_tomorrow_forecast.get('mintempF')}°F",
'noteworthy': day_after_tomorrow_noteworthy,
},
}
temp_f = current['temp_f'] logger.debug(f"results: {summary}")
temp_c = current['temp_c'] return summary
temp_str = current['temperature_string']
if temp_f != '' and temp_c != '':
reply += "{0:.1f}°F ({1:.1f}°C)".format(temp_f, temp_c)
elif temp_str != '':
reply += "{0:s}".format(temp_str)
# append feels like if we have it
feelslike_f = current['feelslike_f']
feelslike_c = current['feelslike_c']
feelslike_str = current['feelslike_string']
if feelslike_f != '' and feelslike_c != '':
reply += ", feels like {0:s}°F ({1:s}°C)".format(feelslike_f, feelslike_c)
elif feelslike_str != '':
reply += ", feels like {0:s}".format(feelslike_str)
# whether this is current or current + feelslike, terminate sentence
reply += ". "
humidity_str = current['relative_humidity']
if humidity_str != '':
reply += "Humidity: {0:s}. ".format(humidity_str)
wind_str = current['wind_string']
if wind_str != '':
reply += "Wind: {0:s}. ".format(wind_str)
pressure_in = current['pressure_in']
pressure_trend = current['pressure_trend']
if pressure_in != '':
reply += "Pressure: {0:s}\"".format(pressure_in)
if pressure_trend != '':
reply += " and {0:s}".format("dropping" if pressure_trend == '-' else "rising")
reply += ". "
heat_index_str = current['heat_index_string']
if heat_index_str != '' and heat_index_str != 'NA':
reply += "Heat index: {0:s}. ".format(heat_index_str)
windchill_str = current['windchill_string']
if windchill_str != '' and windchill_str != 'NA':
reply += "Wind chill: {0:s}. ".format(windchill_str)
visibility_mi = current['visibility_mi']
if visibility_mi != '':
reply += "Visibility: {0:s} miles. ".format(visibility_mi)
precip_in = current['precip_today_in']
if precip_in != '':
reply += "Precipitation today: {0:s}\". ".format(precip_in)
observation_time = current['observation_time']
if observation_time != '':
reply += "{0:s}. ".format(observation_time)
return _prettify_weather_strings(reply.rstrip())
except KeyError as e:
log.error("error or unexpected results in conditions reply")
log.exception(e)
return "Error parsing results."
def get_forecast_for_query(queryitems):
"""Make a wunderground forecast call, return as string."""
# recombine the query into a string
query = ' '.join(queryitems)
query = query.replace(' ', '_')
try:
url = wu_base_url + ('{0:s}/q/{1:s}.json'.format('forecast', query))
resp = requests.get(url)
forecast_data = resp.json()
except IOError as e:
log.error("error while making forecast query")
log.exception(e)
raise
# forecast data is loaded. the rest of this is obviously specific to
# http://www.wunderground.com/weather/api/d/docs?d=data/forecast
log.debug(forecast_data)
try:
# just see if we have forecast data
forecasts = forecast_data['forecast']['txt_forecast']
except KeyError as e:
# ok, try to see if the ambiguous results stuff will help
log.debug(e)
log.debug("potentially ambiguous results, checking")
try:
results = forecast_data['response']['results']
reply = "Multiple results, try one of the following zmw codes:"
for res in results[:-1]:
q = res['l'].strip('/q/')
reply += " {0:s} ({1:s}, {2:s}),".format(q, res['name'],
res['country_name'])
q = results[-1]['l'].strip('/q/')
reply += " or {0:s} ({1:s}, {2:s}).".format(q, results[-1]['name'],
results[-1]['country_name'])
return reply
except KeyError as e:
# now we really know something is wrong
log.error("error or bad query in forecast lookup")
log.exception(e)
return "No results."
else:
try:
reply = "Forecast: "
for forecast in forecasts['forecastday'][0:5]:
reply += "{0:s}: {1:s} ".format(forecast['title'],
forecast['fcttext'])
return _prettify_weather_strings(reply.rstrip())
except KeyError as e:
log.error("error or unexpected results in forecast reply")
log.exception(e)
return "Error parsing results."
def _prettify_weather_strings(weather_str):
"""
Clean up output strings.
For example, turn 32F into 32°F in input string.
Input:
weather_str --- the string to clean up
"""
return re.sub(r'(\d+)\s*([FC])', r'\\2', weather_str)

8
weather/urls.py Normal file
View File

@ -0,0 +1,8 @@
"""URL patterns for markov stuff."""
from django.urls import path
from weather.views import rpc_weather_report
urlpatterns = [
path('rpc/<query>/', rpc_weather_report, name='weather_rpc_query'),
]

23
weather/views.py Normal file
View File

@ -0,0 +1,23 @@
"""Manipulate Markov data via the Django site."""
import logging
from rest_framework.authentication import BasicAuthentication
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.decorators import api_view, authentication_classes, permission_classes
from weather.lib import weather_summary
logger = logging.getLogger(__name__)
@api_view(['GET'])
@authentication_classes((BasicAuthentication, ))
@permission_classes((IsAuthenticated, ))
def rpc_weather_report(request, query):
"""Provide the weather report for a given query."""
if request.method != 'GET':
return Response({'detail': "Supported method: GET."}, status=405)
report = weather_summary(query)
return Response(report)