diff --git a/.gitignore b/.gitignore index 6e9eb1a..986398e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ build/ dist/ tags/ *.egg-info/ +.tox/ +.coverage dr.botzo.data dr.botzo.cfg localsettings.py diff --git a/.prospector.yaml b/.prospector.yaml deleted file mode 100644 index 838b437..0000000 --- a/.prospector.yaml +++ /dev/null @@ -1,20 +0,0 @@ -doc-warnings: true -strictness: high -ignore-paths: - - migrations -ignore-patterns: - - \.log$ - - localsettings.py$ - - parsetab.py$ -pylint: - enable: - - relative-import - options: - max-line-length: 120 - good-names: log -pep8: - options: - max-line-length: 120 -pep257: - disable: - - D203 diff --git a/countdown/ircplugin.py b/countdown/ircplugin.py index 8edd977..9780636 100644 --- a/countdown/ircplugin.py +++ b/countdown/ircplugin.py @@ -22,7 +22,8 @@ class Countdown(Plugin): new_reminder_regex = (r'remind\s+(?P[^\s]+)\s+(?Pat|in|on)\s+(?P.*?)\s+' r'(and\s+every\s+(?P.*?)\s+)?' r'(until\s+(?P.*?)\s+)?' - r'(to|that|about)\s+(?P.*)') + r'(to|that|about)\s+(?P.*?)' + r'(?=\s+\("(?P.*)"\)|$)') def __init__(self, bot, connection, event): """Initialize some stuff.""" @@ -42,7 +43,7 @@ class Countdown(Plugin): self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!countdown\s+list$', self.handle_item_list, -20) - self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!countdown\s+(\S+)$', + self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!countdown\s+(.+)$', self.handle_item_detail, -20) self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], self.new_reminder_regex, self.handle_new_reminder, -50) @@ -99,9 +100,16 @@ class Countdown(Plugin): recurring_period = match.group('recurring_period') recurring_until = match.group('recurring_until') text = match.group('text') + name = match.group('name') log.debug("%s / %s / %s", who, when, text) - item_name = '{0:s}-{1:s}'.format(event.sender_nick, timezone.now().strftime('%s')) + if not name: + item_name = '{0:s}-{1:s}'.format(event.sender_nick, timezone.now().strftime('%s')) + else: + if CountdownItem.objects.filter(name=name).count() > 0: + self.bot.reply(event, "item with name '{0:s}' already exists".format(name)) + return 'NO MORE' + item_name = name # parse when to send the notification if when_type == 'in': diff --git a/countdown/migrations/0006_auto_20201025_1716.py b/countdown/migrations/0006_auto_20201025_1716.py new file mode 100644 index 0000000..72a2fc3 --- /dev/null +++ b/countdown/migrations/0006_auto_20201025_1716.py @@ -0,0 +1,23 @@ +# Generated by Django 3.1.2 on 2020-10-25 17:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('countdown', '0005_countdownitem_recurring_until'), + ] + + operations = [ + migrations.AlterField( + model_name='countdownitem', + name='recurring_period', + field=models.CharField(blank=True, default='', max_length=64), + ), + migrations.AlterField( + model_name='countdownitem', + name='reminder_target', + field=models.CharField(blank=True, default='', max_length=64), + ), + ] diff --git a/countdown/models.py b/countdown/models.py index fc2c89c..6a84fc3 100644 --- a/countdown/models.py +++ b/countdown/models.py @@ -1,14 +1,8 @@ """Countdown item models.""" - -import logging - from django.db import models from django.utils import timezone -log = logging.getLogger('countdown.models') - - class CountdownItem(models.Model): """Track points in time.""" @@ -19,13 +13,13 @@ class CountdownItem(models.Model): sent_reminder = models.BooleanField(default=False) reminder_message = models.TextField(default="") - reminder_target = models.CharField(max_length=64, default='') + reminder_target = models.CharField(max_length=64, blank=True, default='') - recurring_period = models.CharField(max_length=64, default='') + recurring_period = models.CharField(max_length=64, blank=True, default='') recurring_until = models.DateTimeField(null=True, blank=True, default=None) created_time = models.DateTimeField(auto_now_add=True) def __str__(self): - """String representation.""" + """Summarize object.""" return "{0:s} @ {1:s}".format(self.name, timezone.localtime(self.at_time).strftime('%Y-%m-%d %H:%M:%S %Z')) diff --git a/countdown/serializers.py b/countdown/serializers.py new file mode 100644 index 0000000..9c9439b --- /dev/null +++ b/countdown/serializers.py @@ -0,0 +1,14 @@ +"""REST serializers for countdown items.""" +from rest_framework import serializers + +from countdown.models import CountdownItem + + +class CountdownItemSerializer(serializers.ModelSerializer): + """Countdown item serializer for the REST API.""" + + class Meta: + """Meta options.""" + + model = CountdownItem + fields = '__all__' diff --git a/countdown/urls.py b/countdown/urls.py new file mode 100644 index 0000000..0083170 --- /dev/null +++ b/countdown/urls.py @@ -0,0 +1,12 @@ +"""URL patterns for the countdown views.""" +from django.conf.urls import include, url +from rest_framework.routers import DefaultRouter + +from countdown.views import CountdownItemViewSet + +router = DefaultRouter() +router.register(r'items', CountdownItemViewSet) + +urlpatterns = [ + url(r'^api/', include(router.urls)), +] diff --git a/countdown/views.py b/countdown/views.py new file mode 100644 index 0000000..14d2e31 --- /dev/null +++ b/countdown/views.py @@ -0,0 +1,16 @@ +"""Provide an interface to countdown items.""" +# from rest_framework.decorators import action +# from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated +from rest_framework.viewsets import ReadOnlyModelViewSet + +from countdown.models import CountdownItem +from countdown.serializers import CountdownItemSerializer + + +class CountdownItemViewSet(ReadOnlyModelViewSet): + """Provide list and detail actions for countdown items.""" + + queryset = CountdownItem.objects.all() + serializer_class = CountdownItemSerializer + permission_classes = [IsAuthenticated] diff --git a/dice/ircplugin.py b/dice/ircplugin.py index 99c7904..55fcb39 100644 --- a/dice/ircplugin.py +++ b/dice/ircplugin.py @@ -14,11 +14,12 @@ logger = logging.getLogger(__name__) class Dice(Plugin): """Roll simple or complex dice strings.""" - def __init__(self): + def __init__(self, bot, connection, event): """Set up the plugin.""" - super(Dice, self).__init__() self.roller = DiceRoller() + super(Dice, self).__init__(bot, connection, event) + def start(self): """Set up the handlers.""" self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!roll\s+(.*)$', diff --git a/dr_botzo/urls.py b/dr_botzo/urls.py index 3abb52b..c63f3f1 100644 --- a/dr_botzo/urls.py +++ b/dr_botzo/urls.py @@ -1,11 +1,9 @@ """General/baselite/site-wide URLs.""" - +from adminplus.sites import AdminSitePlus from django.conf.urls import include, url from django.contrib import admin from django.views.generic import TemplateView -from adminplus.sites import AdminSitePlus - admin.site = AdminSitePlus() admin.sites.site = admin.site admin.autodiscover() @@ -13,11 +11,13 @@ admin.autodiscover() urlpatterns = [ url(r'^$', TemplateView.as_view(template_name='index.html'), name='index'), + url(r'^countdown/', include('countdown.urls')), url(r'^dice/', include('dice.urls')), url(r'^dispatch/', include('dispatch.urls')), url(r'^itemsets/', include('facts.urls')), url(r'^karma/', include('karma.urls')), url(r'^markov/', include('markov.urls')), + url(r'^pi/', include('pi.urls')), url(r'^races/', include('races.urls')), url(r'^weather/', include('weather.urls')), diff --git a/karma/ircplugin.py b/karma/ircplugin.py index 1469c4e..7a97a35 100644 --- a/karma/ircplugin.py +++ b/karma/ircplugin.py @@ -6,6 +6,7 @@ import re import irc.client from django.conf import settings +from django.db.models import Count, Sum from ircbot.lib import Plugin from karma.models import KarmaKey, KarmaLogEntry @@ -23,6 +24,9 @@ class Karma(Plugin): self.connection.add_global_handler('pubmsg', self.handle_chatter, -20) self.connection.add_global_handler('privmsg', self.handle_chatter, -20) + self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], + (r'^!karma\s+keyreport\s+(.*)'), + self.handle_keyreport, -20) self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!karma\s+rank\s+(.*)$', self.handle_rank, -20) @@ -42,6 +46,7 @@ class Karma(Plugin): self.connection.remove_global_handler('pubmsg', self.handle_chatter) self.connection.remove_global_handler('privmsg', self.handle_chatter) + self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_keyreport) self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_rank) self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_report) self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_stats) @@ -102,6 +107,19 @@ class Karma(Plugin): except KarmaKey.DoesNotExist: return self.bot.reply(event, "i have not seen any karma for {0:s}".format(match.group(1))) + def handle_keyreport(self, connection, event, match): + """Provide report on a karma key.""" + key = match.group(1).lower().rstrip() + try: + karma_key = KarmaKey.objects.get(key=key) + karmaers = KarmaLogEntry.objects.filter(key=karma_key) + karmaers = karmaers.values('nickmask').annotate(Sum('delta')).annotate(Count('delta')).order_by('-delta__count') + karmaers_list = [f"{irc.client.NickMask(x['nickmask']).nick} ({x['delta__count']}, {'+' if x['delta__sum'] >= 0 else ''}{x['delta__sum']})" for x in karmaers] + karmaers_list_str = ", ".join(karmaers_list[:10]) + return self.bot.reply(event, f"most opinionated on {key}: {karmaers_list_str}") + except KarmaKey.DoesNotExist: + return self.bot.reply(event, "i have not seen any karma for {0:s}".format(match.group(1))) + def handle_report(self, connection, event, match): """Provide some karma reports.""" diff --git a/pi/ircplugin.py b/pi/ircplugin.py index 375d7bd..87e1e15 100644 --- a/pi/ircplugin.py +++ b/pi/ircplugin.py @@ -1,21 +1,14 @@ # coding: utf-8 - -import logging - +"""Provide pi simulation results to IRC.""" from ircbot.lib import Plugin from pi.models import PiLog -log = logging.getLogger('pi.ircplugin') - - class Pi(Plugin): - """Use the Monte Carlo method to simulate pi.""" def start(self): """Set up the handlers.""" - self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!pi$', self.handle_pi, -20) @@ -23,17 +16,16 @@ class Pi(Plugin): def stop(self): """Tear down handlers.""" - self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_pi) super(Pi, self).stop() def handle_pi(self, connection, event, match): """Handle the pi command by generating another value and presenting it.""" - - newest, x, y, hit = PiLog.objects.simulate() + newest, x, y = PiLog.objects.simulate() msg = ("({0:.10f}, {1:.10f}) is {2}within the unit circle. π is {5:.10f}. (i:{3:d} p:{4:d})" - "".format(x, y, "" if hit else "not ", newest.count_inside, newest.count_total, newest.value())) + "".format(x, y, "" if newest.hit else "not ", newest.total_count_inside, + newest.total_count, newest.value)) return self.bot.reply(event, msg) diff --git a/pi/migrations/0003_rename_count_fields.py b/pi/migrations/0003_rename_count_fields.py new file mode 100644 index 0000000..e722df5 --- /dev/null +++ b/pi/migrations/0003_rename_count_fields.py @@ -0,0 +1,23 @@ +# Generated by Django 3.1.2 on 2020-10-24 16:27 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('pi', '0002_auto_20150521_2204'), + ] + + operations = [ + migrations.RenameField( + model_name='pilog', + old_name='count_total', + new_name='total_count', + ), + migrations.RenameField( + model_name='pilog', + old_name='count_inside', + new_name='total_count_inside', + ), + ] diff --git a/pi/migrations/0004_simulation_x_y_logging.py b/pi/migrations/0004_simulation_x_y_logging.py new file mode 100644 index 0000000..0670024 --- /dev/null +++ b/pi/migrations/0004_simulation_x_y_logging.py @@ -0,0 +1,25 @@ +# Generated by Django 3.1.2 on 2020-10-24 16:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pi', '0003_rename_count_fields'), + ] + + operations = [ + migrations.AddField( + model_name='pilog', + name='simulation_x', + field=models.DecimalField(decimal_places=10, default=-1.0, max_digits=11), + preserve_default=False, + ), + migrations.AddField( + model_name='pilog', + name='simulation_y', + field=models.DecimalField(decimal_places=10, default=-1.0, max_digits=11), + preserve_default=False, + ), + ] diff --git a/pi/models.py b/pi/models.py index 10e9548..ae622f2 100644 --- a/pi/models.py +++ b/pi/models.py @@ -1,67 +1,70 @@ """Karma logging models.""" - -import logging import math -import pytz import random +import pytz from django.conf import settings from django.db import models -log = logging.getLogger('pi.models') - - class PiLogManager(models.Manager): - """Assemble some queries against PiLog.""" def simulate(self): """Add one more entry to the log, and return it.""" - try: latest = self.latest() except PiLog.DoesNotExist: - latest = PiLog(count_inside=0, count_total=0) + latest = PiLog.objects.create(simulation_x=0.0, simulation_y=0.0, + total_count_inside=0, total_count=0) latest.save() - inside = latest.count_inside - total = latest.count_total + inside = latest.total_count_inside + total = latest.total_count x = random.random() y = random.random() - hit = True if math.hypot(x,y) < 1 else False + total += 1 + if math.hypot(x, y) < 1: + inside += 1 - if hit: - newest = PiLog(count_inside=inside+1, count_total=total+1) - else: - newest = PiLog(count_inside=inside, count_total=total+1) - newest.save() + newest = PiLog.objects.create(simulation_x=x, simulation_y=y, + total_count_inside=inside, total_count=total) - return newest, x, y, hit + # TODO: remove the x, y return values, now that we track them in the object + return newest, x, y class PiLog(models.Model): - """Track pi as it is estimated over time.""" - count_inside = models.PositiveIntegerField() - count_total = models.PositiveIntegerField() + simulation_x = models.DecimalField(max_digits=11, decimal_places=10) + simulation_y = models.DecimalField(max_digits=11, decimal_places=10) + total_count_inside = models.PositiveIntegerField() + total_count = models.PositiveIntegerField() created = models.DateTimeField(auto_now_add=True) objects = PiLogManager() class Meta: + """Options for the PiLog class.""" + get_latest_by = 'created' def __str__(self): - """String representation.""" - + """Provide string representation.""" tz = pytz.timezone(settings.TIME_ZONE) - return "({0:d}/{1:d}) @ {2:s}".format(self.count_inside, self.count_total, + return "({0:d}/{1:d}) @ {2:s}".format(self.total_count_inside, self.total_count, self.created.astimezone(tz).strftime('%Y-%m-%d %H:%M:%S %Z')) + @property def value(self): - """Return this log entry's value of pi.""" + """Return this log entry's estimated value of pi.""" + if self.total_count == 0: + return 0.0 + return 4.0 * int(self.total_count_inside) / int(self.total_count) - return 4.0 * int(self.count_inside) / int(self.count_total) + @property + def hit(self): + """Return if this log entry is inside the unit circle.""" + return math.hypot(self.simulation_x, self.simulation_y) < 1 diff --git a/pi/serializers.py b/pi/serializers.py new file mode 100644 index 0000000..abae8a2 --- /dev/null +++ b/pi/serializers.py @@ -0,0 +1,14 @@ +"""REST serializers for pi simulations.""" +from rest_framework import serializers + +from pi.models import PiLog + + +class PiLogSerializer(serializers.ModelSerializer): + """Pi simulation log entry serializer for the REST API.""" + + class Meta: + """Meta options.""" + + model = PiLog + fields = ('id', 'simulation_x', 'simulation_y', 'total_count', 'total_count_inside', 'value', 'hit') diff --git a/pi/urls.py b/pi/urls.py new file mode 100644 index 0000000..3c7596b --- /dev/null +++ b/pi/urls.py @@ -0,0 +1,12 @@ +"""URL patterns for the pi views.""" +from django.conf.urls import include, url +from rest_framework.routers import DefaultRouter + +from pi.views import PiLogViewSet + +router = DefaultRouter() +router.register(r'simulations', PiLogViewSet) + +urlpatterns = [ + url(r'^api/', include(router.urls)), +] diff --git a/pi/views.py b/pi/views.py new file mode 100644 index 0000000..ea63733 --- /dev/null +++ b/pi/views.py @@ -0,0 +1,22 @@ +"""Provide pi simulation results.""" +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.viewsets import ReadOnlyModelViewSet + +from pi.models import PiLog +from pi.serializers import PiLogSerializer + + +class PiLogViewSet(ReadOnlyModelViewSet): + """Provide list and detail actions for pi simulation log entries.""" + + queryset = PiLog.objects.all() + serializer_class = PiLogSerializer + permission_classes = [IsAuthenticated] + + @action(detail=False, methods=['post']) + def simulate(self, request): + """Run one simulation of the pi estimator.""" + simulation, _, _ = PiLog.objects.simulate() + return Response(self.get_serializer(simulation).data, 201) diff --git a/requirements-dev.in b/requirements-dev.in deleted file mode 100644 index 8b474bf..0000000 --- a/requirements-dev.in +++ /dev/null @@ -1,6 +0,0 @@ --r requirements.in - -logilab-common # prospector thing, i guess -pip-tools # pip-compile -prospector # code quality -versioneer # auto-generate version numbers diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index 4e73415..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,68 +0,0 @@ -# -# This file is autogenerated by pip-compile -# To update, run: -# -# pip-compile --output-file=requirements-dev.txt requirements-dev.in -# -astroid==2.2.5 # via pylint, pylint-celery, pylint-flask, requirements-detector -certifi==2019.6.16 # via requests -chardet==3.0.4 # via requests -click==7.0 # via pip-tools -django-adminplus==0.5 -django-bootstrap3==11.0.0 -django-extensions==2.1.9 -django-registration-redux==2.6 -django==2.2.2 -djangorestframework==3.9.4 -dodgy==0.1.9 # via prospector -future==0.17.1 # via parsedatetime -idna==2.8 # via requests -inflect==2.1.0 # via jaraco.itertools -irc==15.0.6 -isort==4.3.20 # via pylint -jaraco.classes==2.0 # via jaraco.collections -jaraco.collections==2.0 # via irc -jaraco.functools==2.0 # via irc, jaraco.text, tempora -jaraco.itertools==4.4.2 # via irc -jaraco.logging==2.0 # via irc -jaraco.stream==2.0 # via irc -jaraco.text==3.0 # via irc, jaraco.collections -lazy-object-proxy==1.4.1 # via astroid -logilab-common==1.4.2 -mccabe==0.6.1 # via prospector, pylint -more-itertools==7.0.0 # via irc, jaraco.functools, jaraco.itertools -oauthlib==3.0.1 # via requests-oauthlib -parsedatetime==2.4 -pep8-naming==0.4.1 # via prospector -pip-tools==4.1.0 -ply==3.11 -prospector==1.1.6.4 -pycodestyle==2.4.0 # via prospector -pydocstyle==3.0.0 # via prospector -pyflakes==1.6.0 # via prospector -pylint-celery==0.3 # via prospector -pylint-django==2.0.9 # via prospector -pylint-flask==0.6 # via prospector -pylint-plugin-utils==0.5 # via prospector, pylint-celery, pylint-django, pylint-flask -pylint==2.3.1 # via prospector, pylint-celery, pylint-django, pylint-flask, pylint-plugin-utils -python-dateutil==2.8.0 -python-gitlab==1.9.0 -python-mpd2==1.0.0 -pytz==2019.1 -pyyaml==5.1.1 # via prospector -requests-oauthlib==1.2.0 # via twython -requests==2.22.0 # via python-gitlab, requests-oauthlib, twython -requirements-detector==0.6 # via prospector -setoptconf==0.2.0 # via prospector -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 -snowballstemmer==1.2.1 # via pydocstyle -sqlparse==0.3.0 # via django -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: -# setuptools==41.4.0 # via logilab-common diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 7cfadb5..0000000 --- a/requirements.txt +++ /dev/null @@ -1,40 +0,0 @@ -# -# This file is autogenerated by pip-compile -# To update, run: -# -# 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-bootstrap3==11.0.0 -django-extensions==2.1.9 -django-registration-redux==2.6 -django==2.2.2 -djangorestframework==3.9.4 -future==0.17.1 # via parsedatetime -idna==2.8 # via requests -inflect==2.1.0 # via jaraco.itertools -irc==15.0.6 -jaraco.classes==2.0 # via jaraco.collections -jaraco.collections==2.0 # via irc -jaraco.functools==2.0 # via irc, jaraco.text, tempora -jaraco.itertools==4.4.2 # via irc -jaraco.logging==2.0 # via irc -jaraco.stream==2.0 # via irc -jaraco.text==3.0 # via irc, jaraco.collections -more-itertools==7.0.0 # via irc, jaraco.functools, jaraco.itertools -oauthlib==3.0.1 # via requests-oauthlib -parsedatetime==2.4 -ply==3.11 -python-dateutil==2.8.0 -python-gitlab==1.9.0 -python-mpd2==1.0.0 -pytz==2019.1 -requests-oauthlib==1.2.0 # via twython -requests==2.22.0 # via python-gitlab, requests-oauthlib, twython -six==1.12.0 # via django-extensions, irc, jaraco.classes, jaraco.collections, jaraco.itertools, jaraco.logging, jaraco.stream, python-dateutil, python-gitlab, tempora -sqlparse==0.3.0 # via django -tempora==1.14.1 # via irc, jaraco.logging -twython==3.7.0 -urllib3==1.25.3 # via requests diff --git a/requirements/requirements-dev.in b/requirements/requirements-dev.in new file mode 100644 index 0000000..1663d96 --- /dev/null +++ b/requirements/requirements-dev.in @@ -0,0 +1,25 @@ +-r requirements.in + +# testing runner, test reporting, packages used during testing (e.g. requests-mock), etc. +pytest +pytest-cov +pytest-django + +# linting and other static code analysis +bandit +dlint +flake8 # flake8 and plugins, for local dev linting in vim +flake8-blind-except +flake8-builtins +flake8-docstrings +flake8-executable +flake8-fixme +flake8-isort +flake8-logging-format +flake8-mutable + +# maintenance utilities and tox +pip-tools # pip-compile +tox # CI stuff +tox-wheel # build wheels in tox +versioneer # automatic version numbering diff --git a/requirements/requirements-dev.txt b/requirements/requirements-dev.txt new file mode 100644 index 0000000..69f2d4a --- /dev/null +++ b/requirements/requirements-dev.txt @@ -0,0 +1,92 @@ +# +# This file is autogenerated by pip-compile +# To update, run: +# +# pip-compile --output-file=requirements/requirements-dev.txt requirements/requirements-dev.in +# +appdirs==1.4.4 # via virtualenv +asgiref==3.2.10 # via django +attrs==20.2.0 # via pytest +bandit==1.6.2 # via -r requirements/requirements-dev.in +certifi==2020.6.20 # via requests +chardet==3.0.4 # via requests +click==7.1.2 # via pip-tools +coverage==5.3 # via pytest-cov +distlib==0.3.1 # via virtualenv +django-adminplus==0.5 # via -r requirements/requirements.in +django-bootstrap3==14.2.0 # via -r requirements/requirements.in +django-extensions==3.0.9 # via -r requirements/requirements.in +django-registration-redux==2.8 # via -r requirements/requirements.in +django==3.1.2 # via -r requirements/requirements.in, django-bootstrap3, djangorestframework +djangorestframework==3.12.1 # via -r requirements/requirements.in +dlint==0.10.3 # via -r requirements/requirements-dev.in +filelock==3.0.12 # via tox, virtualenv +flake8-blind-except==0.1.1 # via -r requirements/requirements-dev.in +flake8-builtins==1.5.3 # via -r requirements/requirements-dev.in +flake8-docstrings==1.5.0 # via -r requirements/requirements-dev.in +flake8-executable==2.0.4 # via -r requirements/requirements-dev.in +flake8-fixme==1.1.1 # via -r requirements/requirements-dev.in +flake8-isort==4.0.0 # via -r requirements/requirements-dev.in +flake8-logging-format==0.6.0 # via -r requirements/requirements-dev.in +flake8-mutable==1.2.0 # via -r requirements/requirements-dev.in +flake8==3.8.4 # via -r requirements/requirements-dev.in, dlint, flake8-builtins, flake8-docstrings, flake8-executable, flake8-isort, flake8-mutable +gitdb==4.0.5 # via gitpython +gitpython==3.1.11 # via bandit +idna==2.10 # via requests +importlib-metadata==1.7.0 # via django-bootstrap3, flake8, inflect, pluggy, pytest, stevedore, tox, virtualenv +importlib-resources==3.1.1 # via jaraco.text, virtualenv +inflect==4.1.0 # via jaraco.itertools +iniconfig==1.1.1 # via pytest +irc==15.0.6 # via -r requirements/requirements.in +isort==5.6.4 # via flake8-isort +jaraco.classes==3.1.0 # via jaraco.collections +jaraco.collections==3.0.0 # via irc +jaraco.functools==3.0.1 # via irc, jaraco.text, tempora +jaraco.itertools==5.0.0 # via irc +jaraco.logging==3.0.0 # via irc +jaraco.stream==3.0.0 # via irc +jaraco.text==3.2.0 # via irc, jaraco.collections +mccabe==0.6.1 # via flake8 +more-itertools==8.5.0 # via irc, jaraco.classes, jaraco.functools, jaraco.itertools +oauthlib==3.1.0 # via requests-oauthlib +packaging==20.4 # via pytest, tox +parsedatetime==2.6 # via -r requirements/requirements.in +pbr==5.5.1 # via stevedore +pip-tools==5.3.1 # via -r requirements/requirements-dev.in +pluggy==0.13.1 # via pytest, tox +ply==3.11 # via -r requirements/requirements.in +py==1.9.0 # via pytest, tox +pycodestyle==2.6.0 # via flake8 +pydocstyle==5.1.1 # via flake8-docstrings +pyflakes==2.2.0 # via flake8 +pyparsing==2.4.7 # via packaging +pytest-cov==2.10.1 # via -r requirements/requirements-dev.in +pytest-django==4.1.0 # via -r requirements/requirements-dev.in +pytest==6.1.1 # via -r requirements/requirements-dev.in, pytest-cov, pytest-django +python-dateutil==2.8.1 # via -r requirements/requirements.in +python-gitlab==2.5.0 # via -r requirements/requirements.in +python-mpd2==1.1.0 # via -r requirements/requirements.in +pytz==2020.1 # via -r requirements/requirements.in, django, irc, tempora +pyyaml==5.3.1 # via bandit +requests-oauthlib==1.3.0 # via twython +requests==2.24.0 # via python-gitlab, requests-oauthlib, twython +six==1.15.0 # via bandit, irc, jaraco.collections, jaraco.logging, jaraco.text, packaging, pip-tools, python-dateutil, tox, virtualenv +smmap==3.0.4 # via gitdb +snowballstemmer==2.0.0 # via pydocstyle +sqlparse==0.4.1 # via django +stevedore==3.2.2 # via bandit +tempora==4.0.0 # via irc, jaraco.logging +testfixtures==6.15.0 # via flake8-isort +toml==0.10.1 # via pytest, tox +tox-wheel==0.5.0 # via -r requirements/requirements-dev.in +tox==3.20.1 # via -r requirements/requirements-dev.in, tox-wheel +twython==3.8.2 # via -r requirements/requirements.in +urllib3==1.25.11 # via requests +versioneer==0.18 # via -r requirements/requirements-dev.in +virtualenv==20.0.35 # via tox +wheel==0.35.1 # via tox-wheel +zipp==3.3.2 # via importlib-metadata, importlib-resources + +# The following packages are considered to be unsafe in a requirements file: +# pip +# setuptools diff --git a/requirements.in b/requirements/requirements.in similarity index 100% rename from requirements.in rename to requirements/requirements.in diff --git a/requirements/requirements.txt b/requirements/requirements.txt new file mode 100644 index 0000000..1aac3bc --- /dev/null +++ b/requirements/requirements.txt @@ -0,0 +1,43 @@ +# +# This file is autogenerated by pip-compile +# To update, run: +# +# pip-compile --output-file=requirements/requirements.txt requirements/requirements.in +# +asgiref==3.2.10 # via django +certifi==2020.6.20 # via requests +chardet==3.0.4 # via requests +django-adminplus==0.5 # via -r requirements/requirements.in +django-bootstrap3==14.2.0 # via -r requirements/requirements.in +django-extensions==3.0.9 # via -r requirements/requirements.in +django-registration-redux==2.8 # via -r requirements/requirements.in +django==3.1.2 # via -r requirements/requirements.in, django-bootstrap3, djangorestframework +djangorestframework==3.12.1 # via -r requirements/requirements.in +idna==2.10 # via requests +importlib-metadata==1.7.0 # via django-bootstrap3, inflect +importlib-resources==3.1.1 # via jaraco.text +inflect==4.1.0 # via jaraco.itertools +irc==15.0.6 # via -r requirements/requirements.in +jaraco.classes==3.1.0 # via jaraco.collections +jaraco.collections==3.0.0 # via irc +jaraco.functools==3.0.1 # via irc, jaraco.text, tempora +jaraco.itertools==5.0.0 # via irc +jaraco.logging==3.0.0 # via irc +jaraco.stream==3.0.0 # via irc +jaraco.text==3.2.0 # via irc, jaraco.collections +more-itertools==8.5.0 # via irc, jaraco.classes, jaraco.functools, jaraco.itertools +oauthlib==3.1.0 # via requests-oauthlib +parsedatetime==2.6 # via -r requirements/requirements.in +ply==3.11 # via -r requirements/requirements.in +python-dateutil==2.8.1 # via -r requirements/requirements.in +python-gitlab==2.5.0 # via -r requirements/requirements.in +python-mpd2==1.1.0 # via -r requirements/requirements.in +pytz==2020.1 # via -r requirements/requirements.in, django, irc, tempora +requests-oauthlib==1.3.0 # via twython +requests==2.24.0 # via python-gitlab, requests-oauthlib, twython +six==1.15.0 # via irc, jaraco.collections, jaraco.logging, jaraco.text, python-dateutil +sqlparse==0.4.1 # via django +tempora==4.0.0 # via irc, jaraco.logging +twython==3.8.2 # via -r requirements/requirements.in +urllib3==1.25.11 # via requests +zipp==3.3.2 # via importlib-metadata, importlib-resources diff --git a/setup.py b/setup.py index 41d7d2b..a538137 100644 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ HERE = os.path.dirname(os.path.abspath(__file__)) def extract_requires(): - with open(os.path.join(HERE, 'requirements.in'), 'r') as reqs: + with open(os.path.join(HERE, 'requirements/requirements.in'), 'r') as reqs: return [line.split(' ')[0] for line in reqs if not line[0] == '-'] diff --git a/tests/test_pi_models.py b/tests/test_pi_models.py new file mode 100644 index 0000000..ddbf457 --- /dev/null +++ b/tests/test_pi_models.py @@ -0,0 +1,47 @@ +"""Test the pi models.""" +from unittest import mock + +from django.test import TestCase +from django.utils.timezone import now + +from pi.models import PiLog + + +class PiLogTest(TestCase): + """Test pi models.""" + + def test_hit_calculation(self): + """Test that x,y combinations are properly considered inside or outside the circle.""" + hit_item = PiLog(simulation_x=0.0, simulation_y=0.0, total_count=0, total_count_inside=0) + miss_item = PiLog(simulation_x=1.0, simulation_y=1.0, total_count=0, total_count_inside=0) + + self.assertTrue(hit_item.hit) + self.assertFalse(miss_item.hit) + + def test_value_calculation(self): + """Test that a simulation's value of pi can be calculated.""" + item = PiLog(simulation_x=0.0, simulation_y=0.0, total_count=1000, total_count_inside=788) + zero_item = PiLog(simulation_x=0.0, simulation_y=0.0, total_count=0, total_count_inside=0) + + self.assertEqual(item.value, 3.152) + self.assertEqual(zero_item.value, 0.0) + + def test_string_repr(self): + """Test the string repr of a simulation log entry.""" + item = PiLog(simulation_x=0.0, simulation_y=0.0, total_count=1000, total_count_inside=788, + created=now()) + + self.assertIn("(788/1000) @ ", str(item)) + + def test_simulation_inside_determination(self): + """Test that running a simulation passes the proper inside value.""" + # get at least one simulation in the DB + original_item, _, _ = PiLog.objects.simulate() + + with mock.patch('random.random', return_value=1.0): + miss_item, _, _ = PiLog.objects.simulate() + self.assertEqual(miss_item.total_count_inside, original_item.total_count_inside) + + with mock.patch('random.random', return_value=0.0): + hit_item, _, _ = PiLog.objects.simulate() + self.assertGreater(hit_item.total_count_inside, original_item.total_count_inside) diff --git a/tests/test_pi_views.py b/tests/test_pi_views.py new file mode 100644 index 0000000..0403f73 --- /dev/null +++ b/tests/test_pi_views.py @@ -0,0 +1,25 @@ +"""Test the pi package's views.""" +from django.contrib.auth.models import User +from rest_framework.status import HTTP_201_CREATED +from rest_framework.test import APITestCase + +from pi.models import PiLog + + +class PiAPITest(APITestCase): + """Test pi DRF views.""" + + def setUp(self): + """Do pre-test stuff.""" + self.client = self.client_class() + self.user = User.objects.create(username='test') + self.client.force_authenticate(user=self.user) + + def test_simulate_creates_simulation(self): + """Test that the simulate action creates a log entry.""" + self.assertEqual(PiLog.objects.count(), 0) + + resp = self.client.post('/pi/api/simulations/simulate/') + + self.assertEqual(resp.status_code, HTTP_201_CREATED) + self.assertEqual(PiLog.objects.count(), 2) # 2 because 0 entry and the real entry diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..9b0ec28 --- /dev/null +++ b/tox.ini @@ -0,0 +1,201 @@ +# tox (https://tox.readthedocs.io/) is a tool for running tests +# in multiple virtualenvs. This configuration file will run the +# test suite on all supported python versions. To use it, "pip install tox" +# and then run "tox" from this directory. + +[tox] +envlist = begin,py36,py37,py38,coverage,security,lint,bundle + +[testenv] +# build a wheel and test it +wheel = true +wheel_build_env = build + +# whitelist commands we need +whitelist_externals = cp + +# install everything via requirements-dev.txt, so that developer environment +# is the same as the tox environment (for ease of use/no weird gotchas in +# local dev results vs. tox results) and also to avoid ticky-tacky maintenance +# of "oh this particular env has weird results unless I install foo" --- just +# shotgun blast install everything everywhere +deps = + -rrequirements/requirements-dev.txt + +[testenv:build] +# require setuptools when building +deps = setuptools + +[testenv:begin] +# clean up potential previous coverage runs +skip_install = true +commands = coverage erase + +[testenv:py36] +# run pytest with coverage +commands = + pytest --cov-append --cov-branch \ + --cov={envsitepackagesdir}/acro/ \ + --cov={envsitepackagesdir}/countdown/ \ + --cov={envsitepackagesdir}/dice/ \ + --cov={envsitepackagesdir}/dispatch/ \ + --cov={envsitepackagesdir}/dr_botzo/ \ + --cov={envsitepackagesdir}/facts/ \ + --cov={envsitepackagesdir}/gitlab_bot/ \ + --cov={envsitepackagesdir}/ircbot/ \ + --cov={envsitepackagesdir}/karma/ \ + --cov={envsitepackagesdir}/markov/ \ + --cov={envsitepackagesdir}/mpdbot/ \ + --cov={envsitepackagesdir}/pi/ \ + --cov={envsitepackagesdir}/races/ \ + --cov={envsitepackagesdir}/seen/ \ + --cov={envsitepackagesdir}/storycraft/ \ + --cov={envsitepackagesdir}/transform/ \ + --cov={envsitepackagesdir}/twitter/ \ + --cov={envsitepackagesdir}/weather/ + +[testenv:py37] +# run pytest with coverage +commands = + pytest --cov-append --cov-branch \ + --cov={envsitepackagesdir}/acro/ \ + --cov={envsitepackagesdir}/countdown/ \ + --cov={envsitepackagesdir}/dice/ \ + --cov={envsitepackagesdir}/dispatch/ \ + --cov={envsitepackagesdir}/dr_botzo/ \ + --cov={envsitepackagesdir}/facts/ \ + --cov={envsitepackagesdir}/gitlab_bot/ \ + --cov={envsitepackagesdir}/ircbot/ \ + --cov={envsitepackagesdir}/karma/ \ + --cov={envsitepackagesdir}/markov/ \ + --cov={envsitepackagesdir}/mpdbot/ \ + --cov={envsitepackagesdir}/pi/ \ + --cov={envsitepackagesdir}/races/ \ + --cov={envsitepackagesdir}/seen/ \ + --cov={envsitepackagesdir}/storycraft/ \ + --cov={envsitepackagesdir}/transform/ \ + --cov={envsitepackagesdir}/twitter/ \ + --cov={envsitepackagesdir}/weather/ + +[testenv:py38] +# run pytest with coverage +commands = + pytest --cov-append --cov-branch \ + --cov={envsitepackagesdir}/acro/ \ + --cov={envsitepackagesdir}/countdown/ \ + --cov={envsitepackagesdir}/dice/ \ + --cov={envsitepackagesdir}/dispatch/ \ + --cov={envsitepackagesdir}/dr_botzo/ \ + --cov={envsitepackagesdir}/facts/ \ + --cov={envsitepackagesdir}/gitlab_bot/ \ + --cov={envsitepackagesdir}/ircbot/ \ + --cov={envsitepackagesdir}/karma/ \ + --cov={envsitepackagesdir}/markov/ \ + --cov={envsitepackagesdir}/mpdbot/ \ + --cov={envsitepackagesdir}/pi/ \ + --cov={envsitepackagesdir}/races/ \ + --cov={envsitepackagesdir}/seen/ \ + --cov={envsitepackagesdir}/storycraft/ \ + --cov={envsitepackagesdir}/transform/ \ + --cov={envsitepackagesdir}/twitter/ \ + --cov={envsitepackagesdir}/weather/ + +[testenv:coverage] +# report on coverage runs from above +skip_install = true +commands = + coverage report --fail-under=95 --show-missing + +[testenv:security] +# run security checks +# +# again it seems the most valuable here to run against the packaged code +commands = + bandit \ + {envsitepackagesdir}/acro/ \ + {envsitepackagesdir}/countdown/ \ + {envsitepackagesdir}/dice/ \ + {envsitepackagesdir}/dispatch/ \ + {envsitepackagesdir}/dr_botzo/ \ + {envsitepackagesdir}/facts/ \ + {envsitepackagesdir}/gitlab_bot/ \ + {envsitepackagesdir}/ircbot/ \ + {envsitepackagesdir}/karma/ \ + {envsitepackagesdir}/markov/ \ + {envsitepackagesdir}/mpdbot/ \ + {envsitepackagesdir}/pi/ \ + {envsitepackagesdir}/races/ \ + {envsitepackagesdir}/seen/ \ + {envsitepackagesdir}/storycraft/ \ + {envsitepackagesdir}/transform/ \ + {envsitepackagesdir}/twitter/ \ + {envsitepackagesdir}/weather/ \ + -r + +[testenv:lint] +# run style checks +commands = + flake8 + - flake8 --disable-noqa --ignore= --select=E,W,F,C,D,A,G,B,I,T,M,DUO + +[testenv:bundle] +# take extra actions (build sdist, sphinx, whatever) to completely package the app +commands = + cp -r {distdir} . + python setup.py sdist + +[coverage:paths] +source = + ./ + .tox/**/site-packages/ + +[coverage:run] +branch = True + +# redundant with pytest --cov above, but this tricks the coverage.xml report into +# using the full path, otherwise files with the same name in different paths +# get clobbered. maybe appends would fix this, IDK +include = + {envsitepackagesdir}/acro/ + {envsitepackagesdir}/countdown/ + {envsitepackagesdir}/dice/ + {envsitepackagesdir}/dispatch/ + {envsitepackagesdir}/dr_botzo/ + {envsitepackagesdir}/facts/ + {envsitepackagesdir}/gitlab_bot/ + {envsitepackagesdir}/ircbot/ + {envsitepackagesdir}/karma/ + {envsitepackagesdir}/markov/ + {envsitepackagesdir}/mpdbot/ + {envsitepackagesdir}/pi/ + {envsitepackagesdir}/races/ + {envsitepackagesdir}/seen/ + {envsitepackagesdir}/storycraft/ + {envsitepackagesdir}/transform/ + {envsitepackagesdir}/twitter/ + {envsitepackagesdir}/weather/ + +omit = + **/_version.py + +[flake8] +enable-extensions = G,M +exclude = + .tox/ + versioneer.py + _version.py + instance/ +extend-ignore = T101 +max-complexity = 10 +max-line-length = 120 + +[isort] +line_length = 120 + +[pytest] +python_files = + *_tests.py + tests.py + test_*.py +DJANGO_SETTINGS_MODULE = dr_botzo.settings +django_find_project = false diff --git a/weather/ircplugin.py b/weather/ircplugin.py index 8e5d746..405b4e4 100644 --- a/weather/ircplugin.py +++ b/weather/ircplugin.py @@ -33,7 +33,7 @@ class Weather(Plugin): if len(queryitems) <= 0: return - weather = weather_summary(queryitems[0]) + weather = weather_summary(query) weather_output = (f"Weather in {weather['location']}: {weather['current']['description']}. " f"{weather['current']['temp_F']}/{weather['current']['temp_C']}, " f"feels like {weather['current']['feels_like_temp_F']}/" diff --git a/weather/lib.py b/weather/lib.py index 6b6611f..9b858e6 100644 --- a/weather/lib.py +++ b/weather/lib.py @@ -2,6 +2,7 @@ """Get results of weather queries.""" import logging import requests +from urllib.parse import quote logger = logging.getLogger(__name__) @@ -9,7 +10,7 @@ logger = logging.getLogger(__name__) def query_wttr_in(query): """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 = requests.get(f'http://wttr.in/{quote(query)}?format=j1') response.raise_for_status() weather_info = response.json()