aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Akarys42 <[email protected]>2019-10-06 13:03:56 +0200
committerGravatar Akarys42 <[email protected]>2019-10-06 13:03:56 +0200
commita0bd8dd48f5eadcef62a17119dfece644e1392cc (patch)
treecc773fdae802f898eaabce53bfebc5e369e115c6
parentAdd line break between the reminder and the jump url (diff)
parentMerge branch 'master' into reminder-up (diff)
Merge branch 'reminder-up' of https://github.com/akarys42/bot into reminder-up
-rw-r--r--CONTRIBUTING.md6
-rw-r--r--bot/cogs/alias.py5
-rw-r--r--bot/cogs/doc.py8
-rw-r--r--bot/cogs/information.py50
-rw-r--r--bot/cogs/moderation.py47
-rw-r--r--bot/cogs/reddit.py39
-rw-r--r--bot/cogs/site.py26
-rw-r--r--bot/cogs/tags.py33
-rw-r--r--bot/cogs/watchchannels/bigbrother.py22
-rw-r--r--bot/cogs/watchchannels/talentpool.py19
-rw-r--r--bot/cogs/watchchannels/watchchannel.py2
-rw-r--r--bot/converters.py46
-rw-r--r--tests/cogs/test_information.py48
-rw-r--r--tests/test_converters.py78
14 files changed, 388 insertions, 41 deletions
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index a0a1200ec..39f76c7b4 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -36,9 +36,9 @@ All projects evolve over time, and this contribution guide is no different. This
## Supplemental Information
### Developer Environment
-A working environment for the [PyDis site](https://github.com/python-discord/site) is required to develop the bot. Instructions for setting up environments for both the site and the bot can be found on the PyDis Wiki:
- * [Site](https://wiki.pythondiscord.com/wiki/contributing/project/site)
- * [Bot](https://wiki.pythondiscord.com/wiki/contributing/project/bot)
+Instructions for setting the bot developer environment can be found on the [PyDis wiki](https://pythondiscord.com/pages/contributing/bot/)
+
+To provide a standalone development environment for this project, docker compose is utilized to pull the current version of the [site backend](https://github.com/python-discord/site). While appropriate for bot-only contributions, any contributions that necessitate backend changes will require the site repository to be appropriately configured as well. Instructions for setting up the site environment can be found on the [PyDis site](https://pythondiscord.com/pages/contributing/site/).
When pulling down changes from GitHub, remember to sync your environment using `pipenv sync --dev` to ensure you're using the most up-to-date versions the project's dependencies.
diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py
index 80ff37983..0f49a400c 100644
--- a/bot/cogs/alias.py
+++ b/bot/cogs/alias.py
@@ -53,6 +53,11 @@ class Alias (Cog):
"""Alias for invoking <prefix>site resources."""
await self.invoke(ctx, "site resources")
+ @command(name="tools", hidden=True)
+ async def site_tools_alias(self, ctx: Context) -> None:
+ """Alias for invoking <prefix>site tools."""
+ await self.invoke(ctx, "site tools")
+
@command(name="watch", hidden=True)
async def bigbrother_watch_alias(self, ctx: Context, user: Union[Member, User, proxy_user], *, reason: str) -> None:
"""Alias for invoking <prefix>bigbrother watch [user] [reason]."""
diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py
index e5c51748f..c9e6b3b91 100644
--- a/bot/cogs/doc.py
+++ b/bot/cogs/doc.py
@@ -261,7 +261,7 @@ class Doc(commands.Cog):
@commands.group(name='docs', aliases=('doc', 'd'), invoke_without_command=True)
async def docs_group(self, ctx: commands.Context, symbol: commands.clean_content = None) -> None:
"""Lookup documentation for Python symbols."""
- await ctx.invoke(self.get_command)
+ await ctx.invoke(self.get_command, symbol)
@docs_group.command(name='get', aliases=('g',))
async def get_command(self, ctx: commands.Context, symbol: commands.clean_content = None) -> None:
@@ -319,9 +319,9 @@ class Doc(commands.Cog):
Example:
!docs set \
- discord \
- https://discordpy.readthedocs.io/en/rewrite/ \
- https://discordpy.readthedocs.io/en/rewrite/objects.inv
+ python \
+ https://docs.python.org/3/ \
+ https://docs.python.org/3/objects.inv
"""
body = {
'package': package_name,
diff --git a/bot/cogs/information.py b/bot/cogs/information.py
index 60aec6219..1afb37103 100644
--- a/bot/cogs/information.py
+++ b/bot/cogs/information.py
@@ -1,7 +1,9 @@
+import colorsys
import logging
import textwrap
+import typing
-from discord import CategoryChannel, Colour, Embed, Member, TextChannel, VoiceChannel
+from discord import CategoryChannel, Colour, Embed, Member, Role, TextChannel, VoiceChannel, utils
from discord.ext.commands import Bot, Cog, Context, command
from bot.constants import Channels, Emojis, MODERATION_ROLES, STAFF_ROLES
@@ -42,6 +44,52 @@ class Information(Cog):
await ctx.send(embed=embed)
+ @with_role(*MODERATION_ROLES)
+ @command(name="role")
+ async def role_info(self, ctx: Context, *roles: typing.Union[Role, str]) -> None:
+ """
+ Return information on a role or list of roles.
+
+ To specify multiple roles just add to the arguments, delimit roles with spaces in them using quotation marks.
+ """
+ parsed_roles = []
+
+ for role_name in roles:
+ if isinstance(role_name, Role):
+ # Role conversion has already succeeded
+ parsed_roles.append(role_name)
+ continue
+
+ role = utils.find(lambda r: r.name.lower() == role_name.lower(), ctx.guild.roles)
+
+ if not role:
+ await ctx.send(f":x: Could not convert `{role_name}` to a role")
+ continue
+
+ parsed_roles.append(role)
+
+ for role in parsed_roles:
+ embed = Embed(
+ title=f"{role.name} info",
+ colour=role.colour,
+ )
+
+ embed.add_field(name="ID", value=role.id, inline=True)
+
+ embed.add_field(name="Colour (RGB)", value=f"#{role.colour.value:0>6x}", inline=True)
+
+ h, s, v = colorsys.rgb_to_hsv(*role.colour.to_rgb())
+
+ embed.add_field(name="Colour (HSV)", value=f"{h:.2f} {s:.2f} {v}", inline=True)
+
+ embed.add_field(name="Member count", value=len(role.members), inline=True)
+
+ embed.add_field(name="Position", value=role.position)
+
+ embed.add_field(name="Permission code", value=role.permissions.value, inline=True)
+
+ await ctx.send(embed=embed)
+
@command(name="server", aliases=["server_info", "guild", "guild_info"])
async def server_info(self, ctx: Context) -> None:
"""Returns an embed full of server information."""
diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py
index b596f36e6..5aa873a47 100644
--- a/bot/cogs/moderation.py
+++ b/bot/cogs/moderation.py
@@ -80,6 +80,43 @@ class Moderation(Scheduler, Cog):
if infraction["expires_at"] is not None:
self.schedule_task(self.bot.loop, infraction["id"], infraction)
+ @Cog.listener()
+ async def on_member_join(self, member: Member) -> None:
+ """Reapply active mute infractions for returning members."""
+ active_mutes = await self.bot.api_client.get(
+ 'bot/infractions',
+ params={'user__id': str(member.id), 'type': 'mute', 'active': 'true'}
+ )
+ if not active_mutes:
+ return
+
+ # assume a single mute because of restrictions elsewhere
+ mute = active_mutes[0]
+
+ # transform expiration to delay in seconds
+ expiration_datetime = datetime.fromisoformat(mute["expires_at"][:-1])
+ delay = expiration_datetime - datetime.utcnow()
+ delay_seconds = delay.total_seconds()
+
+ # if under a minute or in the past
+ if delay_seconds < 60:
+ log.debug(f"Marking infraction {mute['id']} as inactive (expired).")
+ await self._deactivate_infraction(mute)
+ self.cancel_task(mute["id"])
+
+ # Notify the user that they've been unmuted.
+ await self.notify_pardon(
+ user=member,
+ title="You have been unmuted.",
+ content="You may now send messages in the server.",
+ icon_url=Icons.user_unmute
+ )
+ return
+
+ # allowing modlog since this is a passive action that should be logged
+ await member.add_roles(self._muted_role, reason=f"Re-applying active mute: {mute['id']}")
+ log.debug(f"User {member.id} has been re-muted on rejoin.")
+
# region: Permanent infractions
@with_role(*MODERATION_ROLES)
@@ -955,6 +992,11 @@ class Moderation(Scheduler, Cog):
user_id = infraction_object["user"]
infraction_type = infraction_object["type"]
+ await self.bot.api_client.patch(
+ 'bot/infractions/' + str(infraction_object['id']),
+ json={"active": False}
+ )
+
if infraction_type == "mute":
member: Member = guild.get_member(user_id)
if member:
@@ -970,11 +1012,6 @@ class Moderation(Scheduler, Cog):
except NotFound:
log.info(f"Tried to unban user `{user_id}`, but Discord does not have an active ban registered.")
- await self.bot.api_client.patch(
- 'bot/infractions/' + str(infraction_object['id']),
- json={"active": False}
- )
-
def _infraction_to_string(self, infraction_object: Dict[str, Union[str, int, bool]]) -> str:
"""Convert the infraction object to a string representation."""
actor_id = infraction_object["actor"]
diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py
index 63a57c5c6..6880aab85 100644
--- a/bot/cogs/reddit.py
+++ b/bot/cogs/reddit.py
@@ -21,6 +21,7 @@ class Reddit(Cog):
HEADERS = {"User-Agent": "Discord Bot: PythonDiscord (https://pythondiscord.com/)"}
URL = "https://www.reddit.com"
+ MAX_FETCH_RETRIES = 3
def __init__(self, bot: Bot):
self.bot = bot
@@ -42,16 +43,23 @@ class Reddit(Cog):
if params is None:
params = {}
- response = await self.bot.http_session.get(
- url=f"{self.URL}/{route}.json",
- headers=self.HEADERS,
- params=params
- )
+ url = f"{self.URL}/{route}.json"
+ for _ in range(self.MAX_FETCH_RETRIES):
+ response = await self.bot.http_session.get(
+ url=url,
+ headers=self.HEADERS,
+ params=params
+ )
+ if response.status == 200 and response.content_type == 'application/json':
+ # Got appropriate response - process and return.
+ content = await response.json()
+ posts = content["data"]["children"]
+ return posts[:amount]
- content = await response.json()
- posts = content["data"]["children"]
+ await asyncio.sleep(3)
- return posts[:amount]
+ log.debug(f"Invalid response from: {url} - status code {response.status}, mimetype {response.content_type}")
+ return list() # Failed to get appropriate response within allowed number of retries.
async def send_top_posts(
self, channel: TextChannel, subreddit: Subreddit, content: str = None, time: str = "all"
@@ -62,13 +70,14 @@ class Reddit(Cog):
embed.description = ""
# Get the posts
- posts = await self.fetch_posts(
- route=f"{subreddit}/top",
- amount=5,
- params={
- "t": time
- }
- )
+ async with channel.typing():
+ posts = await self.fetch_posts(
+ route=f"{subreddit}/top",
+ amount=5,
+ params={
+ "t": time
+ }
+ )
if not posts:
embed.title = random.choice(ERROR_REPLIES)
diff --git a/bot/cogs/site.py b/bot/cogs/site.py
index 4a423faa9..c3bdf85e4 100644
--- a/bot/cogs/site.py
+++ b/bot/cogs/site.py
@@ -44,17 +44,29 @@ class Site(Cog):
async def site_resources(self, ctx: Context) -> None:
"""Info about the site's Resources page."""
learning_url = f"{PAGES_URL}/resources"
- tools_url = f"{PAGES_URL}/tools"
- embed = Embed(title="Resources & Tools")
- embed.set_footer(text=f"{learning_url} | {tools_url}")
+ embed = Embed(title="Resources")
+ embed.set_footer(text=f"{learning_url}")
embed.colour = Colour.blurple()
embed.description = (
f"The [Resources page]({learning_url}) on our website contains a "
- "list of hand-selected goodies that we regularly recommend "
- f"to both beginners and experts. The [Tools page]({tools_url}) "
- "contains a couple of the most popular tools for programming in "
- "Python."
+ "list of hand-selected learning resources that we regularly recommend "
+ f"to both beginners and experts."
+ )
+
+ await ctx.send(embed=embed)
+
+ @site_group.command(name="tools")
+ async def site_tools(self, ctx: Context) -> None:
+ """Info about the site's Tools page."""
+ tools_url = f"{PAGES_URL}/tools"
+
+ embed = Embed(title="Tools")
+ embed.set_footer(text=f"{tools_url}")
+ embed.colour = Colour.blurple()
+ embed.description = (
+ f"The [Tools page]({tools_url}) on our website contains a "
+ f"couple of the most popular tools for programming in Python."
)
await ctx.send(embed=embed)
diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py
index b9dd3595e..cd70e783a 100644
--- a/bot/cogs/tags.py
+++ b/bot/cogs/tags.py
@@ -86,7 +86,7 @@ class Tags(Cog):
max_lines=15
)
- @tags_group.command(name='set', aliases=('add', 'edit', 's'))
+ @tags_group.command(name='set', aliases=('add', 's'))
@with_role(*MODERATION_ROLES)
async def set_command(
self,
@@ -95,7 +95,7 @@ class Tags(Cog):
*,
tag_content: TagContentConverter,
) -> None:
- """Create a new tag or update an existing one."""
+ """Create a new tag."""
body = {
'title': tag_name.lower().strip(),
'embed': {
@@ -116,6 +116,35 @@ class Tags(Cog):
colour=Colour.blurple()
))
+ @tags_group.command(name='edit', aliases=('e', ))
+ @with_role(*MODERATION_ROLES)
+ async def edit_command(
+ self,
+ ctx: Context,
+ tag_name: TagNameConverter,
+ *,
+ tag_content: TagContentConverter,
+ ) -> None:
+ """Edit an existing tag."""
+ body = {
+ 'embed': {
+ 'title': tag_name,
+ 'description': tag_content
+ }
+ }
+
+ await self.bot.api_client.patch(f'bot/tags/{tag_name}', json=body)
+
+ log.debug(f"{ctx.author} successfully edited the following tag in our database: \n"
+ f"tag_name: {tag_name}\n"
+ f"tag_content: '{tag_content}'\n")
+
+ await ctx.send(embed=Embed(
+ title="Tag successfully edited",
+ description=f"**{tag_name}** edited in the database.",
+ colour=Colour.blurple()
+ ))
+
@tags_group.command(name='delete', aliases=('remove', 'rm', 'd'))
@with_role(Roles.admin, Roles.owner)
async def delete_command(self, ctx: Context, *, tag_name: TagNameConverter) -> None:
diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py
index e191c2dbc..3eba9862f 100644
--- a/bot/cogs/watchchannels/bigbrother.py
+++ b/bot/cogs/watchchannels/bigbrother.py
@@ -70,7 +70,27 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):
if response is not None:
self.watched_users[user.id] = response
- await ctx.send(f":white_check_mark: Messages sent by {user} will now be relayed to Big Brother.")
+ msg = f":white_check_mark: Messages sent by {user} will now be relayed to Big Brother."
+
+ history = await self.bot.api_client.get(
+ self.api_endpoint,
+ params={
+ "user__id": str(user.id),
+ "active": "false",
+ 'type': 'watch',
+ 'ordering': '-inserted_at'
+ }
+ )
+
+ if len(history) > 1:
+ total = f"({len(history) // 2} previous infractions in total)"
+ end_reason = history[0]["reason"]
+ start_reason = f"Watched: {history[1]['reason']}"
+ msg += f"\n\nUser's previous watch reasons {total}:```{start_reason}\n\n{end_reason}```"
+ else:
+ msg = ":x: Failed to post the infraction: response was empty."
+
+ await ctx.send(msg)
@bigbrother_group.command(name='unwatch', aliases=('uw',))
@with_role(Roles.owner, Roles.admin, Roles.moderator)
diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py
index 4a23902d5..176c6f760 100644
--- a/bot/cogs/watchchannels/talentpool.py
+++ b/bot/cogs/watchchannels/talentpool.py
@@ -93,7 +93,24 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
resp.raise_for_status()
self.watched_users[user.id] = response_data
- await ctx.send(f":white_check_mark: Messages sent by {user} will now be relayed to the talent pool channel")
+ msg = f":white_check_mark: Messages sent by {user} will now be relayed to the talent pool channel"
+
+ history = await self.bot.api_client.get(
+ self.api_endpoint,
+ params={
+ "user__id": str(user.id),
+ "active": "false",
+ "ordering": "-inserted_at"
+ }
+ )
+
+ if history:
+ total = f"({len(history)} previous nominations in total)"
+ start_reason = f"Watched: {history[0]['reason']}"
+ end_reason = f"Unwatched: {history[0]['end_reason']}"
+ msg += f"\n\nUser's previous watch reasons {total}:```{start_reason}\n\n{end_reason}```"
+
+ await ctx.send(msg)
@nomination_group.command(name='history', aliases=('info', 'search'))
@with_role(Roles.owner, Roles.admin, Roles.moderator)
diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py
index ce8014d69..760e012eb 100644
--- a/bot/cogs/watchchannels/watchchannel.py
+++ b/bot/cogs/watchchannels/watchchannel.py
@@ -335,7 +335,7 @@ class WatchChannel(metaclass=CogABCMeta):
def cog_unload(self) -> None:
"""Takes care of unloading the cog and canceling the consumption task."""
self.log.trace(f"Unloading the cog")
- if not self._consume_task.done():
+ if self._consume_task and not self._consume_task.done():
self._consume_task.cancel()
try:
self._consume_task.result()
diff --git a/bot/converters.py b/bot/converters.py
index 339da7b60..cf0496541 100644
--- a/bot/converters.py
+++ b/bot/converters.py
@@ -4,6 +4,8 @@ from datetime import datetime
from ssl import CertificateError
from typing import Union
+import dateutil.parser
+import dateutil.tz
import discord
from aiohttp import ClientConnectorError
from dateutil.relativedelta import relativedelta
@@ -49,7 +51,7 @@ class ValidURL(Converter):
async with ctx.bot.http_session.get(url) as resp:
if resp.status != 200:
raise BadArgument(
- f"HTTP GET on `{url}` returned status `{resp.status_code}`, expected 200"
+ f"HTTP GET on `{url}` returned status `{resp.status}`, expected 200"
)
except CertificateError:
if url.startswith('https'):
@@ -215,3 +217,45 @@ class Duration(Converter):
now = datetime.utcnow()
return now + delta
+
+
+class ISODateTime(Converter):
+ """Converts an ISO-8601 datetime string into a datetime.datetime."""
+
+ async def convert(self, ctx: Context, datetime_string: str) -> datetime:
+ """
+ Converts a ISO-8601 `datetime_string` into a `datetime.datetime` object.
+
+ The converter is flexible in the formats it accepts, as it uses the `isoparse` method of
+ `dateutil.parser`. In general, it accepts datetime strings that start with a date,
+ optionally followed by a time. Specifying a timezone offset in the datetime string is
+ supported, but the `datetime` object will be converted to UTC and will be returned without
+ `tzinfo` as a timezone-unaware `datetime` object.
+
+ See: https://dateutil.readthedocs.io/en/stable/parser.html#dateutil.parser.isoparse
+
+ Formats that are guaranteed to be valid by our tests are:
+
+ - `YYYY-mm-ddTHH:MM:SSZ` | `YYYY-mm-dd HH:MM:SSZ`
+ - `YYYY-mm-ddTHH:MM:SS±HH:MM` | `YYYY-mm-dd HH:MM:SS±HH:MM`
+ - `YYYY-mm-ddTHH:MM:SS±HHMM` | `YYYY-mm-dd HH:MM:SS±HHMM`
+ - `YYYY-mm-ddTHH:MM:SS±HH` | `YYYY-mm-dd HH:MM:SS±HH`
+ - `YYYY-mm-ddTHH:MM:SS` | `YYYY-mm-dd HH:MM:SS`
+ - `YYYY-mm-ddTHH:MM` | `YYYY-mm-dd HH:MM`
+ - `YYYY-mm-dd`
+ - `YYYY-mm`
+ - `YYYY`
+
+ Note: ISO-8601 specifies a `T` as the separator between the date and the time part of the
+ datetime string. The converter accepts both a `T` and a single space character.
+ """
+ try:
+ dt = dateutil.parser.isoparse(datetime_string)
+ except ValueError:
+ raise BadArgument(f"`{datetime_string}` is not a valid ISO-8601 datetime string")
+
+ if dt.tzinfo:
+ dt = dt.astimezone(dateutil.tz.UTC)
+ dt = dt.replace(tzinfo=None)
+
+ return dt
diff --git a/tests/cogs/test_information.py b/tests/cogs/test_information.py
index 85b2d092e..184bd2595 100644
--- a/tests/cogs/test_information.py
+++ b/tests/cogs/test_information.py
@@ -8,6 +8,8 @@ import pytest
from discord import (
CategoryChannel,
Colour,
+ Permissions,
+ Role,
TextChannel,
VoiceChannel,
)
@@ -66,6 +68,52 @@ def test_roles_info_command(cog, ctx):
assert embed.footer.text == "Total roles: 1"
+def test_role_info_command(cog, ctx):
+ dummy_role = MagicMock(spec=Role)
+ dummy_role.name = "Dummy"
+ dummy_role.colour = Colour.blurple()
+ dummy_role.id = 112233445566778899
+ dummy_role.position = 10
+ dummy_role.permissions = Permissions(0)
+ dummy_role.members = [ctx.author]
+
+ admin_role = MagicMock(spec=Role)
+ admin_role.name = "Admin"
+ admin_role.colour = Colour.red()
+ admin_role.id = 998877665544332211
+ admin_role.position = 3
+ admin_role.permissions = Permissions(0)
+ admin_role.members = [ctx.author]
+
+ ctx.guild.roles = [dummy_role, admin_role]
+
+ cog.role_info.can_run = AsyncMock()
+ cog.role_info.can_run.return_value = True
+
+ coroutine = cog.role_info.callback(cog, ctx, dummy_role, admin_role)
+
+ assert asyncio.run(coroutine) is None
+
+ assert ctx.send.call_count == 2
+
+ (_, dummy_kwargs), (_, admin_kwargs) = ctx.send.call_args_list
+
+ dummy_embed = dummy_kwargs["embed"]
+ admin_embed = admin_kwargs["embed"]
+
+ assert dummy_embed.title == "Dummy info"
+ assert dummy_embed.colour == Colour.blurple()
+
+ assert dummy_embed.fields[0].value == str(dummy_role.id)
+ assert dummy_embed.fields[1].value == f"#{dummy_role.colour.value:0>6x}"
+ assert dummy_embed.fields[2].value == "0.63 0.48 218"
+ assert dummy_embed.fields[3].value == "1"
+ assert dummy_embed.fields[4].value == "10"
+ assert dummy_embed.fields[5].value == "0"
+
+ assert admin_embed.title == "Admin info"
+ assert admin_embed.colour == Colour.red()
+
# There is no argument passed in here that we can use to test,
# so the return value would change constantly.
@patch('bot.cogs.information.time_since')
diff --git a/tests/test_converters.py b/tests/test_converters.py
index 35fc5d88e..f69995ec6 100644
--- a/tests/test_converters.py
+++ b/tests/test_converters.py
@@ -8,6 +8,7 @@ from discord.ext.commands import BadArgument
from bot.converters import (
Duration,
+ ISODateTime,
TagContentConverter,
TagNameConverter,
ValidPythonIdentifier,
@@ -184,3 +185,80 @@ 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))
+
+
+ ("datetime_string", "expected_dt"),
+ (
+
+ # `YYYY-mm-ddTHH:MM:SSZ` | `YYYY-mm-dd HH:MM:SSZ`
+ ('2019-09-02T02:03:05Z', datetime.datetime(2019, 9, 2, 2, 3, 5)),
+ ('2019-09-02 02:03:05Z', datetime.datetime(2019, 9, 2, 2, 3, 5)),
+
+ # `YYYY-mm-ddTHH:MM:SS±HH:MM` | `YYYY-mm-dd HH:MM:SS±HH:MM`
+ ('2019-09-02T03:18:05+01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)),
+ ('2019-09-02 03:18:05+01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)),
+ ('2019-09-02T00:48:05-01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)),
+ ('2019-09-02 00:48:05-01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)),
+
+ # `YYYY-mm-ddTHH:MM:SS±HHMM` | `YYYY-mm-dd HH:MM:SS±HHMM`
+ ('2019-09-02T03:18:05+0115', datetime.datetime(2019, 9, 2, 2, 3, 5)),
+ ('2019-09-02 03:18:05+0115', datetime.datetime(2019, 9, 2, 2, 3, 5)),
+ ('2019-09-02T00:48:05-0115', datetime.datetime(2019, 9, 2, 2, 3, 5)),
+ ('2019-09-02 00:48:05-0115', datetime.datetime(2019, 9, 2, 2, 3, 5)),
+
+ # `YYYY-mm-ddTHH:MM:SS±HH` | `YYYY-mm-dd HH:MM:SS±HH`
+ ('2019-09-02 03:03:05+01', datetime.datetime(2019, 9, 2, 2, 3, 5)),
+ ('2019-09-02T01:03:05-01', datetime.datetime(2019, 9, 2, 2, 3, 5)),
+
+ # `YYYY-mm-ddTHH:MM:SS` | `YYYY-mm-dd HH:MM:SS`
+ ('2019-09-02T02:03:05', datetime.datetime(2019, 9, 2, 2, 3, 5)),
+ ('2019-09-02 02:03:05', datetime.datetime(2019, 9, 2, 2, 3, 5)),
+
+ # `YYYY-mm-ddTHH:MM` | `YYYY-mm-dd HH:MM`
+ ('2019-11-12T09:15', datetime.datetime(2019, 11, 12, 9, 15)),
+ ('2019-11-12 09:15', datetime.datetime(2019, 11, 12, 9, 15)),
+
+ # `YYYY-mm-dd`
+ ('2019-04-01', datetime.datetime(2019, 4, 1)),
+
+ # `YYYY-mm`
+ ('2019-02-01', datetime.datetime(2019, 2, 1)),
+
+ # `YYYY`
+ ('2025', datetime.datetime(2025, 1, 1)),
+ ),
+)
+def test_isodatetime_converter_for_valid(datetime_string: str, expected_dt: datetime.datetime):
+ converter = ISODateTime()
+ converted_dt = asyncio.run(converter.convert(None, datetime_string))
+ assert converted_dt.tzinfo is None
+ assert converted_dt == expected_dt
+
+
+ ("datetime_string"),
+ (
+ # Make sure it doesn't interfere with the Duration converter
+ ('1Y'),
+ ('1d'),
+ ('1H'),
+
+ # Check if it fails when only providing the optional time part
+ ('10:10:10'),
+ ('10:00'),
+
+ # Invalid date format
+ ('19-01-01'),
+
+ # Other non-valid strings
+ ('fisk the tag master'),
+ ),
+)
+def test_isodatetime_converter_for_invalid(datetime_string: str):
+ converter = ISODateTime()
+ with pytest.raises(
+ BadArgument,
+ match=f"`{datetime_string}` is not a valid ISO-8601 datetime string",
+ ):
+ asyncio.run(converter.convert(None, datetime_string))