diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 00000000..c21e52b4 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,71 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + types: [opened, synchronize] + +jobs: + tests: + runs-on: ubuntu-20.04 + strategy: + matrix: + python-version: ["3.7", "3.8", "3.9", "3.10"] + include: + - python-version: "3.7" + toxenv: py37 + - python-version: "3.8" + toxenv: py38 + - python-version: "3.9" + toxenv: py39 + - python-version: "3.10" + toxenv: py310-flake8 + env: + PYTHON: ${{ matrix.python-version }} + steps: + - name: examine environment + run: env + - uses: actions/checkout@v1 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install tox + - name: Test with tox + run: python -m tox + env: + TOXENV: ${{ matrix.toxenv }} + - uses: codecov/codecov-action@v1 + with: + env_vars: PYTHON + + docker: + runs-on: ubuntu-latest + steps: + - name: Build Docker image + uses: docker/build-push-action@v2 + with: + load: true + push: false + tags: csbot:latest + - name: Run tests inside Docker + run: docker run --rm csbot:latest pytest + - name: Login to GitHub Container Registry + if: github.event_name == 'push' && github.repository == 'HackSoc/csbot' && github.ref == 'refs/heads/main' + uses: docker/login-action@v1 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Publish Docker image + if: github.event_name == 'push' && github.repository == 'HackSoc/csbot' && github.ref == 'refs/heads/main' + uses: docker/build-push-action@v2 + with: + push: true + tags: | + ghcr.io/hacksoc/csbot/csbot:latest diff --git a/.gitignore b/.gitignore index e11da9cc..521658fa 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ pip-log.txt # Unit test / coverage reports .coverage +coverage*.xml .tox nosetests.xml _trial_temp/ diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 34fbfc18..00000000 --- a/.travis.yml +++ /dev/null @@ -1,25 +0,0 @@ -sudo: false -dist: xenial -language: python -install: - - pip install tox - -matrix: - include: - - python: '3.6' - env: TOXENV=py36-coveralls - - python: '3.7' - env: TOXENV=py37-coveralls-flake8 - -script: tox - -cache: - directories: - - $HOME/.cache/pip - -notifications: - irc: - channels: - - irc.freenode.org#cs-york-dev - skip_join: true - use_notice: true diff --git a/Dockerfile b/Dockerfile index ed86684f..5df8e9a2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.7 +FROM python:3.10 ARG UID=9000 ARG GID=9000 diff --git a/README.rst b/README.rst index fd77afdc..1d8a57ab 100644 --- a/README.rst +++ b/README.rst @@ -69,15 +69,14 @@ Testing ------- csbot has some unit tests. (It'd be nice to have more.) To run them:: - $ pytest + $ tox -We're also using Travis-CI for continuous integration and continuous deployment. +We're also using GitHub Actions for continuous integration and continuous deployment. -.. image:: https://travis-ci.org/HackSoc/csbot.svg?branch=master - :target: https://travis-ci.org/HackSoc/csbot +.. image:: https://github.com/HackSoc/csbot/actions/workflows/main.yml/badge.svg -.. image:: https://coveralls.io/repos/HackSoc/csbot/badge.png - :target: https://coveralls.io/r/HackSoc/csbot +.. image:: https://codecov.io/gh/HackSoc/csbot/branch/main/graph/badge.svg?token=oMJcY9E9lj + :target: https://codecov.io/gh/HackSoc/csbot .. [1] csbot depends on lxml_, which is a compiled extension module based on @@ -90,4 +89,4 @@ We're also using Travis-CI for continuous integration and continuous deployment. .. _lxml: http://lxml.de/ .. _Docker Compose: https://docs.docker.com/compose/ .. _published image: https://hub.docker.com/r/alanbriolat/csbot -.. _Watchtower: https://containrrr.github.io/watchtower/ \ No newline at end of file +.. _Watchtower: https://containrrr.github.io/watchtower/ diff --git a/csbot.deploy.toml b/csbot.deploy.toml index 21f990f1..8bae6e32 100644 --- a/csbot.deploy.toml +++ b/csbot.deploy.toml @@ -2,7 +2,7 @@ ircv3 = true nickname = "Mathison" auth_method = "sasl_plain" -irc_host = "irc.freenode.net" +irc_host = "irc.libera.chat" channels = [ "#cs-york", "#cs-york-dev", @@ -39,15 +39,15 @@ scan_limit = 2 [auth] "@everything" = "* *:*" Alan = "@everything" -hjmills = "@everything" +#hjmills = "@everything" barrucadu = "#cs-york:topic" Helzibah = "#cs-york:topic" -DinCahill = "#cs-york:topic" -jalada = "#cs-york:topic" -kyubiko = "#cs-york:topic #hacksoc:*" -eep = "#cs-york:topic #hacksoc:*" +#DinCahill = "#cs-york:topic" +#jalada = "#cs-york:topic" +#kyubiko = "#cs-york:topic #hacksoc:*" +#eep = "#cs-york:topic #hacksoc:*" fromankyra = "#hacksoc-bottest:*" -ldm = "#hacksoc:* #hacksoc-bottest:*" +luke = "#hacksoc:* #hacksoc-bottest:*" "*" = "#compsoc-uk:topic" diff --git a/docker-compose.test.yml b/docker-compose.test.yml deleted file mode 100644 index 503a0377..00000000 --- a/docker-compose.test.yml +++ /dev/null @@ -1,6 +0,0 @@ -version: "3" - -services: - sut: - image: "${IMAGE_NAME}" - command: pytest diff --git a/docker-compose.yml b/docker-compose.yml index e4bbb0b1..8913ec7e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ version: "3" services: bot: - image: alanbriolat/csbot:latest + image: ghcr.io/hacksoc/csbot/csbot:latest volumes: - ${CSBOT_CONFIG_LOCAL:-./csbot.toml}:/app/csbot.toml links: diff --git a/pytest.ini b/pytest.ini index 4f70ef19..43eede17 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,5 +1,6 @@ [pytest] testpaths = tests/ -addopts = --cov=src/ -W ignore::schematics.deprecated.SchematicsDeprecationWarning +addopts = --cov=src/ --cov-report=xml -W ignore::schematics.deprecated.SchematicsDeprecationWarning markers = bot: mark a test as Bot-based rather than IRCClient-based +asyncio_mode = auto diff --git a/requirements.txt b/requirements.txt index 77908d6c..287ea32c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,12 @@ # Requirements for unit testing -pytest>=5.2.2,<6.0 -pytest-asyncio==0.10.0 -pytest-aiohttp==0.3.0 -aioresponses==0.6.1 +pytest>=7.0.1,<8 +pytest-asyncio==0.18.1 +pytest-aiohttp==1.0.4 +aioresponses==0.7.3 pytest-cov asynctest==0.13.0 aiofastforward==0.0.24 +time-machine==2.6.0 mongomock # Requirements for documentation diff --git a/setup.py b/setup.py index 12136b2d..ab0ac41c 100644 --- a/setup.py +++ b/setup.py @@ -15,7 +15,7 @@ ], install_requires=[ 'click>=6.2,<7.0', - 'pymongo>=3.6.0', + 'pymongo>=4.0.1', 'requests>=2.9.1,<3.0.0', 'lxml>=2.3.5', 'aiogoogle>=0.1.13', diff --git a/src/csbot/config.py b/src/csbot/config.py index 23fc81c1..ee173b3b 100644 --- a/src/csbot/config.py +++ b/src/csbot/config.py @@ -350,8 +350,8 @@ def _write(self, s, raw=False): """Write *s* to the current stream; if *raw* is True, don't apply comment filter.""" if not raw and self._commented: lines = s.split("\n") - modified = [f"# {l}" if l and not l.startswith("#") else l - for l in lines] + modified = [f"# {line}" if line and not line.startswith("#") else line + for line in lines] s = "\n".join(modified) self._stream.write(s) self._at_start = False diff --git a/src/csbot/events.py b/src/csbot/events.py index b6527229..f99fe8c4 100644 --- a/src/csbot/events.py +++ b/src/csbot/events.py @@ -33,7 +33,7 @@ def __init__(self, get_handlers, loop=None): self.loop = loop self.events = deque() - self.new_events = asyncio.Event(loop=self.loop) + self.new_events = asyncio.Event() self.futures = set() self.future = None @@ -91,11 +91,7 @@ def _run_handler(self, handler, event): result = handler(event) except Exception as e: self._handle_exception(exception=e, csbot_event=event) - future = maybe_future( - result, - log=LOG, - loop=self.loop, - ) + future = maybe_future(result, log=LOG) if future: future = asyncio.ensure_future(self._finish_async_handler(future, event), loop=self.loop) return future @@ -129,9 +125,7 @@ async def _run(self): # Run until one or more futures complete (or new events are added) new_events = self.loop.create_task(self.new_events.wait()) LOG.debug('waiting on %s futures', len(self.futures)) - done, pending = await asyncio.wait(self.futures | {new_events}, - loop=self.loop, - return_when=asyncio.FIRST_COMPLETED) + done, pending = await asyncio.wait(self.futures | {new_events}, return_when=asyncio.FIRST_COMPLETED) # Remove done futures from the set of futures being waited on done_futures = done - {new_events} LOG.debug('%s of %s futures done', len(done_futures), len(self.futures)) diff --git a/src/csbot/irc.py b/src/csbot/irc.py index b94da48d..371278ff 100644 --- a/src/csbot/irc.py +++ b/src/csbot/irc.py @@ -276,9 +276,9 @@ def __init__(self, *, loop=None, **kwargs): self.reader, self.writer = None, None self._exiting = False - self.connected = asyncio.Event(loop=self.loop) + self.connected = asyncio.Event() self.connected.clear() - self.disconnected = asyncio.Event(loop=self.loop) + self.disconnected = asyncio.Event() self.disconnected.set() self._last_message_received = self.loop.time() self._client_ping = None @@ -325,7 +325,6 @@ async def connect(self): self.reader, self.writer = await asyncio.open_connection(self.__config['host'], self.__config['port'], - loop=self.loop, local_addr=local_addr) def disconnect(self): diff --git a/src/csbot/plugins/last.py b/src/csbot/plugins/last.py index c1fc59d0..fbba774f 100644 --- a/src/csbot/plugins/last.py +++ b/src/csbot/plugins/last.py @@ -24,7 +24,9 @@ def last(self, nick, channel=None, msgtype=None): if msgtype is not None: search['type'] = msgtype - return self.db.find_one(search, sort=[('when', pymongo.DESCENDING)]) + # Additional sorting by _id to make sort order stable for messages that arrive in the same millisecond + # (which sometimes happens during tests). + return self.db.find_one(search, sort=[('when', pymongo.DESCENDING), ('_id', pymongo.DESCENDING)]) def last_message(self, nick, channel=None): """Get the last message sent by a nick, optionally filtering @@ -104,8 +106,7 @@ def _schedule_update(self, e, query, update): @Plugin.hook('last.update') def _apply_update(self, e): - self.db.remove(e['query']) - self.db.insert(e['update']) + self.db.replace_one(e['query'], e['update'], upsert=True) @Plugin.command('seen', help=('seen nick [type]: show the last thing' ' said by a nick in this channel, optionally' diff --git a/src/csbot/plugins/termdates.py b/src/csbot/plugins/termdates.py index a9dd869b..e30b3638 100644 --- a/src/csbot/plugins/termdates.py +++ b/src/csbot/plugins/termdates.py @@ -1,43 +1,95 @@ from csbot.plugin import Plugin -from datetime import datetime, timedelta +import datetime import math +import typing as _t from ..util import ordinal +class Term: + def __init__(self, key: str, start_date: datetime.datetime): + self.key = key + self.start_date = start_date + + @property + def first_monday(self) -> datetime.datetime: + return self.start_date - datetime.timedelta(days=self.start_date.weekday()) + + @property + def last_friday(self) -> datetime.datetime: + return self.first_monday + datetime.timedelta(days=4, weeks=9) + + def get_week_number(self, date: datetime.date) -> int: + """Get the "term week number" of a date relative to this term. + + The first week of term is week 1, not week 0. Week 1 starts at the + Monday of the term's start date, even if the term's start date is not + Monday. Any date before the start of the term gives a negative week + number. + """ + delta = date - self.first_monday.date() + week_number = math.floor(delta.days / 7.0) + if week_number >= 0: + return week_number + 1 + else: + return week_number + + def get_week_start(self, week_number: int) -> datetime.datetime: + """Get the start date of a specific week number relative to this term. + + The first week of term is week 1, not week 0, although this method + allows both. When referring to the first week of term, the start date is + the term start date (which may not be a Monday). All other weeks start + on their Monday. + """ + if week_number in (0, 1): + return self.start_date + elif week_number > 1: + return self.first_monday + datetime.timedelta(weeks=week_number - 1) + else: + return self.first_monday + datetime.timedelta(weeks=week_number) + + class TermDates(Plugin): """ A wonderful plugin allowing old people (graduates) to keep track of the ever-changing calendar. """ DATE_FORMAT = '%Y-%m-%d' + TERM_KEYS = ('aut', 'spr', 'sum') db_terms = Plugin.use('mongodb', collection='terms') - db_weeks = Plugin.use('mongodb', collection='weeks') + + terms = None + _doc_id = None def setup(self): super(TermDates, self).setup() + self._load() + + def _load(self): + doc = self.db_terms.find_one() + if not doc: + return False + self.terms = {key: Term(key, doc[key][0]) for key in self.TERM_KEYS} + self._doc_id = doc['_id'] + return True + + def _save(self): + if not self.terms: + return False + doc = {key: (self.terms[key].start_date, self.terms[key].last_friday) for key in self.TERM_KEYS} + if self._doc_id: + self.db_terms.replace_one({'_id': self._doc_id}, doc, upsert=True) + else: + res = self.db_terms.insert_one(doc) + self._doc_id = res.inserted_id + return True - # If we have stuff in mongodb, we can just load it directly. - if self.db_terms.find_one(): - self.initialised = True - self.terms = self.db_terms.find_one() - self.weeks = self.db_weeks.find_one() - return - - # If no term dates have been set, the calendar is uninitialised and - # can't be asked about term things. - self.initialised = False - - # Each term is represented as a tuple of the date of the first Monday - # and the last Friday in it. - self.terms = {term: (None, None) - for term in ['aut', 'spr', 'sum']} - - # And each week is just the date of the Monday - self.weeks = {'{} {}'.format(term, week): None - for term in ['aut', 'spr', 'sum'] - for week in range(1, 11)} + @property + def initialised(self) -> bool: + """If no term dates have been set, the calendar is uninitialised and can't be asked about term thing.""" + return self._doc_id is not None @Plugin.command('termdates', help='termdates: show the current term dates') def termdates(self, e): @@ -55,7 +107,7 @@ def _term_start(self, term): """ term = term.lower() - return self.terms[term][0].strftime(self.DATE_FORMAT) + return self.terms[term].start_date.strftime(self.DATE_FORMAT) def _term_end(self, term): """ @@ -63,7 +115,7 @@ def _term_end(self, term): """ term = term.lower() - return self.terms[term][1].strftime(self.DATE_FORMAT) + return self.terms[term].last_friday.strftime(self.DATE_FORMAT) @Plugin.command('week', help='week [term] [num]: info about a week, ' @@ -80,81 +132,74 @@ def week(self, e): # !week term n - get the date of week n in the given term # !week n term - as above - week = e['data'].split() + week = e['data'].lower().split() if len(week) == 0: - term, weeknum = self._current_week() + term, week_number = self._current_week() elif len(week) == 1: try: - term = self._current_term() - weeknum = int(week[0]) - if weeknum < 1: + term = self._current_or_next_term() + week_number = int(week[0]) + if week_number < 1: e.reply('error: bad week format') return except ValueError: - term = week[0][:3] - term, weeknum = self._current_week(term) + term_key = week[0][:3] + term, week_number = self._current_week(term_key) elif len(week) >= 2: try: - term = week[0][:3] - weeknum = int(week[1]) + term_key = week[0][:3] + week_number = int(week[1]) except ValueError: try: - term = week[1][:3] - weeknum = int(week[0]) + term_key = week[1][:3] + week_number = int(week[0]) except ValueError: e.reply('error: bad week format') return + try: + term = self.terms[term_key] + except KeyError: + e.reply('error: bad week format') + return else: e.reply('error: bad week format') return - if weeknum > 0: - e.reply('{} {}: {}'.format(term.capitalize(), - weeknum, - self._week_start(term, weeknum))) + if term is None: + e.reply('error: no term dates (see termdates.set)') + elif week_number > 0: + e.reply('{} {}: {}'.format(term.key.capitalize(), + week_number, + term.get_week_start(week_number).strftime(self.DATE_FORMAT))) else: e.reply('{} week before {} (starts {})' - .format(ordinal(-weeknum), - term.capitalize(), - self._week_start(term, 1))) + .format(ordinal(-week_number), + term.key.capitalize(), + term.start_date.strftime(self.DATE_FORMAT))) - def _current_term(self): + def _current_or_next_term(self) -> _t.Optional[Term]: """ Get the name of the current term """ - now = datetime.now().date() - for term in ['aut', 'spr', 'sum']: - dates = self.terms[term] - if now >= dates[0].date() and now <= dates[1].date(): + now = datetime.datetime.now().date() + for key in self.TERM_KEYS: + term = self.terms[key] + if now < term.first_monday.date(): return term - elif now <= dates[0].date(): - # We can do this because the terms are ordered + elif now <= term.last_friday.date(): return term + return None - def _current_week(self, term=None): - if term is None: - term = self._current_term() - start, _ = self.terms[term] - now = datetime.now() - delta = now.date() - start.date() - weeknum = math.floor(delta.days / 7.0) - if weeknum >= 0: - weeknum += 1 - return term, weeknum - - def _week_start(self, term, week): - """ - Get the start date of a week as a string. - """ - - term = term.lower() - start = self.weeks['{} 1'.format(term)] - if week > 0: - offset = timedelta(weeks=week - 1) + def _current_week(self, key: _t.Optional[str] = None) -> (_t.Optional[Term], _t.Optional[int]): + if key: + term = self.terms.get(key.lower()) + else: + term = self._current_or_next_term() + if term: + return term, term.get_week_number(datetime.date.today()) else: - offset = timedelta(weeks=week) - return (start + offset).strftime(self.DATE_FORMAT) + return None, None @Plugin.command('termdates.set', help='termdates.set : set the term dates') @@ -165,39 +210,15 @@ def termdates_set(self, e): e.reply('error: all three dates must be provided') return - # Firstly compute the start and end dates of each term - for term, date in zip(['aut', 'spr', 'sum'], dates): + terms = {} + for key, date in zip(self.TERM_KEYS, dates): try: - term_start = datetime.strptime(date, self.DATE_FORMAT) + term_start = datetime.datetime.strptime(date, self.DATE_FORMAT) except ValueError: e.reply('error: dates must be in %Y-%M-%d format.') return - # Not all terms start on a monday, so we need to compute the "real" - # term start used in all the other calculations. - # Fortunately Monday is used as the start of the week in Python's - # datetime stuff, which makes this really simple. - real_start = term_start - timedelta(days=term_start.weekday()) - - # Log for informational purposes - if not term_start == real_start: - self.log.info('Computed real_start as {} (from {})'.format( - repr(real_start), repr(term_start))) - - term_end = real_start + timedelta(days=4, weeks=9) - self.terms[term] = (term_start, term_end) - - # Then the start of each week - self.weeks['{} 1'.format(term)] = term_start - for week in range(2, 11): - week_start = real_start + timedelta(weeks=week-1) - self.weeks['{} {}'.format(term, week)] = week_start - - # Save to the database. As we don't touch the _id attribute in this - # method, this will cause `save` to override the previously-loaded - # entry (if there is one). - self.db_terms.save(self.terms) - self.db_weeks.save(self.weeks) - - # Finally, we're initialised! - self.initialised = True + terms[key] = Term(key, term_start) + + self.terms = terms + self._save() diff --git a/src/csbot/plugins/youtube.py b/src/csbot/plugins/youtube.py index ead5d495..70a264a1 100644 --- a/src/csbot/plugins/youtube.py +++ b/src/csbot/plugins/youtube.py @@ -56,7 +56,7 @@ class Youtube(Plugin): 'api_key': ['YOUTUBE_DATA_API_KEY'], } - RESPONSE = '"{title}" [{duration}] (by {uploader} at {uploaded}) | Views: {views} [{likes}]' + RESPONSE = '"{title}" [{duration}] (by {uploader} at {uploaded}) | Views: {views}' CMD_RESPONSE = RESPONSE + ' | {link}' async def get_video_json(self, id): @@ -130,13 +130,6 @@ async def _yt(self, url): except KeyError: vid_info["views"] = "N/A" - try: - likes = int(json["statistics"]["likeCount"]) - dislikes = int(json["statistics"]["dislikeCount"]) - vid_info["likes"] = "+{:,}/-{:,}".format(likes, dislikes) - except KeyError: - vid_info["likes"] = "N/A" - return vid_info @Plugin.integrate_with('linkinfo') diff --git a/src/csbot/util.py b/src/csbot/util.py index 49f4c3f9..4c011672 100644 --- a/src/csbot/util.py +++ b/src/csbot/util.py @@ -127,14 +127,14 @@ def pairwise(iterable): return zip(a, b) -def cap_string(s, l): +def cap_string(s, n): """If a string is longer than a particular length, it gets truncated and has '...' added to the end. """ - if len(s) <= l: + if len(s) <= n: return s - return s[0:l-3] + "..." + return s[0:n - 3] + "..." def ordinal(value): diff --git a/tests/__init__.py b/tests/__init__.py index 8e4f45b3..5e27da92 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -12,10 +12,10 @@ def close(self): self._reader.feed_eof() -def paused(f, *, loop=None): +def paused(f): """Wrap a coroutine function so it waits until explicitly enabled. """ - event = asyncio.Event(loop=loop) + event = asyncio.Event() resume = event.set async def _f(*args, **kwargs): @@ -27,9 +27,10 @@ async def _f(*args, **kwargs): return _f, resume -async def open_mock_connection(*args, loop=None, **kwargs): +async def open_mock_connection(*args, **kwargs): """Create a mock reader and writer pair. """ + loop = asyncio.get_running_loop() reader = MockStreamReader(loop=loop) writer = MockStreamWriter(None, None, reader, loop) writer.write = mock.Mock() diff --git a/tests/conftest.py b/tests/conftest.py index c65007b2..e25f9be5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +from __future__ import annotations import asyncio from textwrap import dedent from unittest import mock @@ -29,7 +30,7 @@ def fast_forward(event_loop): async def f(n): # The wait_for() prevents forward(n) from blocking if there isn't enough async work to do try: - await asyncio.wait_for(forward(n), n, loop=event_loop) + await asyncio.wait_for(forward(n), n) except asyncio.TimeoutError: pass yield f @@ -80,6 +81,30 @@ async def irc_client(request, event_loop, config_example_mode, irc_client_class, return client +class LineMatcher: + def __init__(self, f, description): + self.f = f + self.description = description + + def __call__(self, line): + return self.f(line) + + def __repr__(self): + return self.description + + @classmethod + def equals(cls, other): + return cls(lambda line: line == other, f"`line == {other!r}`") + + @classmethod + def contains(cls, other): + return cls(lambda line: other in line, f"`{other!r} in line`") + + @classmethod + def endswith(cls, other): + return cls(lambda line: line.endswith(other), f"`line.endswith({other!r})`") + + class IRCClientHelper: def __init__(self, irc_client): self.client = irc_client @@ -122,18 +147,39 @@ def receive(self, lines): """Shortcut to push a series of lines to the client.""" if isinstance(lines, str): lines = [lines] - return [self.client.line_received(l) for l in lines] + return [self.client.line_received(line) for line in lines] - def assert_sent(self, lines): + def assert_sent(self, matchers, *, any_order=False, reset_mock=True): """Check that a list of (unicode) strings have been sent. Resets the mock so the next call will not contain what was checked by this call. """ - if isinstance(lines, str): - lines = [lines] - self.client.send_line.assert_has_calls([mock.call(l) for l in lines]) - self.client.send_line.reset_mock() + sent_lines = [args[0] for name, args, kwargs in self.client.send_line.mock_calls] + + if callable(matchers) or isinstance(matchers, str): + matchers = [matchers] + matchers = [LineMatcher.equals(matcher) if not callable(matcher) else matcher + for matcher in matchers] + + if not matchers: + pass + elif any_order: + for matcher in matchers: + assert any(matcher(line) for line in sent_lines), f"sent line not found: {matcher}" + else: + # Find the start of the matching run of sent messages + start = 0 + while start < len(sent_lines) and not matchers[0](sent_lines[start]): + start += 1 + for i, matcher in enumerate(matchers): + assert start + i < len(sent_lines), f"no line matching {matcher} in {sent_lines}" + assert matcher(sent_lines[start + i]), f"expected {sent_lines[start + i]!r} to match {matcher}" + + if reset_mock: + self.client.send_line.reset_mock() + + match_line = LineMatcher @pytest.fixture @@ -149,7 +195,6 @@ async def run_client(event_loop, irc_client_helper): point it should yield control to allow the client to progress. >>> @pytest.mark.usefixtures("run_client") - ... @pytest.mark.asyncio ... async def test_something(irc_client_helper): ... await irc_client_helper.receive_bytes(b":nick!user@host PRIVMSG #channel :hello\r\n") ... irc_client_helper.assert_sent('PRIVMSG #channel :what do you mean, hello?') @@ -171,7 +216,7 @@ def bot_helper_class(): @pytest.fixture -def bot_helper(irc_client, bot_helper_class): +def bot_helper(irc_client, bot_helper_class) -> BotHelper: irc_client.bot_setup() return bot_helper_class(irc_client) diff --git a/tests/test_bot.py b/tests/test_bot.py index ce398af6..875c7a8e 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -103,7 +103,6 @@ def quit(self, event): pytestmark = pytest.mark.bot(plugins=[MockPlugin], config=CONFIG) - @pytest.mark.asyncio async def test_hooks(self, bot_helper): """Check that hooks fire in the expected way.""" bot = bot_helper.bot @@ -136,7 +135,6 @@ async def test_hooks(self, bot_helper): mock.call('test3', {}), ] - @pytest.mark.asyncio @pytest.mark.parametrize('n', list(range(1, 10))) async def test_burst_in_order(self, bot_helper, n): """Check that a plugin always gets messages in receive order.""" @@ -146,7 +144,6 @@ async def test_burst_in_order(self, bot_helper, n): await asyncio.wait(bot_helper.receive(messages)) assert plugin.handler_mock.mock_calls == [mock.call('quit', user) for user in users] - @pytest.mark.asyncio async def test_non_blocking(self, bot_helper): """Check that long-running hooks don't block other events from being processed.""" plugin = bot_helper['mockplugin'] @@ -188,14 +185,13 @@ def command_b(self, *args, **kwargs): def command_cd(self, *args, **kwargs): self.handler_mock(inspect.currentframe().f_code.co_name) - CONFIG_A = f"""\ + CONFIG_A = """\ ["@bot"] command_prefix = "&" plugins = ["mockplugin1"] """ @pytest.mark.bot(plugins=[MockPlugin1], config=CONFIG_A) - @pytest.mark.asyncio async def test_command_help(self, bot_helper): """Check that commands fire in the expected way.""" await asyncio.wait(bot_helper.receive([':nick!user@host PRIVMSG #channel :&plugins'])) @@ -214,7 +210,6 @@ async def test_command_help(self, bot_helper): bot_helper.assert_sent('NOTICE #channel :This command has help') @pytest.mark.bot(plugins=[MockPlugin1], config=CONFIG_A) - @pytest.mark.asyncio async def test_command_fired(self, bot_helper): """Check that commands fire in the expected way.""" plugin = bot_helper['mockplugin1'] @@ -248,14 +243,13 @@ def __init__(self, *args, **kwargs): def command_a(self, *args, **kwargs): self.handler_mock(inspect.currentframe().f_code.co_name) - CONFIG_B = f"""\ + CONFIG_B = """\ ["@bot"] command_prefix = "&" plugins = ["mockplugin1", "mockplugin2"] """ @pytest.mark.bot(plugins=[MockPlugin1, MockPlugin2], config=CONFIG_B) - @pytest.mark.asyncio async def test_command_single_handler(self, caplog, bot_helper): count = 0 for r in caplog.get_records("setup"): diff --git a/tests/test_config.py b/tests/test_config.py index 949cd2ac..b653b39b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -749,7 +749,7 @@ def _test_config_generator_list_wrap(): class Config(config.Config): a = config.option_list(str, default=["abcdefghijklmnopqrstuvwxyz" for _ in range(2)], help="") b = config.option_list(str, default=["abcdefghijklmnopqrstuvwxyz" for _ in range(5)], help="") - abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz = config.option_list(str, help="") + abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz = config.option_list(str, help="") # noqa: E501 return Config, [ # Shorter than threshold, don't split @@ -763,7 +763,7 @@ class Config(config.Config): ' "abcdefghijklmnopqrstuvwxyz",', ']', # Key longer than threshold, but no items, don't split - 'abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz = []', + 'abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz = []', # noqa: E501 ] diff --git a/tests/test_events.py b/tests/test_events.py index dcc971a9..bcc4f296 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -10,7 +10,6 @@ import csbot.events -@pytest.mark.asyncio class TestHybridEventRunner: class EventHandler: def __init__(self): @@ -110,7 +109,7 @@ async def test_event_chain_asynchronous(self, event_loop, event_runner): Any events that occur during an event handler should be processed before the initial `post_event()` future has a result. """ - events = [asyncio.Event(loop=event_loop) for _ in range(2)] + events = [asyncio.Event() for _ in range(2)] complete = [] @event_runner.add_handler('a') @@ -156,7 +155,7 @@ async def e(_): # - should have a post_event('a') call # - a1 should complete, a2 is blocked on events[0] future = event_runner.runner.post_event('a') - await asyncio.wait({future}, loop=event_loop, timeout=0.1) + await asyncio.wait({future}, timeout=0.1) assert not future.done() assert event_runner.get_handlers.mock_calls == [ mock.call('a'), @@ -172,7 +171,7 @@ async def e(_): # - post_event('f') should be called (by c) # - d should complete events[0].set() - await asyncio.wait({future}, loop=event_loop, timeout=0.1) + await asyncio.wait({future}, timeout=0.1) assert not future.done() assert event_runner.get_handlers.mock_calls == [ mock.call('a'), @@ -189,7 +188,7 @@ async def e(_): # - e should complete # - future should complete, because no events or tasks remain pending events[1].set() - await asyncio.wait({future}, loop=event_loop, timeout=0.1) + await asyncio.wait({future}, timeout=0.1) assert future.done() assert event_runner.get_handlers.mock_calls == [ mock.call('a'), @@ -208,7 +207,7 @@ async def test_event_chain_hybrid(self, event_loop, event_runner): event all run before synchronous handlers for the next event, but asynchronous handers can run out-of-order. """ - events = [asyncio.Event(loop=event_loop) for _ in range(2)] + events = [asyncio.Event() for _ in range(2)] complete = [] @event_runner.add_handler('a') @@ -252,7 +251,7 @@ def d2(_): # - post_event('a') should be called (initial) # - a1 should complete, a2 is blocked on events[0] future = event_runner.runner.post_event('a') - await asyncio.wait({future}, loop=event_loop, timeout=0.1) + await asyncio.wait({future}, timeout=0.1) assert not future.done() assert event_runner.get_handlers.mock_calls == [ mock.call('a'), @@ -267,7 +266,7 @@ def d2(_): # - d2 should complete (synchronous phase) # - d1 should complete (asynchronous phase) events[0].set() - await asyncio.wait({future}, loop=event_loop, timeout=0.1) + await asyncio.wait({future}, timeout=0.1) assert not future.done() assert event_runner.get_handlers.mock_calls == [ mock.call('a'), @@ -283,7 +282,7 @@ def d2(_): # - c2 should complete (asynchronous phase) # - future should complete, because no events or tasks remain pending events[1].set() - await asyncio.wait({future}, loop=event_loop, timeout=0.1) + await asyncio.wait({future}, timeout=0.1) assert future.done() assert event_runner.get_handlers.mock_calls == [ mock.call('a'), @@ -295,7 +294,7 @@ def d2(_): async def test_overlapping_root_events(self, event_loop, event_runner): """Check that overlapping events get the same future.""" - events = [asyncio.Event(loop=event_loop) for _ in range(1)] + events = [asyncio.Event() for _ in range(1)] complete = [] @event_runner.add_handler('a') @@ -310,7 +309,7 @@ async def b(_): # Post the first event and allow tasks to run: # - a is blocked on events[0] f1 = event_runner.runner.post_event('a') - await asyncio.wait({f1}, loop=event_loop, timeout=0.1) + await asyncio.wait({f1}, timeout=0.1) assert not f1.done() assert complete == [] @@ -319,7 +318,7 @@ async def b(_): # - a is still blocked on events[0] # - f1 and f2 are not done, because they're for the same run loop, and a is still blocked f2 = event_runner.runner.post_event('b') - await asyncio.wait({f2}, loop=event_loop, timeout=0.1) + await asyncio.wait({f2}, timeout=0.1) assert not f2.done() assert not f1.done() assert complete == ['b'] @@ -328,7 +327,7 @@ async def b(_): # - a completes # - f1 and f2 are both done, because the run loop has finished events[0].set() - await asyncio.wait([f1, f2], loop=event_loop, timeout=0.1) + await asyncio.wait([f1, f2], timeout=0.1) assert f1.done() assert f2.done() assert complete == ['b', 'a'] @@ -349,14 +348,14 @@ async def b(_): complete.append('b') f1 = event_runner.runner.post_event('a') - await asyncio.wait({f1}, loop=event_loop, timeout=0.1) + await asyncio.wait({f1}, timeout=0.1) assert f1.done() assert complete == ['a'] f2 = event_runner.runner.post_event('b') assert not f2.done() assert f2 is not f1 - await asyncio.wait({f2}, loop=event_loop, timeout=0.1) + await asyncio.wait({f2}, timeout=0.1) assert f2.done() assert complete == ['a', 'b'] @@ -394,7 +393,7 @@ async def b2(_): assert event_runner.exception_handler.call_count == 0 future = event_runner.runner.post_event('a') - await asyncio.wait({future}, loop=event_loop, timeout=0.1) + await asyncio.wait({future}, timeout=0.1) assert future.done() assert future.exception() is None assert event_runner.runner.get_handlers.mock_calls == [ diff --git a/tests/test_irc.py b/tests/test_irc.py index d7d4c770..915fa7cf 100644 --- a/tests/test_irc.py +++ b/tests/test_irc.py @@ -9,7 +9,6 @@ # Test IRC client line protocol -@pytest.mark.asyncio async def test_buffer(run_client): """Check that incoming data is converted to a line-oriented protocol.""" with run_client.patch('line_received') as m: @@ -34,7 +33,6 @@ async def test_buffer(run_client): ]) -@pytest.mark.asyncio async def test_decode_ascii(run_client): """Check that plain ASCII ends up as a (unicode) string.""" with run_client.patch('line_received') as m: @@ -42,7 +40,6 @@ async def test_decode_ascii(run_client): m.assert_called_once_with(':nick!user@host PRIVMSG #channel :hello') -@pytest.mark.asyncio async def test_decode_utf8(run_client): """Check that incoming UTF-8 is properly decoded.""" with run_client.patch('line_received') as m: @@ -50,7 +47,6 @@ async def test_decode_utf8(run_client): m.assert_called_once_with(':nick!user@host PRIVMSG #channel :ಠ') -@pytest.mark.asyncio async def test_decode_cp1252(run_client): """Check that incoming CP1252 is properly decoded. @@ -62,7 +58,6 @@ async def test_decode_cp1252(run_client): m.assert_called_once_with(':nick!user@host PRIVMSG #channel :“”') -@pytest.mark.asyncio async def test_decode_invalid_sequence(run_client): """Check that incoming invalid byte sequence is properly handled. @@ -91,7 +86,6 @@ def test_truncate(irc_client_helper): # Test IRC client behaviour -@pytest.mark.asyncio async def test_auto_reconnect_eof(run_client): with mock_open_connection_paused() as m: assert not m.called @@ -105,7 +99,6 @@ async def test_auto_reconnect_eof(run_client): m.assert_called_once() -@pytest.mark.asyncio async def test_auto_reconnect_exception(run_client): with mock_open_connection_paused() as m: assert not m.called @@ -119,7 +112,6 @@ async def test_auto_reconnect_exception(run_client): m.assert_called_once() -@pytest.mark.asyncio async def test_disconnect(run_client): with mock_open_connection() as m: assert not m.called @@ -141,7 +133,6 @@ def irc_client_config(self): 'rate_limit_count': 2, } - @pytest.mark.asyncio async def test_rate_limit_applied(self, fast_forward, run_client): # Make sure startup messages don't contribute to the test await fast_forward(10) @@ -160,7 +151,6 @@ async def test_rate_limit_applied(self, fast_forward, run_client): run_client.assert_bytes_sent(b"PRIVMSG #channel :3\r\n" b"PRIVMSG #channel :4\r\n") - @pytest.mark.asyncio async def test_cancel_on_disconnect(self, fast_forward, run_client): with mock_open_connection_paused() as m: # Make sure startup messages don't contribute to the test @@ -197,70 +187,44 @@ def irc_client_config(self): 'client_ping_interval': 3, } - @pytest.mark.asyncio async def test_client_PING(self, fast_forward, run_client): """Check that client PING commands are sent at the expected interval.""" run_client.reset_mock() run_client.client.send_line.assert_not_called() # Advance time, test that a ping was sent await fast_forward(4) - assert run_client.client.send_line.mock_calls == [ - mock.call('PING 1'), - ] + run_client.assert_sent(['PING 1'], reset_mock=False) # Advance time again, test that the right number of pings was sent await fast_forward(12) - assert run_client.client.send_line.mock_calls == [ - mock.call('PING 1'), - mock.call('PING 2'), - mock.call('PING 3'), - mock.call('PING 4'), - mock.call('PING 5'), - ] + run_client.assert_sent(['PING 1', 'PING 2', 'PING 3', 'PING 4', 'PING 5'], reset_mock=False) # Disconnect, advance time, test that no more pings were sent run_client.client.disconnect() await run_client.client.disconnected.wait() await fast_forward(12) - assert run_client.client.send_line.mock_calls == [ - mock.call('PING 1'), - mock.call('PING 2'), - mock.call('PING 3'), - mock.call('PING 4'), - mock.call('PING 5'), - ] - - @pytest.mark.asyncio + run_client.assert_sent(['PING 1', 'PING 2', 'PING 3', 'PING 4', 'PING 5'], reset_mock=False) + async def test_client_PING_only_when_needed(self, fast_forward, run_client): """Check that client PING commands are sent relative to the last received message.""" run_client.reset_mock() run_client.client.send_line.assert_not_called() # Advance time to just before the second PING, check that the first PING was sent await fast_forward(5) - assert run_client.client.send_line.mock_calls == [ - mock.call('PING 1'), - ] + run_client.assert_sent(['PING 1'], reset_mock=False) # Receive a message, this should reset the PING timer run_client.receive(':nick!user@host PRIVMSG #channel :foo') # Advance time to just after when the second PING would happen without any messages # received, check that still only one PING was sent await fast_forward(2) - assert run_client.client.send_line.mock_calls == [ - mock.call('PING 1'), - ] + run_client.assert_sent(['PING 1'], reset_mock=False) # Advance time to 4 seconds after the last message was received, and check that another # PING has now been sent await fast_forward(2) - assert run_client.client.send_line.mock_calls == [ - mock.call('PING 1'), - mock.call('PING 2'), - ] + run_client.assert_sent(['PING 1', 'PING 2'], reset_mock=False) # Disconnect, advance time, test that no more pings were sent run_client.client.disconnect() await run_client.client.disconnected.wait() await fast_forward(12) - assert run_client.client.send_line.mock_calls == [ - mock.call('PING 1'), - mock.call('PING 2'), - ] + run_client.assert_sent(['PING 1', 'PING 2'], reset_mock=False) def test_PING_PONG(irc_client_helper): @@ -408,7 +372,6 @@ def test_parse_failure(irc_client_helper): irc_client_helper.receive('') -@pytest.mark.asyncio async def test_wait_for_success(irc_client_helper): messages = [ IRCMessage.create('PING', ['0']), @@ -444,7 +407,6 @@ async def test_wait_for_success(irc_client_helper): ] -@pytest.mark.asyncio async def test_wait_for_cancelled(irc_client_helper): messages = [ IRCMessage.create('PING', ['0']), @@ -469,7 +431,6 @@ async def test_wait_for_cancelled(irc_client_helper): ] -@pytest.mark.asyncio async def test_wait_for_exception(irc_client_helper): messages = [ IRCMessage.create('PING', ['0']), @@ -523,7 +484,6 @@ def test_quit(irc_client_helper): irc_client_helper.assert_sent('QUIT :reason') -@pytest.mark.asyncio async def test_quit_no_reconnect(run_client): with run_client.patch('connect') as m: run_client.client.quit(reconnect=False) @@ -532,7 +492,6 @@ async def test_quit_no_reconnect(run_client): assert not m.called -@pytest.mark.asyncio async def test_quit_reconnect(run_client): with run_client.patch('connect') as m: run_client.client.quit(reconnect=True) diff --git a/tests/test_plugin_calc.py b/tests/test_plugin_calc.py index 3c2e4291..b9f9c474 100644 --- a/tests/test_plugin_calc.py +++ b/tests/test_plugin_calc.py @@ -1,3 +1,5 @@ +import sys + import pytest @@ -51,7 +53,11 @@ def test_error(bot_helper): assert calc._calc("'B' > 'H'") == "Error, invalid argument" assert calc._calc("e ^ pi") == "Error, invalid arguments" assert calc._calc("factorial(-42)") == "Error, factorial() not defined for negative values" - assert calc._calc("factorial(4.2)") == "Error, factorial() only accepts integral values" + if sys.version_info < (3, 10): + assert calc._calc("factorial(4.2)") == "Error, factorial() only accepts integral values" + else: + assert calc._calc("factorial(4.2)") == "Error, invalid arguments" assert calc._calc("not await 1").startswith("Error,") # ast SyntaxError in Python 3.6 but not 3.7 - assert calc._calc("(" * 200 + ")" * 200) == "Error, unable to parse" + if sys.version_info < (3, 9): + assert calc._calc("(" * 200 + ")" * 200) == "Error, unable to parse" assert calc._calc("1@2") == "Error, invalid operator" diff --git a/tests/test_plugin_github.py b/tests/test_plugin_github.py index 3be1a8e3..f0e1ceb7 100644 --- a/tests/test_plugin_github.py +++ b/tests/test_plugin_github.py @@ -67,8 +67,8 @@ class TestGitHubPlugin: ["github/alanbriolat/csbot-webhook-test"] notify = "#mychannel" - """ - URL = f'/webhook/github/foobar' + """ # noqa: E501 + URL = '/webhook/github/foobar' pytestmark = pytest.mark.bot(plugins=PLUGINS, config=CONFIG) TEST_CASES = [ @@ -200,7 +200,6 @@ class TestGitHubPlugin: @pytest.mark.parametrize("fixture_file, expected", TEST_CASES) @pytest.mark.usefixtures("run_client") - @pytest.mark.asyncio async def test_behaviour(self, bot_helper, client, fixture_file, expected): payload, headers = bot_helper.payload_and_headers_from_fixture(fixture_file) resp = await client.post(self.URL, data=payload, headers=headers) diff --git a/tests/test_plugin_imgur.py b/tests/test_plugin_imgur.py index 18cc3f85..72862db9 100644 --- a/tests/test_plugin_imgur.py +++ b/tests/test_plugin_imgur.py @@ -164,7 +164,6 @@ """) -@pytest.mark.asyncio @pytest.mark.parametrize("url, api_url, status, content_type, fixture, title", test_cases) async def test_integration(bot_helper, aioresponses, url, api_url, status, content_type, fixture, title): aioresponses.get(api_url, status=status, @@ -178,7 +177,6 @@ async def test_integration(bot_helper, aioresponses, url, api_url, status, conte assert title == result.text -@pytest.mark.asyncio @pytest.mark.parametrize("url, api_url, status, content_type, fixture, title", nsfw_test_cases) async def test_integration_nsfw(bot_helper, aioresponses, url, api_url, status, content_type, fixture, title): aioresponses.get(api_url, status=status, @@ -192,7 +190,6 @@ async def test_integration_nsfw(bot_helper, aioresponses, url, api_url, status, assert title == result.text -@pytest.mark.asyncio async def test_invalid_URL(bot_helper, aioresponses): """Test that an unrecognised URL never even results in a request.""" result = await bot_helper['linkinfo'].get_link_info('http://imgur.com/invalid/url') diff --git a/tests/test_plugin_last.py b/tests/test_plugin_last.py new file mode 100644 index 00000000..585d49bd --- /dev/null +++ b/tests/test_plugin_last.py @@ -0,0 +1,143 @@ +import asyncio + +import pytest + +from csbot.plugins.last import Last + + +pytestmark = [ + pytest.mark.bot(config="""\ + ["@bot"] + plugins = ["mongodb", "last"] + + [mongodb] + mode = "mock" + """), + pytest.mark.usefixtures("run_client"), +] + + +def diff_dict(actual: dict, expected: dict) -> dict: + """Find items in *expected* that are different at the same keys in *actual*, returning a dict + mapping the offending key to a dict with "expected" and "actual" items.""" + diff = dict() + for k, v in expected.items(): + actual_value = actual.get(k) + expected_value = expected.get(k) + if actual_value != expected_value: + diff[k] = dict(actual=actual_value, expected=expected_value) + return diff + + +async def test_message_types(bot_helper): + plugin: Last = bot_helper["last"] + + # Starting state: should have no "last message" for a user + assert plugin.last("Nick") is None + assert plugin.last_message("Nick") is None + assert plugin.last_action("Nick") is None + assert plugin.last_command("Nick") is None + + # Receive a PRIVMSG from the user + await bot_helper.client.line_received(":Nick!~user@hostname PRIVMSG #channel :Example message") + # Check that message was recorded correctly + assert diff_dict(plugin.last("Nick"), {"nick": "Nick", "message": "Example message"}) == {} + # Check that message was only recorded in the correct category + assert plugin.last_message("Nick") == plugin.last("Nick") + assert not plugin.last_action("Nick") == plugin.last("Nick") + assert not plugin.last_command("Nick") == plugin.last("Nick") + + # Receive a CTCP ACTION from the user (inside a PRIVMSG) + await bot_helper.client.line_received(":Nick!~user@hostname PRIVMSG #channel :\x01ACTION emotes\x01") + # Check that message was recorded correctly + assert diff_dict(plugin.last("Nick"), {"nick": "Nick", "message": "emotes"}) == {} + # Check that message was only recorded in the correct category + assert not plugin.last_message("Nick") == plugin.last("Nick") + assert plugin.last_action("Nick") == plugin.last("Nick") + assert not plugin.last_command("Nick") == plugin.last("Nick") + + # Receive a bot command from the user (inside a PRIVMSG) + await bot_helper.client.line_received(":Nick!~user@hostname PRIVMSG #channel :!help") + # Check that message was recorded correctly + assert diff_dict(plugin.last("Nick"), {"nick": "Nick", "message": "!help"}) == {} + # Check that message was only recorded in the correct category + assert not plugin.last_message("Nick") == plugin.last("Nick") + assert not plugin.last_action("Nick") == plugin.last("Nick") + assert plugin.last_command("Nick") == plugin.last("Nick") + + # Final confirmation that the "message", "action" and "command" message types were all recorded separately + assert diff_dict(plugin.last_message("Nick"), {"nick": "Nick", "message": "Example message"}) == {} + assert diff_dict(plugin.last_action("Nick"), {"nick": "Nick", "message": "emotes"}) == {} + assert diff_dict(plugin.last_command("Nick"), {"nick": "Nick", "message": "!help"}) == {} + + # Also there shouldn't be any records for a different nick + assert plugin.last("OtherNick") is None + + +async def test_channel_filter(bot_helper): + plugin: Last = bot_helper["last"] + + # Starting state: should have no "last message" for a user + assert plugin.last("Nick") is None + assert plugin.last("Nick", channel="#a") is None + assert plugin.last("Nick", channel="#b") is None + + # Receive a PRIVMSG from the user in #a + await bot_helper.client.line_received(":Nick!~user@hostname PRIVMSG #a :Message A") + # Check that the message was recorded correctly + assert diff_dict(plugin.last("Nick"), {"nick": "Nick", "channel": "#a", "message": "Message A"}) == {} + # Check that channel filter applies correctly + assert plugin.last("Nick", channel="#a") == plugin.last("Nick") + assert not plugin.last("Nick", channel="#b") == plugin.last("Nick") + + # Receive a PRIVMSG from the user in #b + await bot_helper.client.line_received(":Nick!~user@hostname PRIVMSG #b :Message B") + # Check that the message was recorded correctly + assert diff_dict(plugin.last("Nick"), {"nick": "Nick", "channel": "#b", "message": "Message B"}) == {} + # Check that channel filter applies correctly + assert not plugin.last("Nick", channel="#a") == plugin.last("Nick") + assert plugin.last("Nick", channel="#b") == plugin.last("Nick") + + # Final confirmation that the latest message for each channel is stored + assert diff_dict(plugin.last("Nick", channel="#a"), {"nick": "Nick", "channel": "#a", "message": "Message A"}) == {} + assert diff_dict(plugin.last("Nick", channel="#b"), {"nick": "Nick", "channel": "#b", "message": "Message B"}) == {} + + # Also there shouldn't be any records for a different channel + assert plugin.last("Nick", channel="#c") is None + + +async def test_seen_command(bot_helper): + bot_helper.reset_mock() + + # !seen for a nick not yet seen + await asyncio.wait(bot_helper.receive(":A!~user@hostname PRIVMSG #a :!seen B")) + bot_helper.assert_sent("NOTICE #a :Nothing recorded for B") + + # !seen for a nick only seen in a different channel + await asyncio.wait(bot_helper.receive(":B!~user@hostname PRIVMSG #b :First message")) + await asyncio.wait(bot_helper.receive(":A!~user@hostname PRIVMSG #a :!seen B")) + bot_helper.assert_sent("NOTICE #a :Nothing recorded for B") + + # !seen for nick seen in the same channel + await asyncio.wait(bot_helper.receive(":A!~user@hostname PRIVMSG #b :!seen B")) + bot_helper.assert_sent(lambda line: " First message" in line) + + # Now seen in both channels, !seen should only return the message relating to the current channel + await asyncio.wait(bot_helper.receive(":B!~user@hostname PRIVMSG #a :Second message")) + await asyncio.wait(bot_helper.receive(":A!~user@hostname PRIVMSG #a :!seen B")) + bot_helper.assert_sent(lambda line: " Second message" in line) + await asyncio.wait(bot_helper.receive(":A!~user@hostname PRIVMSG #b :!seen B")) + bot_helper.assert_sent(lambda line: " First message" in line) + + # !seen on own nick should get the !seen command itself (because it makes more sense than "Nothing recorded") + await asyncio.wait(bot_helper.receive(":B!~user@hostname PRIVMSG #a :!seen B")) + bot_helper.assert_sent(lambda line: " !seen B" in line) + + # Check different formatting for actions + await asyncio.wait(bot_helper.receive(":B!~user@hostname PRIVMSG #a :\x01ACTION does something\x01")) + await asyncio.wait(bot_helper.receive(":A!~user@hostname PRIVMSG #a :!seen B")) + bot_helper.assert_sent(lambda line: "* B does something" in line) + + # Error when bad message type is specified + await asyncio.wait(bot_helper.receive(":A!~user@hostname PRIVMSG #a :!seen B foobar")) + bot_helper.assert_sent("NOTICE #a :Bad filter: foobar. Accepted are \"message\", \"command\", and \"action\".") diff --git a/tests/test_plugin_linkinfo.py b/tests/test_plugin_linkinfo.py index 0386eb4f..ecd789ee 100644 --- a/tests/test_plugin_linkinfo.py +++ b/tests/test_plugin_linkinfo.py @@ -141,7 +141,6 @@ async def irc_client(irc_client): return irc_client -@pytest.mark.asyncio @pytest.mark.parametrize("url, content_type, body, expected_title", encoding_test_cases, ids=[_[0] for _ in encoding_test_cases]) async def test_encoding_handling(bot_helper, aioresponses, url, content_type, body, expected_title): @@ -150,7 +149,6 @@ async def test_encoding_handling(bot_helper, aioresponses, url, content_type, bo assert result.text == expected_title -@pytest.mark.asyncio @pytest.mark.parametrize("url, content_type, body", error_test_cases, ids=[_[0] for _ in error_test_cases]) async def test_html_title_errors(bot_helper, aioresponses, url, content_type, body): @@ -159,7 +157,6 @@ async def test_html_title_errors(bot_helper, aioresponses, url, content_type, bo assert result.is_error -@pytest.mark.asyncio async def test_not_found(bot_helper, aioresponses): # Test our assumptions: direct request should raise connection error, because aioresponses # is mocking the internet @@ -172,7 +169,6 @@ async def test_not_found(bot_helper, aioresponses): assert result.is_error -@pytest.mark.asyncio @pytest.mark.parametrize("msg, urls", [('http://example.com', ['http://example.com'])]) async def test_scan_privmsg(event_loop, bot_helper, aioresponses, msg, urls): with asynctest.mock.patch.object(bot_helper['linkinfo'], 'get_link_info') as get_link_info: @@ -180,7 +176,6 @@ async def test_scan_privmsg(event_loop, bot_helper, aioresponses, msg, urls): get_link_info.assert_has_calls([mock.call(url) for url in urls]) -@pytest.mark.asyncio @pytest.mark.parametrize("msg, urls", [('http://example.com', ['http://example.com'])]) async def test_command(event_loop, bot_helper, aioresponses, msg, urls): with asynctest.mock.patch.object(bot_helper['linkinfo'], 'get_link_info') as get_link_info, \ @@ -191,8 +186,7 @@ async def test_command(event_loop, bot_helper, aioresponses, msg, urls): assert link_command.call_count == 1 -@pytest.mark.asyncio -def test_scan_privmsg_rate_limit(bot_helper, aioresponses): +async def test_scan_privmsg_rate_limit(bot_helper, aioresponses): """Test that we won't respond too frequently to URLs in messages. Unfortunately we can't currently test the passage of time, so the only @@ -203,11 +197,11 @@ def test_scan_privmsg_rate_limit(bot_helper, aioresponses): count = linkinfo.config.rate_limit_count for i in range(count): with asynctest.mock.patch.object(linkinfo, 'get_link_info', ) as get_link_info: - yield from bot_helper.client.line_received( + await bot_helper.client.line_received( ':nick!user@host PRIVMSG #channel :http://example.com/{}'.format(i)) get_link_info.assert_called_once_with('http://example.com/{}'.format(i)) with asynctest.mock.patch.object(linkinfo, 'get_link_info') as get_link_info: - yield from bot_helper.client.line_received(':nick!user@host PRIVMSG #channel :http://example.com/12345') + await bot_helper.client.line_received(':nick!user@host PRIVMSG #channel :http://example.com/12345') assert not get_link_info.called @@ -221,18 +215,17 @@ def __init__(self, *args, **kwargs): def privmsg(self, event): self.handler_mock(event['message']) - CONFIG = f"""\ + CONFIG = """\ ["@bot"] plugins = ["mockplugin", "linkinfo"] """ pytestmark = pytest.mark.bot(plugins=find_plugins() + [MockPlugin], config=CONFIG) - @pytest.mark.asyncio async def test_non_blocking_privmsg(self, event_loop, bot_helper, aioresponses): bot_helper.reset_mock() - event = asyncio.Event(loop=event_loop) + event = asyncio.Event() async def handler(url, **kwargs): await event.wait() @@ -245,7 +238,7 @@ async def handler(url, **kwargs): ':nick!user@host PRIVMSG #channel :http://example.com/', ':nick!user@host PRIVMSG #channel :b', ]) - await asyncio.wait(futures, loop=event_loop, timeout=0.1) + await asyncio.wait(futures, timeout=0.1) assert bot_helper['mockplugin'].handler_mock.mock_calls == [ mock.call('a'), mock.call('http://example.com/'), @@ -254,17 +247,14 @@ async def handler(url, **kwargs): bot_helper.client.send_line.assert_not_called() event.set() - await asyncio.wait(futures, loop=event_loop, timeout=0.1) + await asyncio.wait(futures, timeout=0.1) assert all(f.done() for f in futures) - bot_helper.client.send_line.assert_has_calls([ - mock.call('NOTICE #channel :foo'), - ]) + bot_helper.assert_sent('NOTICE #channel :foo') - @pytest.mark.asyncio async def test_non_blocking_command(self, event_loop, bot_helper, aioresponses): bot_helper.reset_mock() - event = asyncio.Event(loop=event_loop) + event = asyncio.Event() async def handler(url, **kwargs): await event.wait() @@ -278,7 +268,7 @@ async def handler(url, **kwargs): ':nick!user@host PRIVMSG #channel :!link http://example.com/', ':nick!user@host PRIVMSG #channel :b', ]) - await asyncio.wait(futures, loop=event_loop, timeout=0.1) + await asyncio.wait(futures, timeout=0.1) assert bot_helper['mockplugin'].handler_mock.mock_calls == [ mock.call('a'), mock.call('!link http://example.com/'), @@ -287,9 +277,7 @@ async def handler(url, **kwargs): bot_helper.client.send_line.assert_not_called() event.set() - await asyncio.wait(futures, loop=event_loop, timeout=0.1) + await asyncio.wait(futures, timeout=0.1) assert all(f.done() for f in futures) - bot_helper.client.send_line.assert_has_calls([ - mock.call('NOTICE #channel :Error: Content-Type not HTML-ish: ' - 'application/octet-stream (http://example.com/)'), - ]) + bot_helper.assert_sent('NOTICE #channel :Error: Content-Type not HTML-ish: ' + 'application/octet-stream (http://example.com/)') diff --git a/tests/test_plugin_termdates.py b/tests/test_plugin_termdates.py new file mode 100644 index 00000000..643db70a --- /dev/null +++ b/tests/test_plugin_termdates.py @@ -0,0 +1,133 @@ +import asyncio +import datetime + +import pytest + + +pytestmark = [ + pytest.mark.bot(config="""\ + ["@bot"] + plugins = ["mongodb", "termdates"] + + [mongodb] + mode = "mock" + """), + pytest.mark.usefixtures("run_client"), +] + + +def say(msg): + return f":Nick!~user@hostname PRIVMSG #channel :{msg}" + + +async def test_term_dates(bot_helper, time_machine): + bot_helper.reset_mock() + + # Nothing configured yet, !termdates should error + await asyncio.wait(bot_helper.receive(say("!termdates"))) + bot_helper.assert_sent(bot_helper.match_line.endswith("error: no term dates (see termdates.set)")) + + # Save dates + await asyncio.wait(bot_helper.receive([ + say("!termdates.set 2021-09-27 2022-01-10 2022-04-19"), + say("!termdates"), + ])) + bot_helper.assert_sent([ + bot_helper.match_line.endswith("Aut 2021-09-27 -- 2021-12-03, " + "Spr 2022-01-10 -- 2022-03-18, " + "Sum 2022-04-19 -- 2022-06-24"), + ]) + + +async def test_week_command(bot_helper, time_machine): + bot_helper.reset_mock() + + # Nothing configured yet, !week should error + await asyncio.wait(bot_helper.receive(say("!week"))) + bot_helper.assert_sent(bot_helper.match_line.endswith("error: no term dates (see termdates.set)")) + + # Configure term dates + await asyncio.wait(bot_helper.receive(say("!termdates.set 2021-09-27 2022-01-10 2022-04-19"))) + + # `!week term n` should give the correct dates for the specified week in the specified term + await asyncio.wait(bot_helper.receive(say("!week aut 3"))) + bot_helper.assert_sent(bot_helper.match_line.endswith("Aut 3: 2021-10-11")) + await asyncio.wait(bot_helper.receive(say("!week spr 10"))) + bot_helper.assert_sent(bot_helper.match_line.endswith("Spr 10: 2022-03-14")) + await asyncio.wait(bot_helper.receive(say("!week sum 4"))) + bot_helper.assert_sent(bot_helper.match_line.endswith("Sum 4: 2022-05-09")) + # `!week n term` means the same as `!week term n` + await asyncio.wait(bot_helper.receive(say("!week 3 aut"))) + bot_helper.assert_sent(bot_helper.match_line.endswith("Aut 3: 2021-10-11")) + await asyncio.wait(bot_helper.receive(say("!week 10 spr"))) + bot_helper.assert_sent(bot_helper.match_line.endswith("Spr 10: 2022-03-14")) + await asyncio.wait(bot_helper.receive(say("!week 4 sum"))) + bot_helper.assert_sent(bot_helper.match_line.endswith("Sum 4: 2022-05-09")) + + # Time travel to before the start of the Autumn term + time_machine.move_to(datetime.datetime(2021, 8, 1, 12, 0)) + # `!week` should give "Nth week before Aut" + await asyncio.wait(bot_helper.receive(say("!week"))) + bot_helper.assert_sent(bot_helper.match_line.endswith("9th week before Aut (starts 2021-09-27)")) + # `!week n` should give the start of the Nth week in the Autumn term + await asyncio.wait(bot_helper.receive(say("!week 3"))) + bot_helper.assert_sent(bot_helper.match_line.endswith("Aut 3: 2021-10-11")) + + # Time travel to during the Autumn term, week 4 + time_machine.move_to(datetime.datetime(2021, 10, 21, 12, 0)) + # `!week` should give "Aut 4: ..." + await asyncio.wait(bot_helper.receive(say("!week"))) + bot_helper.assert_sent(bot_helper.match_line.endswith("Aut 4: 2021-10-18")) + # `!week n` should give the start of the Nth week in the Autumn term + await asyncio.wait(bot_helper.receive(say("!week 3"))) + bot_helper.assert_sent(bot_helper.match_line.endswith("Aut 3: 2021-10-11")) + + # Time travel to after the Autumn term + time_machine.move_to(datetime.datetime(2021, 12, 15, 12, 0)) + # `!week` should give "Nth week before Spr" + await asyncio.wait(bot_helper.receive(say("!week"))) + bot_helper.assert_sent(bot_helper.match_line.endswith("4th week before Spr (starts 2022-01-10)")) + # `!week n` should give the start of the Nth week in the Spring term + await asyncio.wait(bot_helper.receive(say("!week 3"))) + bot_helper.assert_sent(bot_helper.match_line.endswith("Spr 3: 2022-01-24")) + + # Time travel to during the Spring term, week 10 + time_machine.move_to(datetime.datetime(2022, 3, 16, 12, 0)) + # `!week` should give "Spr 10: ..." + await asyncio.wait(bot_helper.receive(say("!week"))) + bot_helper.assert_sent(bot_helper.match_line.endswith("Spr 10: 2022-03-14")) + # `!week n` should give the start of the Nth week in the Spring term + await asyncio.wait(bot_helper.receive(say("!week 3"))) + bot_helper.assert_sent(bot_helper.match_line.endswith("Spr 3: 2022-01-24")) + + # Time travel to after the Spring term + time_machine.move_to(datetime.datetime(2022, 4, 4, 12, 0)) + # `!week` should give "Nth week before Sum" + await asyncio.wait(bot_helper.receive(say("!week"))) + bot_helper.assert_sent(bot_helper.match_line.endswith("2nd week before Sum (starts 2022-04-19)")) + # `!week n` should give the start of the Nth week in the Summer term + await asyncio.wait(bot_helper.receive(say("!week 3"))) + bot_helper.assert_sent(bot_helper.match_line.endswith("Sum 3: 2022-05-02")) + + # Time travel to during the Summer term, week 7 + time_machine.move_to(datetime.datetime(2022, 5, 31, 12, 0)) + # `!week` should give "Sum 7: ..." + await asyncio.wait(bot_helper.receive(say("!week"))) + bot_helper.assert_sent(bot_helper.match_line.endswith("Sum 7: 2022-05-30")) + # `!week n` should give the start of the Nth week in the Summer term + await asyncio.wait(bot_helper.receive(say("!week 3"))) + bot_helper.assert_sent(bot_helper.match_line.endswith("Sum 3: 2022-05-02")) + + # Time travel to after the Summer term + time_machine.move_to(datetime.datetime(2022, 8, 15, 12, 0)) + # `!week` should error because no current or next term + await asyncio.wait(bot_helper.receive(say("!week"))) + bot_helper.assert_sent(bot_helper.match_line.endswith("error: no term dates (see termdates.set)")) + # `!week n` should error because no current term + await asyncio.wait(bot_helper.receive(say("!week 3"))) + bot_helper.assert_sent(bot_helper.match_line.endswith("error: no term dates (see termdates.set)")) + + # Edge case: time travel to the Monday of the first week of a term that starts on Tuesday + time_machine.move_to(datetime.datetime(2022, 4, 18)) + await asyncio.wait(bot_helper.receive(say("!week"))) + bot_helper.assert_sent(bot_helper.match_line.endswith("Sum 1: 2022-04-19")) diff --git a/tests/test_plugin_usertrack.py b/tests/test_plugin_usertrack.py index 29d2554a..8ec72845 100644 --- a/tests/test_plugin_usertrack.py +++ b/tests/test_plugin_usertrack.py @@ -25,7 +25,6 @@ def assert_account(self, nick, account): plugins = ["usertrack"] """), pytest.mark.usefixtures("run_client"), - pytest.mark.asyncio, ] diff --git a/tests/test_plugin_whois.py b/tests/test_plugin_whois.py index 59f3f261..27457a9d 100644 --- a/tests/test_plugin_whois.py +++ b/tests/test_plugin_whois.py @@ -95,7 +95,6 @@ def test_whois_setdefault_unset(self, whois): @pytest.mark.usefixtures("run_client") -@pytest.mark.asyncio class TestWhoisBehaviour: async def test_client_reply_whois_after_set(self, bot_helper): await bot_helper.recv_privmsg('Nick!~user@host', '#First', '!whois.set test1') diff --git a/tests/test_plugin_xkcd.py b/tests/test_plugin_xkcd.py index 423f122d..ff08adb8 100644 --- a/tests/test_plugin_xkcd.py +++ b/tests/test_plugin_xkcd.py @@ -94,7 +94,6 @@ def populate_responses(self, bot_helper, aioresponses): aioresponses.add(url, body=read_fixture_file(fixture), content_type=content_type) - @pytest.mark.asyncio @pytest.mark.usefixtures("populate_responses") @pytest.mark.parametrize("num, url, content_type, fixture, expected", json_test_cases, ids=[_[1] for _ in json_test_cases]) @@ -102,14 +101,12 @@ async def test_correct(self, bot_helper, num, url, content_type, fixture, expect result = await bot_helper['xkcd']._xkcd(num) assert result == expected - @pytest.mark.asyncio @pytest.mark.usefixtures("populate_responses") async def test_latest_success(self, bot_helper): # Also test the empty string num, url, content_type, fixture, expected = json_test_cases[0] assert await bot_helper['xkcd']._xkcd("") == expected - @pytest.mark.asyncio @pytest.mark.usefixtures("populate_responses") async def test_random(self, bot_helper): # !xkcd 221 @@ -117,7 +114,6 @@ async def test_random(self, bot_helper): with patch("random.randint", return_value=1): assert await bot_helper['xkcd']._xkcd("rand") == expected - @pytest.mark.asyncio async def test_error_1(self, bot_helper, aioresponses): num, url, content_type, fixture, _ = json_test_cases[0] # Latest # Test if the comics are unavailable by making the latest return a 404 @@ -126,7 +122,6 @@ async def test_error_1(self, bot_helper, aioresponses): with pytest.raises(bot_helper['xkcd'].XKCDError): await bot_helper['xkcd']._xkcd("") - @pytest.mark.asyncio async def test_error_2(self, bot_helper, aioresponses): num, url, content_type, fixture, _ = json_test_cases[0] # Latest # Now override the actual 404 page and the latest "properly" @@ -162,7 +157,6 @@ def populate_responses(self, bot_helper, aioresponses): content_type=content_type) @pytest.mark.usefixtures("populate_responses") - @pytest.mark.asyncio @pytest.mark.parametrize("num, url, content_type, fixture, expected", json_test_cases, ids=[_[1] for _ in json_test_cases]) async def test_integration(self, bot_helper, num, url, content_type, fixture, expected): @@ -173,19 +167,15 @@ async def test_integration(self, bot_helper, num, url, content_type, fixture, ex assert alt in result.text @pytest.mark.usefixtures("populate_responses") - @pytest.mark.asyncio @pytest.mark.parametrize("num, url, content_type, fixture, expected", json_test_cases, ids=[_[1] for _ in json_test_cases]) async def test_command(self, bot_helper, num, url, content_type, fixture, expected): _, title, alt = expected incoming = f":nick!user@host PRIVMSG #channel :!xkcd {num}" await asyncio.wait(bot_helper.receive(incoming)) - _, (outgoing,), _ = bot_helper.client.send_line.mock_calls[-1] - assert title in outgoing - assert alt in outgoing + bot_helper.assert_sent(lambda line: title in line and alt in line) @pytest.mark.usefixtures("populate_responses") - @pytest.mark.asyncio async def test_integration_error(self, bot_helper): # Error case result = await bot_helper['linkinfo'].get_link_info("http://xkcd.com/flibble") diff --git a/tests/test_plugin_youtube.py b/tests/test_plugin_youtube.py index c221b88b..68c99baa 100644 --- a/tests/test_plugin_youtube.py +++ b/tests/test_plugin_youtube.py @@ -18,7 +18,7 @@ "youtube_fItlK6L-khc.json", {'link': 'http://youtu.be/fItlK6L-khc', 'uploader': 'BruceWillakers', 'uploaded': '2014-08-29', 'views': '28,843', 'duration': '21:00', - 'likes': '+1,192/-13', 'title': 'Trouble In Terrorist Town | Hiding in Fire'} + 'title': 'Trouble In Terrorist Town | Hiding in Fire'} ), # Unicode @@ -26,7 +26,7 @@ "vZ_YpOvRd3o", 200, "youtube_vZ_YpOvRd3o.json", - {'title': "Oh! it's just me! / Фух! Это всего лишь я!", 'likes': '+12,571/-155', + {'title': "Oh! it's just me! / Фух! Это всего лишь я!", 'duration': '00:24', 'uploader': 'ignoramusky', 'uploaded': '2014-08-26', 'views': '6,054,406', 'link': 'http://youtu.be/vZ_YpOvRd3o'} ), @@ -36,7 +36,7 @@ "sw4hmqVPe0E", 200, "youtube_sw4hmqVPe0E.json", - {'title': "Sky News Live", 'likes': '+2,195/-586', + {'title': "Sky News Live", 'duration': 'LIVE', 'uploader': 'Sky News', 'uploaded': '2015-03-24', 'views': '2,271,999', 'link': 'http://youtu.be/sw4hmqVPe0E'} ), @@ -46,7 +46,7 @@ "539OnO-YImk", 200, "youtube_539OnO-YImk.json", - {'title': 'sharpest Underwear kitchen knife in the world', 'likes': '+52,212/-2,209', + {'title': 'sharpest Underwear kitchen knife in the world', 'duration': '12:24', 'uploader': '圧倒的不審者の極み!', 'uploaded': '2018-07-14', 'views': '2,710,723', 'link': 'http://youtu.be/539OnO-YImk'} ), @@ -94,7 +94,6 @@ def pre_irc_client(aioresponses): api_key = "abc" """) class TestYoutubePlugin: - @pytest.mark.asyncio @pytest.mark.parametrize("vid_id, status, fixture, expected", json_test_cases) async def test_ids(self, bot_helper, aioresponses, vid_id, status, fixture, expected): pattern = re.compile(rf'https://www.googleapis.com/youtube/v3/videos\?.*\bid={vid_id}\b.*') @@ -127,7 +126,6 @@ def bot_helper(self, bot_helper): }) return bot_helper - @pytest.mark.asyncio @pytest.mark.parametrize("vid_id, status, fixture, response", json_test_cases) @pytest.mark.parametrize("url", [ "https://www.youtube.com/watch?v={}", diff --git a/tests/test_util.py b/tests/test_util.py index 7151f133..00a4f64d 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -8,12 +8,10 @@ from csbot import util -@pytest.mark.asyncio async def test_maybe_future_none(): assert util.maybe_future(None) is None -@pytest.mark.asyncio async def test_maybe_future_non_awaitable(): on_error = mock.Mock(spec=callable) assert util.maybe_future("foo", on_error=on_error) is None @@ -22,7 +20,6 @@ async def test_maybe_future_non_awaitable(): ] -@pytest.mark.asyncio async def test_maybe_future_coroutine(): async def foo(): await asyncio.sleep(0) @@ -36,13 +33,11 @@ async def foo(): assert future.exception() is None -@pytest.mark.asyncio async def test_maybe_future_result_none(): result = await util.maybe_future_result(None) assert result is None -@pytest.mark.asyncio async def test_maybe_future_result_non_awaitable(): on_error = mock.Mock(spec=callable) result = await util.maybe_future_result("foo", on_error=on_error) @@ -52,7 +47,6 @@ async def test_maybe_future_result_non_awaitable(): ] -@pytest.mark.asyncio async def test_maybe_future_result_coroutine(): async def foo(): await asyncio.sleep(0) @@ -73,7 +67,6 @@ def test_truncate_utf8(): # @pytest.mark.skip class TestRateLimited: - @pytest.mark.asyncio async def test_bursts(self, event_loop, fast_forward): f = mock.Mock(spec=callable) # Test with 2 calls per 2 seconds @@ -100,7 +93,6 @@ async def test_bursts(self, event_loop, fast_forward): rl.stop() - @pytest.mark.asyncio async def test_constant_rate(self, event_loop, fast_forward): f = mock.Mock(spec=callable) # Test with 2 calls per 2 seconds @@ -131,7 +123,6 @@ async def test_constant_rate(self, event_loop, fast_forward): rl.stop() - @pytest.mark.asyncio async def test_restart_with_clear(self, event_loop, fast_forward): f = mock.Mock(spec=callable) # Test with 2 calls per 2 seconds @@ -170,7 +161,6 @@ async def test_restart_with_clear(self, event_loop, fast_forward): rl.stop() - @pytest.mark.asyncio async def test_restart_without_clear(self, event_loop, fast_forward): f = mock.Mock(spec=callable) # Test with 2 calls per 2 seconds @@ -213,7 +203,6 @@ async def test_restart_without_clear(self, event_loop, fast_forward): rl.stop() - @pytest.mark.asyncio async def test_call_before_start(self, event_loop, fast_forward): f = mock.Mock(spec=callable) # Test with 2 calls per 2 seconds @@ -232,7 +221,6 @@ async def test_call_before_start(self, event_loop, fast_forward): rl.stop() - @pytest.mark.asyncio async def test_exception(self, event_loop, fast_forward): f = mock.Mock(spec=callable, side_effect=Exception("fail")) # Test with 2 calls per 2 seconds @@ -246,7 +234,6 @@ async def test_exception(self, event_loop, fast_forward): rl.stop() - @pytest.mark.asyncio async def test_start_stop(self, event_loop): f = mock.Mock(spec=callable) # Test with 2 calls per 2 seconds @@ -262,7 +249,6 @@ async def test_start_stop(self, event_loop): rl.stop() - @pytest.mark.asyncio async def test_stop_returns_cancelled_calls(self, event_loop): f = mock.Mock(spec=callable) # Test with 2 calls per 2 seconds diff --git a/tox.ini b/tox.ini index 8ffa995c..b349a891 100644 --- a/tox.ini +++ b/tox.ini @@ -1,15 +1,12 @@ [tox] -envlist = py36,py37 +envlist = py37,py38,py39,py310-flake8 skipsdist = True [testenv] -passenv = TRAVIS TRAVIS_* +passenv = TRAVIS TRAVIS_* GITHUB_* deps = -r requirements.txt - coveralls: coveralls - flake8: flake8 + flake8: flake8==4.0.1 commands = python -m pytest {posargs} - flake8: flake8 --exit-zero --exclude=src/csbot/plugins_broken src/ tests/ - # Try to run coveralls, but don't fail if coveralls fails - coveralls: - coveralls + flake8: flake8 --exclude=src/csbot/plugins_broken src/ tests/