aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Mark <[email protected]>2019-09-24 10:24:56 -0700
committerGravatar GitHub <[email protected]>2019-09-24 10:24:56 -0700
commit6f097fe34347e1dd9d8df477590d07ba6045fe1a (patch)
tree392293ef8e69f62e50bc81987543e56cb2b9a205
parentFix E128 linting error (diff)
parentDocker Build & CI Refinements (#444) (diff)
Merge branch 'master' into master
-rw-r--r--.gitignore1
-rw-r--r--Dockerfile31
-rw-r--r--Pipfile39
-rw-r--r--Pipfile.lock57
-rw-r--r--azure-pipelines.yml122
-rw-r--r--bot/cogs/antispam.py4
-rw-r--r--bot/cogs/moderation.py12
-rw-r--r--bot/cogs/reminders.py12
-rw-r--r--bot/cogs/superstarify/__init__.py4
-rw-r--r--bot/converters.py50
-rw-r--r--docker-compose.yml30
-rw-r--r--docker/ci.Dockerfile20
-rw-r--r--scripts/deploy-azure.sh12
-rw-r--r--tests/test_converters.py123
14 files changed, 286 insertions, 231 deletions
diff --git a/.gitignore b/.gitignore
index cda3aeb9f..261fa179f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,7 +20,6 @@ lib64/
parts/
sdist/
var/
-wheels/
*.egg-info/
.installed.cfg
*.egg
diff --git a/Dockerfile b/Dockerfile
index aa6333380..271c25050 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,27 +1,20 @@
-FROM python:3.7-alpine3.7
+FROM python:3.7-slim
-RUN apk add --no-cache \
- build-base \
- freetype-dev \
- git \
- jpeg-dev \
- libffi-dev \
- libxml2 \
- libxml2-dev \
- libxslt-dev \
- tini \
- zlib \
- zlib-dev
-
-ENV \
- LIBRARY_PATH=/lib:/usr/lib
+# Set pip to have cleaner logs and no saved cache
+ENV PIP_NO_CACHE_DIR=false \
+ PIPENV_HIDE_EMOJIS=1 \
+ PIPENV_IGNORE_VIRTUALENVS=1 \
+ PIPENV_NOSPIN=1
+# Install pipenv
RUN pip install -U pipenv
+# Copy project files into working directory
WORKDIR /bot
COPY . .
-RUN pipenv install --deploy --system
+# Install project dependencies
+RUN pipenv install --system --deploy
-ENTRYPOINT ["/sbin/tini", "--"]
-CMD ["python3", "-m", "bot"]
+ENTRYPOINT ["python3"]
+CMD ["-m", "bot"]
diff --git a/Pipfile b/Pipfile
index 6a58054c1..da46a536d 100644
--- a/Pipfile
+++ b/Pipfile
@@ -5,19 +5,18 @@ name = "pypi"
[packages]
discord-py = "~=1.2"
-aiodns = "*"
-logmatic-python = "*"
-aiohttp = "*"
-sphinx = "*"
-markdownify = "*"
-lxml = "*"
-pyyaml = "*"
-fuzzywuzzy = "*"
-aio-pika = "*"
-python-dateutil = "*"
-deepdiff = "*"
-requests = "*"
-dateparser = "*"
+aiodns = "~=2.0"
+logmatic-python = "~=0.1"
+aiohttp = "~=3.5"
+sphinx = "~=2.2"
+markdownify = "~=0.4"
+lxml = "~=4.4"
+pyyaml = "~=5.1"
+fuzzywuzzy = "~=0.17"
+aio-pika = "~=6.1"
+python-dateutil = "~=2.8"
+deepdiff = "~=4.0"
+requests = "~=2.22"
more_itertools = "~=7.2"
urllib3 = ">=1.24.2,<1.25"
@@ -31,10 +30,10 @@ flake8-string-format = "~=0.2"
flake8-tidy-imports = "~=2.0"
flake8-todo = "~=0.7"
pre-commit = "~=1.18"
-safety = "*"
-dodgy = "*"
-pytest = "*"
-pytest-cov = "*"
+safety = "~=1.8"
+dodgy = "~=0.1"
+pytest = "~=5.1"
+pytest-cov = "~=2.7"
[requires]
python_version = "3.7"
@@ -43,9 +42,5 @@ python_version = "3.7"
start = "python -m bot"
lint = "python -m flake8"
precommit = "pre-commit install"
-build = "docker build -t pythondiscord/bot:latest -f docker/bot.Dockerfile ."
+build = "docker build -t pythondiscord/bot:latest -f Dockerfile ."
push = "docker push pythondiscord/bot:latest"
-buildbase = "docker build -t pythondiscord/bot-base:latest -f docker/base.Dockerfile ."
-pushbase = "docker push pythondiscord/bot-base:latest"
-buildci = "docker build -t pythondiscord/bot-ci:latest -f docker/ci.Dockerfile ."
-pushci = "docker push pythondiscord/bot-ci:latest"
diff --git a/Pipfile.lock b/Pipfile.lock
index 9bdcff923..58489c60e 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "29aaaa90a070d544e5b39fb6033410daa9bb7f658077205e44099f3175f6822b"
+ "sha256": "6c2d9ea980f1dbe954237de6d173ffa9ba480aa5cf0a03c4d7840b0739d4e2fa"
},
"pipfile-spec": 6,
"requires": {
@@ -62,10 +62,10 @@
},
"aiormq": {
"hashes": [
- "sha256:0b755b748d87d5ec63b4b7f162102333bf0901caf1f8a2bf29467bbdd54e637d",
- "sha256:f8eef1f98bc331a266404d925745fac589dab30412688564d740754d6d643863"
+ "sha256:c3e4dd01a2948a75f739fb637334dbb8c6f1a4cecf74d5ed662dc3bab7f39973",
+ "sha256:e220d3f9477bb2959b729b79bec815148ddb8a7686fc6c3d05d41c88ebd7c59e"
],
- "version": "==2.7.5"
+ "version": "==2.8.0"
},
"alabaster": {
"hashes": [
@@ -150,14 +150,6 @@
],
"version": "==3.0.4"
},
- "dateparser": {
- "hashes": [
- "sha256:983d84b5e3861cb0aa240cad07f12899bb10b62328aae188b9007e04ce37d665",
- "sha256:e1eac8ef28de69a554d5fcdb60b172d526d61924b1a40afbbb08df459a36006b"
- ],
- "index": "pypi",
- "version": "==0.7.2"
- },
"deepdiff": {
"hashes": [
"sha256:1123762580af0904621136d117c8397392a244d3ff0fa0a50de57a7939582476",
@@ -342,10 +334,10 @@
},
"packaging": {
"hashes": [
- "sha256:a7ac867b97fdc07ee80a8058fe4435ccd274ecc3b0ed61d852d7d53055528cf9",
- "sha256:c491ca87294da7cc01902edbe30a5bc6c4c28172b5138ab4e4aa1b9d7bfaeafe"
+ "sha256:28b924174df7a2fa32c1953825ff29c61e2f5e082343165438812f00d3a7fc47",
+ "sha256:d9551545c6d761f3def1677baf08ab2a3ca17c56879e70fecba2fc4dde4ed108"
],
- "version": "==19.1"
+ "version": "==19.2"
},
"pamqp": {
"hashes": [
@@ -432,22 +424,6 @@
"index": "pypi",
"version": "==5.1.2"
},
- "regex": {
- "hashes": [
- "sha256:1e9f9bc44ca195baf0040b1938e6801d2f3409661c15fe57f8164c678cfc663f",
- "sha256:587b62d48ca359d2d4f02d486f1f0aa9a20fbaf23a9d4198c4bed72ab2f6c849",
- "sha256:835ccdcdc612821edf132c20aef3eaaecfb884c9454fdc480d5887562594ac61",
- "sha256:93f6c9da57e704e128d90736430c5c59dd733327882b371b0cae8833106c2a21",
- "sha256:a46f27d267665016acb3ec8c6046ec5eae8cf80befe85ba47f43c6f5ec636dcd",
- "sha256:c5c8999b3a341b21ac2c6ec704cfcccbc50f1fedd61b6a8ee915ca7fd4b0a557",
- "sha256:d4d1829cf97632673aa49f378b0a2c3925acd795148c5ace8ef854217abbee89",
- "sha256:d96479257e8e4d1d7800adb26bf9c5ca5bab1648a1eddcac84d107b73dc68327",
- "sha256:f20f4912daf443220436759858f96fefbfc6c6ba9e67835fd6e4e9b73582791a",
- "sha256:f2b37b5b2c2a9d56d9e88efef200ec09c36c7f323f9d58d0b985a90923df386d",
- "sha256:fe765b809a1f7ce642c2edeee351e7ebd84391640031ba4b60af8d91a9045890"
- ],
- "version": "==2019.8.19"
- },
"requests": {
"hashes": [
"sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4",
@@ -526,13 +502,6 @@
],
"version": "==1.1.3"
},
- "tzlocal": {
- "hashes": [
- "sha256:11c9f16e0a633b4b60e1eede97d8a46340d042e67b670b290ca526576e039048",
- "sha256:949b9dd5ba4be17190a80c0268167d7e6c92c62b30026cf9764caf3e308e5590"
- ],
- "version": "==2.0.0"
- },
"urllib3": {
"hashes": [
"sha256:2393a695cd12afedd0dcb26fe5d50d0cf248e5a66f75dbd89a3d4eb333a61af4",
@@ -800,10 +769,10 @@
},
"packaging": {
"hashes": [
- "sha256:a7ac867b97fdc07ee80a8058fe4435ccd274ecc3b0ed61d852d7d53055528cf9",
- "sha256:c491ca87294da7cc01902edbe30a5bc6c4c28172b5138ab4e4aa1b9d7bfaeafe"
+ "sha256:28b924174df7a2fa32c1953825ff29c61e2f5e082343165438812f00d3a7fc47",
+ "sha256:d9551545c6d761f3def1677baf08ab2a3ca17c56879e70fecba2fc4dde4ed108"
],
- "version": "==19.1"
+ "version": "==19.2"
},
"pluggy": {
"hashes": [
@@ -857,11 +826,11 @@
},
"pytest": {
"hashes": [
- "sha256:95d13143cc14174ca1a01ec68e84d76ba5d9d493ac02716fd9706c949a505210",
- "sha256:b78fe2881323bd44fd9bd76e5317173d4316577e7b1cddebae9136a4495ec865"
+ "sha256:813b99704b22c7d377bbd756ebe56c35252bb710937b46f207100e843440b3c2",
+ "sha256:cc6620b96bc667a0c8d4fa592a8c9c94178a1bd6cc799dbb057dfd9286d31a31"
],
"index": "pypi",
- "version": "==5.1.2"
+ "version": "==5.1.3"
},
"pytest-cov": {
"hashes": [
diff --git a/azure-pipelines.yml b/azure-pipelines.yml
index 4dcad685c..b5ecab83c 100644
--- a/azure-pipelines.yml
+++ b/azure-pipelines.yml
@@ -6,69 +6,59 @@ variables:
PIPENV_NOSPIN: 1
jobs:
-- job: test
- displayName: 'Lint & Test'
-
- pool:
- vmImage: ubuntu-16.04
-
- variables:
- PIPENV_CACHE_DIR: ".cache/pipenv"
- PIP_CACHE_DIR: ".cache/pip"
- PIP_SRC: ".cache/src"
-
- steps:
- - script: |
- sudo apt-get update
- sudo apt-get install build-essential curl docker libffi-dev libfreetype6-dev libxml2 libxml2-dev libxslt1-dev zlib1g zlib1g-dev
- displayName: 'Install base dependencies'
-
- - task: UsePythonVersion@0
- displayName: 'Set Python version'
- inputs:
- versionSpec: '3.7.x'
- addToPath: true
-
- - script: sudo pip install pipenv
- displayName: 'Install pipenv'
-
- - script: pipenv install --dev --deploy --system
- displayName: 'Install project using pipenv'
-
- - script: python -m flake8
- displayName: 'Run linter'
-
- - script: BOT_API_KEY=foo BOT_TOKEN=bar WOLFRAM_API_KEY=baz python -m pytest --junitxml=junit.xml --cov=bot --cov-branch --cov-report=term --cov-report=xml tests
- displayName: Run tests
-
- - task: PublishCodeCoverageResults@1
- displayName: 'Publish Coverage Results'
- condition: succeededOrFailed()
- inputs:
- codeCoverageTool: Cobertura
- summaryFileLocation: coverage.xml
-
- - task: PublishTestResults@2
- displayName: 'Publish Test Results'
- condition: succeededOrFailed()
- inputs:
- testResultsFiles: junit.xml
- testRunTitle: 'Bot Test results'
-
-- job: build
- displayName: 'Build Containers'
- dependsOn: 'test'
-
- steps:
- - task: Docker@1
- displayName: 'Login: Docker Hub'
-
- inputs:
- containerregistrytype: 'Container Registry'
- dockerRegistryEndpoint: 'DockerHub'
- command: 'login'
-
- - task: ShellScript@2
- displayName: 'Build and deploy containers'
- inputs:
- scriptPath: scripts/deploy-azure.sh
+ - job: test
+ displayName: 'Lint & Test'
+ pool:
+ vmImage: ubuntu-16.04
+
+ variables:
+ PIP_CACHE_DIR: ".cache/pip"
+
+ steps:
+ - task: UsePythonVersion@0
+ displayName: 'Set Python version'
+ inputs:
+ versionSpec: '3.7.x'
+ addToPath: true
+
+ - script: pip install pipenv
+ displayName: 'Install pipenv'
+
+ - script: pipenv install --dev --deploy --system
+ displayName: 'Install project using pipenv'
+
+ - script: python -m flake8
+ displayName: 'Run linter'
+
+ - script: BOT_API_KEY=foo BOT_TOKEN=bar WOLFRAM_API_KEY=baz python -m pytest --junitxml=junit.xml --cov=bot --cov-branch --cov-report=term --cov-report=xml tests
+ displayName: Run tests
+
+ - task: PublishCodeCoverageResults@1
+ displayName: 'Publish Coverage Results'
+ condition: succeededOrFailed()
+ inputs:
+ codeCoverageTool: Cobertura
+ summaryFileLocation: coverage.xml
+
+ - task: PublishTestResults@2
+ displayName: 'Publish Test Results'
+ condition: succeededOrFailed()
+ inputs:
+ testResultsFiles: junit.xml
+ testRunTitle: 'Bot Test results'
+
+ - job: build
+ displayName: 'Build & Push Container'
+ dependsOn: 'test'
+ condition: and(succeeded(), ne(variables['Build.Reason'], 'PullRequest'))
+
+ steps:
+ - task: Docker@2
+ displayName: 'Build & Push Container'
+ inputs:
+ containerRegistry: 'DockerHub'
+ repository: 'pythondiscord/bot'
+ command: 'buildAndPush'
+ Dockerfile: 'Dockerfile'
+ buildContext: '.'
+ tags: 'latest'
diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py
index 7a3360436..8dfa0ad05 100644
--- a/bot/cogs/antispam.py
+++ b/bot/cogs/antispam.py
@@ -17,7 +17,7 @@ from bot.constants import (
Guild as GuildConfig, Icons,
STAFF_ROLES,
)
-from bot.converters import ExpirationDate
+from bot.converters import Duration
log = logging.getLogger(__name__)
@@ -102,7 +102,7 @@ class AntiSpam(Cog):
self.validation_errors = validation_errors
role_id = AntiSpamConfig.punishment['role_id']
self.muted_role = Object(role_id)
- self.expiration_date_converter = ExpirationDate()
+ self.expiration_date_converter = Duration()
self.message_deletion_queue = dict()
self.queue_consumption_tasks = dict()
diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py
index 81b3864a7..4d651bef7 100644
--- a/bot/cogs/moderation.py
+++ b/bot/cogs/moderation.py
@@ -14,7 +14,7 @@ from discord.ext.commands import (
from bot import constants
from bot.cogs.modlog import ModLog
from bot.constants import Colours, Event, Icons, MODERATION_ROLES
-from bot.converters import ExpirationDate, InfractionSearchQuery
+from bot.converters import Duration, InfractionSearchQuery
from bot.decorators import with_role
from bot.pagination import LinePaginator
from bot.utils.moderation import already_has_active_infraction, post_infraction
@@ -279,7 +279,7 @@ class Moderation(Scheduler, Cog):
@with_role(*MODERATION_ROLES)
@command()
- async def tempmute(self, ctx: Context, user: Member, duration: ExpirationDate, *, reason: str = None) -> None:
+ async def tempmute(self, ctx: Context, user: Member, duration: Duration, *, reason: str = None) -> None:
"""
Create a temporary mute infraction for a user with the provided expiration and reason.
@@ -345,7 +345,7 @@ class Moderation(Scheduler, Cog):
@with_role(*MODERATION_ROLES)
@command()
- async def tempban(self, ctx: Context, user: UserTypes, duration: ExpirationDate, *, reason: str = None) -> None:
+ async def tempban(self, ctx: Context, user: UserTypes, duration: Duration, *, reason: str = None) -> None:
"""
Create a temporary ban infraction for a user with the provided expiration and reason.
@@ -600,7 +600,7 @@ class Moderation(Scheduler, Cog):
@with_role(*MODERATION_ROLES)
@command(hidden=True, aliases=["shadowtempmute, stempmute"])
async def shadow_tempmute(
- self, ctx: Context, user: Member, duration: ExpirationDate, *, reason: str = None
+ self, ctx: Context, user: Member, duration: Duration, *, reason: str = None
) -> None:
"""
Create a temporary mute infraction for a user with the provided reason.
@@ -653,7 +653,7 @@ class Moderation(Scheduler, Cog):
@with_role(*MODERATION_ROLES)
@command(hidden=True, aliases=["shadowtempban, stempban"])
async def shadow_tempban(
- self, ctx: Context, user: UserTypes, duration: ExpirationDate, *, reason: str = None
+ self, ctx: Context, user: UserTypes, duration: Duration, *, reason: str = None
) -> None:
"""
Create a temporary ban infraction for a user with the provided reason.
@@ -884,7 +884,7 @@ class Moderation(Scheduler, Cog):
@infraction_edit_group.command(name="duration")
async def edit_duration(
self, ctx: Context,
- infraction_id: int, expires_at: Union[ExpirationDate, str]
+ infraction_id: int, expires_at: Union[Duration, str]
) -> None:
"""
Sets the duration of the given infraction, relative to the time of updating.
diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py
index 8460de91f..6e91d2c06 100644
--- a/bot/cogs/reminders.py
+++ b/bot/cogs/reminders.py
@@ -11,7 +11,7 @@ from discord import Colour, Embed, Message
from discord.ext.commands import Bot, Cog, Context, group
from bot.constants import Channels, Icons, NEGATIVE_REPLIES, POSITIVE_REPLIES, STAFF_ROLES
-from bot.converters import ExpirationDate
+from bot.converters import Duration
from bot.pagination import LinePaginator
from bot.utils.checks import without_role_check
from bot.utils.scheduling import Scheduler
@@ -118,12 +118,12 @@ class Reminders(Scheduler, Cog):
await self._delete_reminder(reminder["id"])
@group(name="remind", aliases=("reminder", "reminders"), invoke_without_command=True)
- async def remind_group(self, ctx: Context, expiration: ExpirationDate, *, content: str) -> None:
+ async def remind_group(self, ctx: Context, expiration: Duration, *, content: str) -> None:
"""Commands for managing your reminders."""
await ctx.invoke(self.new_reminder, expiration=expiration, content=content)
@remind_group.command(name="new", aliases=("add", "create"))
- async def new_reminder(self, ctx: Context, expiration: ExpirationDate, *, content: str) -> Optional[Message]:
+ async def new_reminder(self, ctx: Context, expiration: Duration, *, content: str) -> Optional[Message]:
"""
Set yourself a simple reminder.
@@ -146,7 +146,7 @@ class Reminders(Scheduler, Cog):
active_reminders = await self.bot.api_client.get(
'bot/reminders',
params={
- 'user__id': str(ctx.author.id)
+ 'author__id': str(ctx.author.id)
}
)
@@ -184,7 +184,7 @@ class Reminders(Scheduler, Cog):
# Get all the user's reminders from the database.
data = await self.bot.api_client.get(
'bot/reminders',
- params={'user__id': str(ctx.author.id)}
+ params={'author__id': str(ctx.author.id)}
)
now = datetime.utcnow()
@@ -237,7 +237,7 @@ class Reminders(Scheduler, Cog):
await ctx.invoke(self.bot.get_command("help"), "reminders", "edit")
@edit_reminder_group.command(name="duration", aliases=("time",))
- async def edit_reminder_duration(self, ctx: Context, id_: int, expiration: ExpirationDate) -> None:
+ async def edit_reminder_duration(self, ctx: Context, id_: int, expiration: Duration) -> None:
"""
Edit one of your reminder's expiration.
diff --git a/bot/cogs/superstarify/__init__.py b/bot/cogs/superstarify/__init__.py
index f7d6a269d..b1936ef3a 100644
--- a/bot/cogs/superstarify/__init__.py
+++ b/bot/cogs/superstarify/__init__.py
@@ -10,7 +10,7 @@ from bot.cogs.moderation import Moderation
from bot.cogs.modlog import ModLog
from bot.cogs.superstarify.stars import get_nick
from bot.constants import Icons, MODERATION_ROLES, POSITIVE_REPLIES
-from bot.converters import ExpirationDate
+from bot.converters import Duration
from bot.decorators import with_role
from bot.utils.moderation import post_infraction
@@ -153,7 +153,7 @@ class Superstarify(Cog):
@command(name='superstarify', aliases=('force_nick', 'star'))
@with_role(*MODERATION_ROLES)
async def superstarify(
- self, ctx: Context, member: Member, expiration: ExpirationDate, reason: str = None
+ self, ctx: Context, member: Member, expiration: Duration, reason: str = None
) -> None:
"""
Force a random superstar name (like Taylor Swift) to be the user's nickname for a specified duration.
diff --git a/bot/converters.py b/bot/converters.py
index 7386187ab..339da7b60 100644
--- a/bot/converters.py
+++ b/bot/converters.py
@@ -1,11 +1,12 @@
import logging
+import re
from datetime import datetime
from ssl import CertificateError
from typing import Union
-import dateparser
import discord
from aiohttp import ClientConnectorError
+from dateutil.relativedelta import relativedelta
from discord.ext.commands import BadArgument, Context, Converter
@@ -177,23 +178,40 @@ class TagContentConverter(Converter):
return tag_content
-class ExpirationDate(Converter):
- """Convert relative expiration date into UTC datetime using dateparser."""
+class Duration(Converter):
+ """Convert duration strings into UTC datetime.datetime objects."""
- DATEPARSER_SETTINGS = {
- 'PREFER_DATES_FROM': 'future',
- 'TIMEZONE': 'UTC',
- 'TO_TIMEZONE': 'UTC'
- }
+ duration_parser = re.compile(
+ r"((?P<years>\d+?) ?(years|year|Y|y) ?)?"
+ r"((?P<months>\d+?) ?(months|month|m) ?)?"
+ r"((?P<weeks>\d+?) ?(weeks|week|W|w) ?)?"
+ r"((?P<days>\d+?) ?(days|day|D|d) ?)?"
+ r"((?P<hours>\d+?) ?(hours|hour|H|h) ?)?"
+ r"((?P<minutes>\d+?) ?(minutes|minute|M) ?)?"
+ r"((?P<seconds>\d+?) ?(seconds|second|S|s))?"
+ )
- async def convert(self, ctx: Context, expiration_string: str) -> datetime:
- """Convert relative expiration date into UTC datetime."""
- expiry = dateparser.parse(expiration_string, settings=self.DATEPARSER_SETTINGS)
- if expiry is None:
- raise BadArgument(f"Failed to parse expiration date from `{expiration_string}`")
+ async def convert(self, ctx: Context, duration: str) -> datetime:
+ """
+ Converts a `duration` string to a datetime object that's `duration` in the future.
+
+ The converter supports the following symbols for each unit of time:
+ - years: `Y`, `y`, `year`, `years`
+ - months: `m`, `month`, `months`
+ - weeks: `w`, `W`, `week`, `weeks`
+ - days: `d`, `D`, `day`, `days`
+ - hours: `H`, `h`, `hour`, `hours`
+ - minutes: `M`, `minute`, `minutes`
+ - seconds: `S`, `s`, `second`, `seconds`
+
+ The units need to be provided in descending order of magnitude.
+ """
+ match = self.duration_parser.fullmatch(duration)
+ if not match:
+ raise BadArgument(f"`{duration}` is not a valid duration string.")
+ duration_dict = {unit: int(amount) for unit, amount in match.groupdict(default=0).items()}
+ delta = relativedelta(**duration_dict)
now = datetime.utcnow()
- if expiry < now:
- expiry = now + (now - expiry)
- return expiry
+ return now + delta
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 000000000..4b0dcff35
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,30 @@
+# This docker compose is used for quick setups of the site and database which
+# the bot project relies on for testing. Use it if you haven't got a
+# ready-to-use site environment already setup.
+
+version: "3.7"
+
+services:
+ postgres:
+ image: postgres:11-alpine
+ ports:
+ - "127.0.0.1:7777:5432"
+ environment:
+ POSTGRES_DB: pysite
+ POSTGRES_PASSWORD: pysite
+ POSTGRES_USER: pysite
+
+ web:
+ image: pythondiscord/site:latest
+ command: >
+ bash -c "echo \"from django.contrib.auth import get_user_model; User = get_user_model(); User.objects.create_superuser('admin', 'admin', 'admin') if not User.objects.filter(username='admin').exists() else print('Admin user already exists')\" | python manage.py shell
+ && ./manage.py runserver 0.0.0.0:8000"
+ ports:
+ - "127.0.0.1:8000:8000"
+ depends_on:
+ - postgres
+ environment:
+ DATABASE_URL: postgres://pysite:pysite@postgres:5432/pysite
+ DEBUG: "true"
+ SECRET_KEY: suitable-for-development-only
+ STATIC_ROOT: /var/www/static
diff --git a/docker/ci.Dockerfile b/docker/ci.Dockerfile
deleted file mode 100644
index fd7e25239..000000000
--- a/docker/ci.Dockerfile
+++ /dev/null
@@ -1,20 +0,0 @@
-FROM python:3.6-alpine3.7
-
-RUN apk add --update docker \
- curl \
- tini \
- build-base \
- libffi-dev \
- zlib \
- jpeg-dev \
- libxml2 libxml2-dev libxslt-dev \
- zlib-dev \
- freetype-dev
-
-RUN pip install pipenv
-
-ENV LIBRARY_PATH=/lib:/usr/lib
-ENV PIPENV_VENV_IN_PROJECT=1
-ENV PIPENV_IGNORE_VIRTUALENVS=1
-ENV PIPENV_NOSPIN=1
-ENV PIPENV_HIDE_EMOJIS=1
diff --git a/scripts/deploy-azure.sh b/scripts/deploy-azure.sh
deleted file mode 100644
index ed4b719e2..000000000
--- a/scripts/deploy-azure.sh
+++ /dev/null
@@ -1,12 +0,0 @@
-#!/bin/bash
-
-cd ..
-
-# Build and deploy on master branch, only if not a pull request
-if [[ ($BUILD_SOURCEBRANCHNAME == 'master') && ($SYSTEM_PULLREQUEST_PULLREQUESTID == '') ]]; then
- echo "Building image"
- docker build -t pythondiscord/bot:latest .
-
- echo "Pushing image"
- docker push pythondiscord/bot:latest
-fi
diff --git a/tests/test_converters.py b/tests/test_converters.py
index 3cf774c80..35fc5d88e 100644
--- a/tests/test_converters.py
+++ b/tests/test_converters.py
@@ -1,12 +1,13 @@
import asyncio
-from datetime import datetime
-from unittest.mock import MagicMock
+import datetime
+from unittest.mock import MagicMock, patch
import pytest
+from dateutil.relativedelta import relativedelta
from discord.ext.commands import BadArgument
from bot.converters import (
- ExpirationDate,
+ Duration,
TagContentConverter,
TagNameConverter,
ValidPythonIdentifier,
@@ -16,18 +17,6 @@ from bot.converters import (
@pytest.mark.parametrize(
('value', 'expected'),
(
- # sorry aliens
- ('2199-01-01T00:00:00', datetime(2199, 1, 1)),
- )
-)
-def test_expiration_date_converter_for_valid(value: str, expected: datetime):
- converter = ExpirationDate()
- assert asyncio.run(converter.convert(None, value)) == expected
-
-
- ('value', 'expected'),
- (
('hello', 'hello'),
(' h ello ', 'h ello')
)
@@ -91,3 +80,107 @@ def test_valid_python_identifier_for_valid(value: str):
def test_valid_python_identifier_for_invalid(value: str):
with pytest.raises(BadArgument, match=f'`{value}` is not a valid Python identifier'):
asyncio.run(ValidPythonIdentifier.convert(None, value))
+
+
+FIXED_UTC_NOW = datetime.datetime.fromisoformat('2019-01-01T00:00:00')
+
+
+ params=(
+ # Simple duration strings
+ ('1Y', {"years": 1}),
+ ('1y', {"years": 1}),
+ ('1year', {"years": 1}),
+ ('1years', {"years": 1}),
+ ('1m', {"months": 1}),
+ ('1month', {"months": 1}),
+ ('1months', {"months": 1}),
+ ('1w', {"weeks": 1}),
+ ('1W', {"weeks": 1}),
+ ('1week', {"weeks": 1}),
+ ('1weeks', {"weeks": 1}),
+ ('1d', {"days": 1}),
+ ('1D', {"days": 1}),
+ ('1day', {"days": 1}),
+ ('1days', {"days": 1}),
+ ('1h', {"hours": 1}),
+ ('1H', {"hours": 1}),
+ ('1hour', {"hours": 1}),
+ ('1hours', {"hours": 1}),
+ ('1M', {"minutes": 1}),
+ ('1minute', {"minutes": 1}),
+ ('1minutes', {"minutes": 1}),
+ ('1s', {"seconds": 1}),
+ ('1S', {"seconds": 1}),
+ ('1second', {"seconds": 1}),
+ ('1seconds', {"seconds": 1}),
+
+ # Complex duration strings
+ (
+ '1y1m1w1d1H1M1S',
+ {
+ "years": 1,
+ "months": 1,
+ "weeks": 1,
+ "days": 1,
+ "hours": 1,
+ "minutes": 1,
+ "seconds": 1
+ }
+ ),
+ ('5y100S', {"years": 5, "seconds": 100}),
+ ('2w28H', {"weeks": 2, "hours": 28}),
+
+ # Duration strings with spaces
+ ('1 year 2 months', {"years": 1, "months": 2}),
+ ('1d 2H', {"days": 1, "hours": 2}),
+ ('1 week2 days', {"weeks": 1, "days": 2}),
+ )
+)
+def create_future_datetime(request):
+ """Yields duration string and target datetime.datetime object."""
+ duration, duration_dict = request.param
+ future_datetime = FIXED_UTC_NOW + relativedelta(**duration_dict)
+ yield duration, future_datetime
+
+
+def test_duration_converter_for_valid(create_future_datetime: tuple):
+ converter = Duration()
+ duration, expected = create_future_datetime
+ with patch('bot.converters.datetime') as mock_datetime:
+ mock_datetime.utcnow.return_value = FIXED_UTC_NOW
+ assert asyncio.run(converter.convert(None, duration)) == expected
+
+
+ ('duration'),
+ (
+ # Units in wrong order
+ ('1d1w'),
+ ('1s1y'),
+
+ # Duplicated units
+ ('1 year 2 years'),
+ ('1 M 10 minutes'),
+
+ # Unknown substrings
+ ('1MVes'),
+ ('1y3breads'),
+
+ # Missing amount
+ ('ym'),
+
+ # Incorrect whitespace
+ (" 1y"),
+ ("1S "),
+ ("1y 1m"),
+
+ # Garbage
+ ('Guido van Rossum'),
+ ('lemon lemon lemon lemon lemon lemon lemon'),
+ )
+)
+def test_duration_converter_for_invalid(duration: str):
+ converter = Duration()
+ with pytest.raises(BadArgument, match=f'`{duration}` is not a valid duration string.'):
+ asyncio.run(converter.convert(None, duration))