From d6fcb2c957826862010f7e316255b57483c50ba0 Mon Sep 17 00:00:00 2001 From: Alan Briolat Date: Sun, 27 Oct 2019 08:15:38 +0000 Subject: [PATCH 01/41] Update main.yml --- .github/workflows/main.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .github/workflows/main.yml diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 00000000..b2340b24 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,17 @@ +name: CI + +on: [push] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v1 + - name: Run a one-line script + run: echo Hello, world! + - name: Run a multi-line script + run: | + echo Add other actions to build, + echo test, and deploy your project. From 061a049a7f200485d2f1609476618df727b102a0 Mon Sep 17 00:00:00 2001 From: Alan Briolat Date: Sun, 27 Oct 2019 08:22:09 +0000 Subject: [PATCH 02/41] Trying to get GitHub Actions CI working --- .github/workflows/main.yml | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b2340b24..5dda989d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -5,13 +5,11 @@ on: [push] jobs: build: - runs-on: ubuntu-latest + runs-on: ubuntu-18.04 steps: - uses: actions/checkout@v1 - - name: Run a one-line script - run: echo Hello, world! - - name: Run a multi-line script - run: | - echo Add other actions to build, - echo test, and deploy your project. + - name: Install tox + run: pip install tox + - name: Run tests with tox + run: tox From b67e938972822d9594c656c30f1e8692a9956e3c Mon Sep 17 00:00:00 2001 From: Alan Briolat Date: Sun, 27 Oct 2019 08:25:17 +0000 Subject: [PATCH 03/41] Trying to get GitHub Actions CI working --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5dda989d..a990037b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -12,4 +12,4 @@ jobs: - name: Install tox run: pip install tox - name: Run tests with tox - run: tox + run: python -m tox From 2b8d3d388516a3fe069adfa50901c8291c299ef8 Mon Sep 17 00:00:00 2001 From: Alan Briolat Date: Sun, 27 Oct 2019 15:54:38 +0000 Subject: [PATCH 04/41] Revert "Trying to get GitHub Actions CI working" This reverts commit b67e938972822d9594c656c30f1e8692a9956e3c. --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a990037b..5dda989d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -12,4 +12,4 @@ jobs: - name: Install tox run: pip install tox - name: Run tests with tox - run: python -m tox + run: tox From 23d8d9c460f7a2846cc012aaf1d6d83412ec1982 Mon Sep 17 00:00:00 2001 From: Alan Briolat Date: Sun, 27 Oct 2019 15:54:41 +0000 Subject: [PATCH 05/41] Revert "Trying to get GitHub Actions CI working" This reverts commit 061a049a7f200485d2f1609476618df727b102a0. --- .github/workflows/main.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5dda989d..b2340b24 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -5,11 +5,13 @@ on: [push] jobs: build: - runs-on: ubuntu-18.04 + runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 - - name: Install tox - run: pip install tox - - name: Run tests with tox - run: tox + - name: Run a one-line script + run: echo Hello, world! + - name: Run a multi-line script + run: | + echo Add other actions to build, + echo test, and deploy your project. From 38ebd6f166d2f111f0a6cca8253dbae048d3d1aa Mon Sep 17 00:00:00 2001 From: Alan Briolat Date: Sat, 26 Oct 2019 19:39:15 +0100 Subject: [PATCH 06/41] Update testing dependencies, remove unused "responses" library/fixture --- requirements.txt | 8 +++----- tests/conftest.py | 7 ------- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/requirements.txt b/requirements.txt index 95005ba7..77908d6c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,13 +1,11 @@ # Requirements for unit testing -pytest==4.2.0 +pytest>=5.2.2,<6.0 pytest-asyncio==0.10.0 pytest-aiohttp==0.3.0 -#aioresponses==0.6.0 -git+https://github.com/alanbriolat/aioresponses.git@callback-coroutines#egg=aioresponses +aioresponses==0.6.1 pytest-cov -asynctest==0.12.2 +asynctest==0.13.0 aiofastforward==0.0.24 -responses mongomock # Requirements for documentation diff --git a/tests/conftest.py b/tests/conftest.py index 01b5064d..c65007b2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,7 +4,6 @@ import pytest import aiofastforward -import responses as responses_ from aioresponses import aioresponses as aioresponses_ import toml @@ -190,12 +189,6 @@ def bot(self): return self.client -@pytest.fixture -def responses(): - with responses_.RequestsMock() as rsps: - yield rsps - - @pytest.fixture def aioresponses(): with aioresponses_() as m: From da38b90d29525d899ad35b239ebda6ebd1fa816d Mon Sep 17 00:00:00 2001 From: Alan Briolat Date: Fri, 26 Mar 2021 19:26:06 +0000 Subject: [PATCH 07/41] Switch from Travis CI to GitHub Actions --- .github/workflows/main.yml | 49 ++++++++++++++++++++++++++++++-------- .travis.yml | 25 ------------------- tox.ini | 2 +- 3 files changed, 40 insertions(+), 36 deletions(-) delete mode 100644 .travis.yml diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b2340b24..624936e3 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,17 +1,46 @@ name: CI -on: [push] +on: + push: + branches: + - master + pull_request: + types: [opened, synchronize] jobs: build: - - runs-on: ubuntu-latest + runs-on: ubuntu-18.04 + strategy: + matrix: + python-version: [3.6, 3.7] + include: + - python-version: 3.6 + toxenv: py36 + - python-version: 3.7 + toxenv: py37-coveralls-flake8 steps: - - uses: actions/checkout@v1 - - name: Run a one-line script - run: echo Hello, world! - - name: Run a multi-line script - run: | - echo Add other actions to build, - echo test, and deploy your project. + # Pretend to be a different CI service so Coveralls can comment on PRs + - name: Environment aliases for Coveralls + run: | + echo "::set-env name=JENKINS_HOME::/dev/null" + echo "::set-env name=BUILD_NUMBER::$RUNNER_TRACKING_ID" + echo "::set-env name=CI_PULL_REQUEST::${{ github.event.pull_request.number }}" + echo "::set-env name=BRANCH_NAME::$GITHUB_REF" + - 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 }} + COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} + COVERALLS_SERVICE_NAME: github-actions 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/tox.ini b/tox.ini index 8ffa995c..54f5a923 100644 --- a/tox.ini +++ b/tox.ini @@ -3,7 +3,7 @@ envlist = py36,py37 skipsdist = True [testenv] -passenv = TRAVIS TRAVIS_* +passenv = TRAVIS TRAVIS_* COVERALLS_* JENKINS_HOME BUILD_NUMBER CI_PULL_REQUEST BRANCH_NAME deps = -r requirements.txt coveralls: coveralls From d29211ecc39e362314c1544833e690d27ac9e5db Mon Sep 17 00:00:00 2001 From: Alan Briolat Date: Fri, 26 Mar 2021 22:11:33 +0000 Subject: [PATCH 08/41] Replace coveralls with codecov coveralls is a bit Travis-specific in its operation... --- .github/workflows/main.yml | 14 ++++---------- .gitignore | 1 + README.rst | 3 --- pytest.ini | 2 +- tox.ini | 5 +---- 5 files changed, 7 insertions(+), 18 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 624936e3..cfcaa604 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -17,16 +17,9 @@ jobs: - python-version: 3.6 toxenv: py36 - python-version: 3.7 - toxenv: py37-coveralls-flake8 + toxenv: py37-flake8 steps: - # Pretend to be a different CI service so Coveralls can comment on PRs - - name: Environment aliases for Coveralls - run: | - echo "::set-env name=JENKINS_HOME::/dev/null" - echo "::set-env name=BUILD_NUMBER::$RUNNER_TRACKING_ID" - echo "::set-env name=CI_PULL_REQUEST::${{ github.event.pull_request.number }}" - echo "::set-env name=BRANCH_NAME::$GITHUB_REF" - name: examine environment run: env - uses: actions/checkout@v1 @@ -42,5 +35,6 @@ jobs: run: python -m tox env: TOXENV: ${{ matrix.toxenv }} - COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} - COVERALLS_SERVICE_NAME: github-actions + - uses: codecov/codecov-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} 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/README.rst b/README.rst index fd77afdc..862fdd6a 100644 --- a/README.rst +++ b/README.rst @@ -76,9 +76,6 @@ We're also using Travis-CI 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://coveralls.io/repos/HackSoc/csbot/badge.png - :target: https://coveralls.io/r/HackSoc/csbot - .. [1] csbot depends on lxml_, which is a compiled extension module based on libxml2 and libxslt. Make sure you have the appropriate libraries and diff --git a/pytest.ini b/pytest.ini index 4f70ef19..824b868c 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,5 +1,5 @@ [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 diff --git a/tox.ini b/tox.ini index 54f5a923..351bce8e 100644 --- a/tox.ini +++ b/tox.ini @@ -3,13 +3,10 @@ envlist = py36,py37 skipsdist = True [testenv] -passenv = TRAVIS TRAVIS_* COVERALLS_* JENKINS_HOME BUILD_NUMBER CI_PULL_REQUEST BRANCH_NAME +passenv = TRAVIS TRAVIS_* GITHUB_* deps = -r requirements.txt - coveralls: coveralls flake8: flake8 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 From 4bbb59cb17a3a930fe645cff989d62acbadd841f Mon Sep 17 00:00:00 2001 From: Alan Briolat Date: Fri, 26 Mar 2021 18:52:32 +0000 Subject: [PATCH 09/41] Bump aioresponses version to fix aiohttp version parsing issue See https://github.com/pnuckowski/aioresponses/issues/183 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 77908d6c..d3a53f6c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ pytest>=5.2.2,<6.0 pytest-asyncio==0.10.0 pytest-aiohttp==0.3.0 -aioresponses==0.6.1 +aioresponses==0.7.2 pytest-cov asynctest==0.13.0 aiofastforward==0.0.24 From 20021e0b67f0c304778d7ba5e5502307bef70d5d Mon Sep 17 00:00:00 2001 From: Alan Briolat Date: Fri, 26 Mar 2021 19:24:45 +0000 Subject: [PATCH 10/41] Add Python 3.8 and 3.9 to the build matrix --- .github/workflows/main.yml | 8 ++++++-- tox.ini | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index cfcaa604..81117fd2 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -12,12 +12,16 @@ jobs: runs-on: ubuntu-18.04 strategy: matrix: - python-version: [3.6, 3.7] + python-version: [3.6, 3.7, 3.8, 3.9] include: - python-version: 3.6 toxenv: py36 - python-version: 3.7 - toxenv: py37-flake8 + toxenv: py37 + - python-version: 3.8 + toxenv: py38 + - python-version: 3.9 + toxenv: py39-flake8 steps: - name: examine environment diff --git a/tox.ini b/tox.ini index 351bce8e..0e1676b3 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py36,py37 +envlist = py36,py37,py38,py39-flake8 skipsdist = True [testenv] From 72d3e3ccd221c1ed0c1589fa7fb2d55b650fe89b Mon Sep 17 00:00:00 2001 From: Alan Briolat Date: Fri, 26 Mar 2021 19:22:00 +0000 Subject: [PATCH 11/41] Skip a "calc" test case that doesn't apply to Python 3.9 (new parser) --- tests/test_plugin_calc.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_plugin_calc.py b/tests/test_plugin_calc.py index 3c2e4291..f84bd7fe 100644 --- a/tests/test_plugin_calc.py +++ b/tests/test_plugin_calc.py @@ -1,3 +1,5 @@ +import sys + import pytest @@ -53,5 +55,6 @@ def test_error(bot_helper): assert calc._calc("factorial(-42)") == "Error, factorial() not defined for negative values" assert calc._calc("factorial(4.2)") == "Error, factorial() only accepts integral values" 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" From c2f7670d77566ce8bea0cdadb2aca8ff69f4baf3 Mon Sep 17 00:00:00 2001 From: Alan Briolat Date: Fri, 26 Mar 2021 20:05:26 +0000 Subject: [PATCH 12/41] Run Docker build in GitHub Actions --- .github/workflows/main.yml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 81117fd2..601aa9e0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -8,7 +8,7 @@ on: types: [opened, synchronize] jobs: - build: + tests: runs-on: ubuntu-18.04 strategy: matrix: @@ -42,3 +42,15 @@ jobs: - uses: codecov/codecov-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} + + docker: + runs-on: ubuntu-latest + steps: + - name: Build Docker image + uses: docker/build-push-action@v2 + with: + load: true + push: false + tags: alanbriolat/csbot:latest + - name: Run tests inside Docker + run: docker run --rm alanbriolat/csbot:latest pytest \ No newline at end of file From ef9b8749ad2f6344e9107a065044477061c1e96c Mon Sep 17 00:00:00 2001 From: Alan Briolat Date: Fri, 26 Mar 2021 20:44:57 +0000 Subject: [PATCH 13/41] Publish Docker image into GitHub Packages --- .github/workflows/main.yml | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 601aa9e0..2f416bef 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -51,6 +51,20 @@ jobs: with: load: true push: false - tags: alanbriolat/csbot:latest + tags: csbot:latest - name: Run tests inside Docker - run: docker run --rm alanbriolat/csbot:latest pytest \ No newline at end of file + run: docker run --rm csbot:latest pytest + - name: Login to GitHub Packages + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + uses: docker/login-action@v1 + with: + registry: docker.pkg.github.com + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Publish Docker image + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + uses: docker/build-push-action@v2 + with: + push: true + tags: | + docker.pkg.github.com/hacksoc/csbot/csbot:latest From 6a4d5cc859a698937e8fdd40a0dcd1a31f6c988d Mon Sep 17 00:00:00 2001 From: Alan Briolat Date: Fri, 26 Mar 2021 21:41:11 +0000 Subject: [PATCH 14/41] Use GitHub Packages image instead of Docker Hub Also remove stuff for building/testing on Docker Hub. --- docker-compose.test.yml | 6 ------ docker-compose.yml | 2 +- 2 files changed, 1 insertion(+), 7 deletions(-) delete mode 100644 docker-compose.test.yml 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..07ce531a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ version: "3" services: bot: - image: alanbriolat/csbot:latest + image: docker.pkg.github.com/hacksoc/csbot/csbot:latest volumes: - ${CSBOT_CONFIG_LOCAL:-./csbot.toml}:/app/csbot.toml links: From f9812594cce0074ddc8c0d63eab5242f72683b12 Mon Sep 17 00:00:00 2001 From: Alan Briolat Date: Fri, 26 Mar 2021 21:48:29 +0000 Subject: [PATCH 15/41] Send Python version to codecov, remove redundant CODECOV_TOKEN --- .github/workflows/main.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2f416bef..4f3e452a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -22,7 +22,8 @@ jobs: toxenv: py38 - python-version: 3.9 toxenv: py39-flake8 - + env: + PYTHON: ${{ matrix.python-version }} steps: - name: examine environment run: env @@ -41,7 +42,7 @@ jobs: TOXENV: ${{ matrix.toxenv }} - uses: codecov/codecov-action@v1 with: - token: ${{ secrets.CODECOV_TOKEN }} + env_vars: PYTHON docker: runs-on: ubuntu-latest From 9ff580065c5932bde44fe88f1671bc9b191180e9 Mon Sep 17 00:00:00 2001 From: Alan Briolat Date: Fri, 26 Mar 2021 22:02:15 +0000 Subject: [PATCH 16/41] Update docs to reflect new CI & coverage status --- README.rst | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index 862fdd6a..7fd9d501 100644 --- a/README.rst +++ b/README.rst @@ -69,12 +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://codecov.io/gh/HackSoc/csbot/branch/master/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 From eff956e0df79cae259efc9b58f89b1d622f8a8e7 Mon Sep 17 00:00:00 2001 From: Alan Briolat Date: Sat, 27 Mar 2021 09:12:38 +0000 Subject: [PATCH 17/41] Upgrade Python in Docker image to 3.9 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index ed86684f..7943340d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.7 +FROM python:3.9 ARG UID=9000 ARG GID=9000 From 1e713e95fe60ec700b53524cf2fd70d39998df39 Mon Sep 17 00:00:00 2001 From: Ash Holland Date: Fri, 1 Oct 2021 23:10:55 +0100 Subject: [PATCH 18/41] Change default branch name to "main" --- .github/workflows/main.yml | 6 +++--- README.rst | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 4f3e452a..a3531a7f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -3,7 +3,7 @@ name: CI on: push: branches: - - master + - main pull_request: types: [opened, synchronize] @@ -56,14 +56,14 @@ jobs: - name: Run tests inside Docker run: docker run --rm csbot:latest pytest - name: Login to GitHub Packages - if: github.event_name == 'push' && github.ref == 'refs/heads/master' + if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: docker/login-action@v1 with: registry: docker.pkg.github.com username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Publish Docker image - if: github.event_name == 'push' && github.ref == 'refs/heads/master' + if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: docker/build-push-action@v2 with: push: true diff --git a/README.rst b/README.rst index 7fd9d501..1d8a57ab 100644 --- a/README.rst +++ b/README.rst @@ -75,7 +75,7 @@ We're also using GitHub Actions for continuous integration and continuous deploy .. image:: https://github.com/HackSoc/csbot/actions/workflows/main.yml/badge.svg -.. image:: https://codecov.io/gh/HackSoc/csbot/branch/master/graph/badge.svg?token=oMJcY9E9lj +.. image:: https://codecov.io/gh/HackSoc/csbot/branch/main/graph/badge.svg?token=oMJcY9E9lj :target: https://codecov.io/gh/HackSoc/csbot @@ -89,4 +89,4 @@ We're also using GitHub Actions for continuous integration and continuous deploy .. _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/ From ec1dd35165568aebb2cc16cd2ff13099d4c201d2 Mon Sep 17 00:00:00 2001 From: Alan Briolat Date: Wed, 17 Nov 2021 15:47:57 +0000 Subject: [PATCH 19/41] Remove like/dislike from LinkInfo YouTube integration YouTube is removing dislike counts from API responses on December 13th, 2021. --- src/csbot/plugins/youtube.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) 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') From 636739da8e97b808ea513c0b5542bfc32a0c6bb2 Mon Sep 17 00:00:00 2001 From: Alan Briolat Date: Wed, 17 Nov 2021 16:09:40 +0000 Subject: [PATCH 20/41] Update YouTube plugin tests to reflect removed "likes" --- tests/test_plugin_youtube.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_plugin_youtube.py b/tests/test_plugin_youtube.py index c221b88b..d51c87f5 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'} ), From f06be31bd00b3bf0fd2883a2db5f1c67ea9d55de Mon Sep 17 00:00:00 2001 From: Alan Briolat Date: Fri, 28 May 2021 22:32:05 +0100 Subject: [PATCH 21/41] Moving main deployment to libera.chat, RIP freenode --- csbot.deploy.toml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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" From bfa4c02c8dbe6784ca207ccb268f61d52bbd5911 Mon Sep 17 00:00:00 2001 From: Alan Briolat Date: Sun, 28 Mar 2021 13:27:39 +0100 Subject: [PATCH 22/41] Switch from GitHub Packages to GitHub Container Registry --- .github/workflows/main.yml | 10 +++++----- docker-compose.yml | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a3531a7f..f32d3d35 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -55,17 +55,17 @@ jobs: tags: csbot:latest - name: Run tests inside Docker run: docker run --rm csbot:latest pytest - - name: Login to GitHub Packages - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + - 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: docker.pkg.github.com + registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Publish Docker image - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: github.event_name == 'push' && github.repository == 'HackSoc/csbot' && github.ref == 'refs/heads/main' uses: docker/build-push-action@v2 with: push: true tags: | - docker.pkg.github.com/hacksoc/csbot/csbot:latest + ghcr.io/hacksoc/csbot/csbot:latest diff --git a/docker-compose.yml b/docker-compose.yml index 07ce531a..8913ec7e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ version: "3" services: bot: - image: docker.pkg.github.com/hacksoc/csbot/csbot:latest + image: ghcr.io/hacksoc/csbot/csbot:latest volumes: - ${CSBOT_CONFIG_LOCAL:-./csbot.toml}:/app/csbot.toml links: From 81c1749e9bb7ce2b6a7a877831896b2d3bf1adf5 Mon Sep 17 00:00:00 2001 From: Alan Briolat Date: Mon, 14 Feb 2022 19:23:29 +0000 Subject: [PATCH 23/41] CI: add Python 3.10, drop Python 3.6, update to ubuntu 20.04 --- .github/workflows/main.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f32d3d35..6e0d2789 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -9,19 +9,19 @@ on: jobs: tests: - runs-on: ubuntu-18.04 + runs-on: ubuntu-20.04 strategy: matrix: - python-version: [3.6, 3.7, 3.8, 3.9] + python-version: [3.7, 3.8, 3.9, 3.10] include: - - python-version: 3.6 - toxenv: py36 - python-version: 3.7 toxenv: py37 - python-version: 3.8 toxenv: py38 - python-version: 3.9 - toxenv: py39-flake8 + toxenv: py39 + - python-version: 3.10 + toxenv: py310-flake8 env: PYTHON: ${{ matrix.python-version }} steps: From db4d1b5791ad5ee55a7bf39539b76d56f72e652f Mon Sep 17 00:00:00 2001 From: Alan Briolat Date: Mon, 14 Feb 2022 19:27:26 +0000 Subject: [PATCH 24/41] CI: quote Python version numbers to avoid accidentally testing Python 3.1... --- .github/workflows/main.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6e0d2789..c21e52b4 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -12,15 +12,15 @@ jobs: runs-on: ubuntu-20.04 strategy: matrix: - python-version: [3.7, 3.8, 3.9, 3.10] + python-version: ["3.7", "3.8", "3.9", "3.10"] include: - - python-version: 3.7 + - python-version: "3.7" toxenv: py37 - - python-version: 3.8 + - python-version: "3.8" toxenv: py38 - - python-version: 3.9 + - python-version: "3.9" toxenv: py39 - - python-version: 3.10 + - python-version: "3.10" toxenv: py310-flake8 env: PYTHON: ${{ matrix.python-version }} From ac845dc73627a531e3f04ac3fca6f0d3c14ac5b7 Mon Sep 17 00:00:00 2001 From: Alan Briolat Date: Mon, 14 Feb 2022 20:17:11 +0000 Subject: [PATCH 25/41] tests: drop Python 3.6, use Python 3.10 as default, update dependencies to work with 3.10 --- requirements.txt | 8 ++++---- tox.ini | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements.txt b/requirements.txt index d3a53f6c..a09a5fa2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ # Requirements for unit testing -pytest>=5.2.2,<6.0 -pytest-asyncio==0.10.0 -pytest-aiohttp==0.3.0 -aioresponses==0.7.2 +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 diff --git a/tox.ini b/tox.ini index 0e1676b3..36c50f09 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py36,py37,py38,py39-flake8 +envlist = py37,py38,py39,py310-flake8 skipsdist = True [testenv] From 745781a8858815891e47aeb26ebbcfa5f7d80937 Mon Sep 17 00:00:00 2001 From: Alan Briolat Date: Mon, 14 Feb 2022 20:24:26 +0000 Subject: [PATCH 26/41] tests: get (almost) everything passing again by removing explicit "loop" kwarg In several parts of asyncio, explicit "loop" kwarg was deprecated in 3.8 and removed in 3.10. These interfaces have automatically used the correct loop since 3.7, so by dropping 3.6 support, no problem remains. --- src/csbot/events.py | 7 ++----- src/csbot/irc.py | 4 ++-- tests/__init__.py | 4 ++-- tests/conftest.py | 2 +- tests/test_events.py | 30 +++++++++++++++--------------- tests/test_plugin_linkinfo.py | 12 ++++++------ 6 files changed, 28 insertions(+), 31 deletions(-) diff --git a/src/csbot/events.py b/src/csbot/events.py index 4f0186fb..c6bf44f6 100644 --- a/src/csbot/events.py +++ b/src/csbot/events.py @@ -78,7 +78,7 @@ def __init__(self, handle_event, loop=None): self.loop = loop self.pending = set() - self.pending_event = asyncio.Event(loop=self.loop) + self.pending_event = asyncio.Event() self.pending_event.clear() self.future = None @@ -134,7 +134,6 @@ def _run(self): break # Run until 1 or more tasks complete (or more tasks are added) done, not_done = yield from asyncio.wait(not_done, - loop=self.loop, return_when=asyncio.FIRST_COMPLETED) # Handle exceptions raised by tasks for f in done: @@ -181,7 +180,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 @@ -242,7 +241,6 @@ def _run_handler(self, handler, event): future = maybe_future( result, log=LOG, - loop=self.loop, ) if future: future = asyncio.ensure_future(self._finish_async_handler(future, event), loop=self.loop) @@ -278,7 +276,6 @@ async def _run(self): 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) # Remove done futures from the set of futures being waited on done_futures = done - {new_events} diff --git a/src/csbot/irc.py b/src/csbot/irc.py index d9293113..01e51167 100644 --- a/src/csbot/irc.py +++ b/src/csbot/irc.py @@ -267,9 +267,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 diff --git a/tests/__init__.py b/tests/__init__.py index 8e4f45b3..28ada007 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): diff --git a/tests/conftest.py b/tests/conftest.py index c65007b2..9da6e9dc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -29,7 +29,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 diff --git a/tests/test_events.py b/tests/test_events.py index b688d0d6..e4d100a8 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -287,7 +287,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') @@ -333,7 +333,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'), @@ -349,7 +349,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'), @@ -366,7 +366,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'), @@ -385,7 +385,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') @@ -429,7 +429,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'), @@ -444,7 +444,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'), @@ -460,7 +460,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'), @@ -472,7 +472,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') @@ -487,7 +487,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 == [] @@ -496,7 +496,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'] @@ -505,7 +505,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'] @@ -526,14 +526,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'] @@ -571,7 +571,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_plugin_linkinfo.py b/tests/test_plugin_linkinfo.py index 0386eb4f..14f105c2 100644 --- a/tests/test_plugin_linkinfo.py +++ b/tests/test_plugin_linkinfo.py @@ -232,7 +232,7 @@ def privmsg(self, event): 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 +245,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,7 +254,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 :foo'), @@ -264,7 +264,7 @@ async def handler(url, **kwargs): 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 +278,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,7 +287,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: ' From f317ce5dddf7b21dcda75c3660887af4815a5217 Mon Sep 17 00:00:00 2001 From: Alan Briolat Date: Mon, 14 Feb 2022 21:49:32 +0000 Subject: [PATCH 27/41] tests: get (almost) everything passing again by removing explicit "loop" kwarg In several parts of asyncio, explicit "loop" kwarg was deprecated in 3.8 and removed in 3.10. These interfaces have automatically used the correct loop since 3.7, so by dropping 3.6 support, no problem remains. --- src/csbot/events.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/csbot/events.py b/src/csbot/events.py index c6bf44f6..fbd98ce6 100644 --- a/src/csbot/events.py +++ b/src/csbot/events.py @@ -133,8 +133,7 @@ def _run(self): new_pending.cancel() break # Run until 1 or more tasks complete (or more tasks are added) - done, not_done = yield from asyncio.wait(not_done, - return_when=asyncio.FIRST_COMPLETED) + done, not_done = await asyncio.wait(not_done, return_when=asyncio.FIRST_COMPLETED) # Handle exceptions raised by tasks for f in done: e = f.exception() @@ -238,10 +237,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, - ) + future = maybe_future(result, log=LOG) if future: future = asyncio.ensure_future(self._finish_async_handler(future, event), loop=self.loop) return future @@ -275,8 +271,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}, - 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)) From 079e845ff9d8594223cea6973e06b8df026dae1a Mon Sep 17 00:00:00 2001 From: Alan Briolat Date: Mon, 14 Feb 2022 20:39:52 +0000 Subject: [PATCH 28/41] tests: account for version-dependent error from math.factorial() --- tests/test_plugin_calc.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_plugin_calc.py b/tests/test_plugin_calc.py index f84bd7fe..b9f9c474 100644 --- a/tests/test_plugin_calc.py +++ b/tests/test_plugin_calc.py @@ -53,7 +53,10 @@ 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 if sys.version_info < (3, 9): assert calc._calc("(" * 200 + ")" * 200) == "Error, unable to parse" From 55d038699adb5c50bdd5948a3b4682059391c2f3 Mon Sep 17 00:00:00 2001 From: Alan Briolat Date: Mon, 14 Feb 2022 20:40:16 +0000 Subject: [PATCH 29/41] docker: use python:3.10 base image --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 7943340d..5df8e9a2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.9 +FROM python:3.10 ARG UID=9000 ARG GID=9000 From 361bd5c57f55379b37ec4c1b5d031b9a368328cb Mon Sep 17 00:00:00 2001 From: Alan Briolat Date: Mon, 14 Feb 2022 20:58:52 +0000 Subject: [PATCH 30/41] Fix a few deprecation warnings --- pytest.ini | 1 + src/csbot/events.py | 3 +-- tests/test_events.py | 21 +++++++-------------- 3 files changed, 9 insertions(+), 16 deletions(-) diff --git a/pytest.ini b/pytest.ini index 824b868c..43eede17 100644 --- a/pytest.ini +++ b/pytest.ini @@ -3,3 +3,4 @@ testpaths = tests/ 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/src/csbot/events.py b/src/csbot/events.py index fbd98ce6..46249361 100644 --- a/src/csbot/events.py +++ b/src/csbot/events.py @@ -116,8 +116,7 @@ def _get_pending(self): self.pending_event.clear() return pending - @asyncio.coroutine - def _run(self): + async def _run(self): # Use self as context manager so an escaping exception doesn't break # the event runner instance permanently (i.e. we clean up the future) with self: diff --git a/tests/test_events.py b/tests/test_events.py index e4d100a8..fd273604 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -138,16 +138,13 @@ def test_values(self, async_runner): @pytest.mark.asyncio def test_event_chain(self, async_runner): """Check that chains of events get handled.""" - @asyncio.coroutine - def f1(): + async def f1(): async_runner.runner.post_event(f2) - @asyncio.coroutine - def f2(): + async def f2(): async_runner.runner.post_event(f3) - @asyncio.coroutine - def f3(): + async def f3(): pass yield from async_runner.runner.post_event(f1) @@ -159,21 +156,17 @@ def test_exception_recovery(self, async_runner): """Check that exceptions are handled but don't block other tasks or leave the runner in a broken state. """ - @asyncio.coroutine - def f1(): + async def f1(): async_runner.runner.post_event(f2) raise Exception() - @asyncio.coroutine - def f2(): + async def f2(): pass - @asyncio.coroutine - def f3(): + async def f3(): async_runner.runner.post_event(f4) - @asyncio.coroutine - def f4(): + async def f4(): pass assert async_runner.exception_handler.call_count == 0 From fa1c743d768af985ccedaa72565788ac9528a64d Mon Sep 17 00:00:00 2001 From: Alan Briolat Date: Mon, 14 Feb 2022 21:07:34 +0000 Subject: [PATCH 31/41] tests: fix lint errors, make flake8 pass mandatory, pin flake8 version --- src/csbot/config.py | 4 ++-- src/csbot/util.py | 6 +++--- tests/conftest.py | 4 ++-- tests/test_bot.py | 4 ++-- tests/test_config.py | 4 ++-- tests/test_plugin_github.py | 4 ++-- tests/test_plugin_linkinfo.py | 2 +- tox.ini | 4 ++-- 8 files changed, 16 insertions(+), 16 deletions(-) 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/util.py b/src/csbot/util.py index 83fd2e09..740a5a96 100644 --- a/src/csbot/util.py +++ b/src/csbot/util.py @@ -134,14 +134,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/conftest.py b/tests/conftest.py index 9da6e9dc..9066f263 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -122,7 +122,7 @@ 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): """Check that a list of (unicode) strings have been sent. @@ -132,7 +132,7 @@ def assert_sent(self, lines): """ if isinstance(lines, str): lines = [lines] - self.client.send_line.assert_has_calls([mock.call(l) for l in lines]) + self.client.send_line.assert_has_calls([mock.call(line) for line in lines]) self.client.send_line.reset_mock() diff --git a/tests/test_bot.py b/tests/test_bot.py index ce398af6..147dc359 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -188,7 +188,7 @@ 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"] @@ -248,7 +248,7 @@ 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"] 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_plugin_github.py b/tests/test_plugin_github.py index 3be1a8e3..a166e487 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 = [ diff --git a/tests/test_plugin_linkinfo.py b/tests/test_plugin_linkinfo.py index 14f105c2..d7726fab 100644 --- a/tests/test_plugin_linkinfo.py +++ b/tests/test_plugin_linkinfo.py @@ -221,7 +221,7 @@ def __init__(self, *args, **kwargs): def privmsg(self, event): self.handler_mock(event['message']) - CONFIG = f"""\ + CONFIG = """\ ["@bot"] plugins = ["mockplugin", "linkinfo"] """ diff --git a/tox.ini b/tox.ini index 36c50f09..b349a891 100644 --- a/tox.ini +++ b/tox.ini @@ -6,7 +6,7 @@ skipsdist = True passenv = TRAVIS TRAVIS_* GITHUB_* deps = -r requirements.txt - flake8: flake8 + flake8: flake8==4.0.1 commands = python -m pytest {posargs} - flake8: flake8 --exit-zero --exclude=src/csbot/plugins_broken src/ tests/ + flake8: flake8 --exclude=src/csbot/plugins_broken src/ tests/ From 8079ed7a009e1eb43efec38579d67e5d850ab0f4 Mon Sep 17 00:00:00 2001 From: Alan Briolat Date: Mon, 14 Feb 2022 21:20:52 +0000 Subject: [PATCH 32/41] tests: remove redundant uses of @pytest.mark.asyncio, fix some old yield-style tests that were being ignored --- tests/conftest.py | 1 - tests/test_bot.py | 6 ------ tests/test_events.py | 19 ++++++++----------- tests/test_irc.py | 17 ----------------- tests/test_plugin_github.py | 1 - tests/test_plugin_imgur.py | 3 --- tests/test_plugin_linkinfo.py | 14 +++----------- tests/test_plugin_usertrack.py | 1 - tests/test_plugin_whois.py | 1 - tests/test_plugin_xkcd.py | 8 -------- tests/test_plugin_youtube.py | 2 -- tests/test_util.py | 14 -------------- 12 files changed, 11 insertions(+), 76 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 9066f263..97907c96 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -149,7 +149,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?') diff --git a/tests/test_bot.py b/tests/test_bot.py index 147dc359..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'] @@ -195,7 +192,6 @@ def command_cd(self, *args, **kwargs): """ @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'] @@ -255,7 +250,6 @@ def command_a(self, *args, **kwargs): """ @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_events.py b/tests/test_events.py index fd273604..dae027d3 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -123,20 +123,18 @@ def handle_event(event): class TestAsyncEventRunner: - @pytest.mark.asyncio - def test_values(self, async_runner): + async def test_values(self, async_runner): """Check that basic values are passed through the event queue unmolested.""" # Test that things actually get through - yield from async_runner.runner.post_event('foo') + await async_runner.runner.post_event('foo') assert async_runner.handle_event.call_args_list == [mock.call('foo')] # The event runner doesn't care what it's passing through for x in ['bar', 1.3, None, object]: - yield from async_runner.runner.post_event(x) + await async_runner.runner.post_event(x) assert async_runner.handle_event.call_args[0][0] is x - @pytest.mark.asyncio - def test_event_chain(self, async_runner): + async def test_event_chain(self, async_runner): """Check that chains of events get handled.""" async def f1(): async_runner.runner.post_event(f2) @@ -147,12 +145,12 @@ async def f2(): async def f3(): pass - yield from async_runner.runner.post_event(f1) + await async_runner.runner.post_event(f1) assert async_runner.handle_event.call_count == 3 async_runner.handle_event.assert_has_calls([mock.call(f1), mock.call(f2), mock.call(f3)]) @pytest.mark.asyncio(allow_unhandled_exception=True) - def test_exception_recovery(self, async_runner): + async def test_exception_recovery(self, async_runner): """Check that exceptions are handled but don't block other tasks or leave the runner in a broken state. """ @@ -170,17 +168,16 @@ async def f4(): pass assert async_runner.exception_handler.call_count == 0 - yield from async_runner.runner.post_event(f1) + await async_runner.runner.post_event(f1) assert async_runner.exception_handler.call_count == 1 async_runner.handle_event.assert_has_calls([mock.call(f1), mock.call(f2)]) # self.assertEqual(set(self.handled_events), {f1, f2}) - yield from async_runner.runner.post_event(f3) + await async_runner.runner.post_event(f3) assert async_runner.exception_handler.call_count == 1 async_runner.handle_event.assert_has_calls([mock.call(f1), mock.call(f2), mock.call(f3), mock.call(f4)]) # self.assertEqual(set(self.handled_events), {f1, f2, f3, f4}) -@pytest.mark.asyncio class TestHybridEventRunner: class EventHandler: def __init__(self): diff --git a/tests/test_irc.py b/tests/test_irc.py index baba25e1..950bbb04 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,7 +187,6 @@ 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() @@ -228,7 +217,6 @@ async def test_client_PING(self, fast_forward, run_client): mock.call('PING 5'), ] - @pytest.mark.asyncio 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() @@ -408,7 +396,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(None, 'PING', ['0'], 'PING', 'PING :0'), @@ -444,7 +431,6 @@ async def test_wait_for_success(irc_client_helper): ] -@pytest.mark.asyncio async def test_wait_for_cancelled(irc_client_helper): messages = [ IRCMessage(None, 'PING', ['0'], 'PING', 'PING :0'), @@ -469,7 +455,6 @@ async def test_wait_for_cancelled(irc_client_helper): ] -@pytest.mark.asyncio async def test_wait_for_exception(irc_client_helper): messages = [ IRCMessage(None, 'PING', ['0'], 'PING', 'PING :0'), @@ -523,7 +508,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 +516,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_github.py b/tests/test_plugin_github.py index a166e487..f0e1ceb7 100644 --- a/tests/test_plugin_github.py +++ b/tests/test_plugin_github.py @@ -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_linkinfo.py b/tests/test_plugin_linkinfo.py index d7726fab..94a256db 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 @@ -228,7 +222,6 @@ def privmsg(self, event): 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() @@ -260,7 +253,6 @@ async def handler(url, **kwargs): mock.call('NOTICE #channel :foo'), ]) - @pytest.mark.asyncio async def test_non_blocking_command(self, event_loop, bot_helper, aioresponses): bot_helper.reset_mock() 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..e335b660 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,7 +167,6 @@ 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): @@ -185,7 +178,6 @@ async def test_command(self, bot_helper, num, url, content_type, fixture, expect assert alt in outgoing @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 d51c87f5..68c99baa 100644 --- a/tests/test_plugin_youtube.py +++ b/tests/test_plugin_youtube.py @@ -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 a82db63d..d4bf6110 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -6,12 +6,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 @@ -20,7 +18,6 @@ async def test_maybe_future_non_awaitable(): ] -@pytest.mark.asyncio async def test_maybe_future_coroutine(): async def foo(): await asyncio.sleep(0) @@ -34,13 +31,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) @@ -50,7 +45,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) @@ -71,7 +65,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 @@ -98,7 +91,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 @@ -129,7 +121,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 @@ -168,7 +159,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 @@ -211,7 +201,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 @@ -230,7 +219,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 @@ -244,7 +232,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 @@ -260,7 +247,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 From 4692005e42d8f786508250452c68cbb93b9c6981 Mon Sep 17 00:00:00 2001 From: Alan Briolat Date: Mon, 14 Feb 2022 22:00:46 +0000 Subject: [PATCH 33/41] Quick fix for one 'loop=' kwarg that was missed --- src/csbot/irc.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/csbot/irc.py b/src/csbot/irc.py index 01e51167..23fb3a6e 100644 --- a/src/csbot/irc.py +++ b/src/csbot/irc.py @@ -316,7 +316,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): From b4427797532fe99756afd550054346243be07407 Mon Sep 17 00:00:00 2001 From: Alan Briolat Date: Mon, 14 Feb 2022 22:04:20 +0000 Subject: [PATCH 34/41] Revert "Quick fix for one 'loop=' kwarg that was missed" This reverts commit 4692005e42d8f786508250452c68cbb93b9c6981. --- src/csbot/irc.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/csbot/irc.py b/src/csbot/irc.py index 23fb3a6e..01e51167 100644 --- a/src/csbot/irc.py +++ b/src/csbot/irc.py @@ -316,6 +316,7 @@ 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): From 4d736c6fdaf1b0fd4b04c921348969d696dd0a45 Mon Sep 17 00:00:00 2001 From: Alan Briolat Date: Mon, 14 Feb 2022 22:00:46 +0000 Subject: [PATCH 35/41] Quick fix for one 'loop=' kwarg that was missed --- src/csbot/irc.py | 1 - tests/__init__.py | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/csbot/irc.py b/src/csbot/irc.py index 01e51167..23fb3a6e 100644 --- a/src/csbot/irc.py +++ b/src/csbot/irc.py @@ -316,7 +316,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/tests/__init__.py b/tests/__init__.py index 28ada007..5e27da92 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -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() From 66c451d2dc9466a761b0aaf57deb8773e7fc12e4 Mon Sep 17 00:00:00 2001 From: Alan Briolat Date: Tue, 15 Feb 2022 12:41:43 +0000 Subject: [PATCH 36/41] tests: extend capabilities of bot_helper.assert_sent() to allow more complex matching --- tests/conftest.py | 45 +++++++++++++++++++++++++++++++---- tests/test_irc.py | 38 ++++++----------------------- tests/test_plugin_last.py | 0 tests/test_plugin_linkinfo.py | 10 +++----- tests/test_plugin_xkcd.py | 4 +--- 5 files changed, 51 insertions(+), 46 deletions(-) create mode 100644 tests/test_plugin_last.py diff --git a/tests/conftest.py b/tests/conftest.py index 97907c96..7cfd91bc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -124,16 +124,51 @@ def receive(self, lines): lines = [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(line) for line 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() + + +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}`") @pytest.fixture diff --git a/tests/test_irc.py b/tests/test_irc.py index 950bbb04..9b88dbb9 100644 --- a/tests/test_irc.py +++ b/tests/test_irc.py @@ -193,29 +193,15 @@ async def test_client_PING(self, fast_forward, run_client): 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'), - ] + 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.""" @@ -223,32 +209,22 @@ async def test_client_PING_only_when_needed(self, fast_forward, run_client): 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): diff --git a/tests/test_plugin_last.py b/tests/test_plugin_last.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_plugin_linkinfo.py b/tests/test_plugin_linkinfo.py index 94a256db..ecd789ee 100644 --- a/tests/test_plugin_linkinfo.py +++ b/tests/test_plugin_linkinfo.py @@ -249,9 +249,7 @@ async def handler(url, **kwargs): event.set() 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') async def test_non_blocking_command(self, event_loop, bot_helper, aioresponses): bot_helper.reset_mock() @@ -281,7 +279,5 @@ async def handler(url, **kwargs): event.set() 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_xkcd.py b/tests/test_plugin_xkcd.py index e335b660..ff08adb8 100644 --- a/tests/test_plugin_xkcd.py +++ b/tests/test_plugin_xkcd.py @@ -173,9 +173,7 @@ async def test_command(self, bot_helper, num, url, content_type, fixture, expect _, 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") async def test_integration_error(self, bot_helper): From 3e827ddfcaa2c9395d429454cd65ef9fc42d3890 Mon Sep 17 00:00:00 2001 From: Alan Briolat Date: Tue, 15 Feb 2022 13:03:22 +0000 Subject: [PATCH 37/41] Temporarily pin pymongo 3.x, to allow writing tests for pymongo-using plugins --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index df78702d..954f38df 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ install_requires=[ 'click>=6.2,<7.0', 'straight.plugin==1.4.0-post-1', - 'pymongo>=3.6.0', + 'pymongo>=3.6.0,<4', 'requests>=2.9.1,<3.0.0', 'lxml>=2.3.5', 'aiogoogle>=0.1.13', From 6a91dfc9076a3e8de627ceb4d00b6e5bb0cfb096 Mon Sep 17 00:00:00 2001 From: Alan Briolat Date: Tue, 15 Feb 2022 13:08:55 +0000 Subject: [PATCH 38/41] Write tests for `last` plugin --- src/csbot/plugins/last.py | 4 +- tests/test_plugin_last.py | 143 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+), 1 deletion(-) diff --git a/src/csbot/plugins/last.py b/src/csbot/plugins/last.py index c1fc59d0..56f1cbd5 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 diff --git a/tests/test_plugin_last.py b/tests/test_plugin_last.py index e69de29b..585d49bd 100644 --- a/tests/test_plugin_last.py +++ 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\".") From ae61a3698d757de94cd85883e2d2c6970b817037 Mon Sep 17 00:00:00 2001 From: Alan Briolat Date: Wed, 16 Feb 2022 09:28:42 +0000 Subject: [PATCH 39/41] Write tests for `termdates` plugin --- requirements.txt | 1 + tests/conftest.py | 3 +- tests/test_plugin_termdates.py | 136 +++++++++++++++++++++++++++++++++ 3 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 tests/test_plugin_termdates.py diff --git a/requirements.txt b/requirements.txt index a09a5fa2..287ea32c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,6 +6,7 @@ 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/tests/conftest.py b/tests/conftest.py index 7cfd91bc..f0165617 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 @@ -205,7 +206,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_plugin_termdates.py b/tests/test_plugin_termdates.py new file mode 100644 index 00000000..bbdc0773 --- /dev/null +++ b/tests/test_plugin_termdates.py @@ -0,0 +1,136 @@ +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(lambda line: 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([ + lambda line: 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(lambda line: 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(lambda line: line.endswith("Aut 3: 2021-10-11")) + await asyncio.wait(bot_helper.receive(say("!week spr 10"))) + bot_helper.assert_sent(lambda line: line.endswith("Spr 10: 2022-03-14")) + await asyncio.wait(bot_helper.receive(say("!week sum 4"))) + # TODO: should actually be bot_helper.assert_sent(lambda line: line.endswith("Sum 4: 2022-05-09")) + bot_helper.assert_sent(lambda line: line.endswith("Sum 4: 2022-05-10")) + # `!week n term` means the same as `!week term n` + await asyncio.wait(bot_helper.receive(say("!week 3 aut"))) + bot_helper.assert_sent(lambda line: line.endswith("Aut 3: 2021-10-11")) + await asyncio.wait(bot_helper.receive(say("!week 10 spr"))) + bot_helper.assert_sent(lambda line: line.endswith("Spr 10: 2022-03-14")) + await asyncio.wait(bot_helper.receive(say("!week 4 sum"))) + # TODO: should actually be bot_helper.assert_sent(lambda line: line.endswith("Sum 4: 2022-05-09")) + bot_helper.assert_sent(lambda line: line.endswith("Sum 4: 2022-05-10")) + + # 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(lambda line: 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(lambda line: 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(lambda line: 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(lambda line: 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(lambda line: 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(lambda line: 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(lambda line: 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(lambda line: 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"))) + # TODO: should actually be + # bot_helper.assert_sent(lambda line: line.endswith("2nd week before Sum (starts 2022-04-18)")) + bot_helper.assert_sent(lambda line: line.endswith("3rd 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"))) + # TODO: should actually be bot_helper.assert_sent(lambda line: line.endswith("Sum 3: 2022-05-02")) + bot_helper.assert_sent(lambda line: line.endswith("Sum 3: 2022-05-03")) + + # 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"))) + # TODO: should actually be bot_helper.assert_sent(lambda line: line.endswith("Sum 7: 2022-05-30")) + bot_helper.assert_sent(lambda line: line.endswith("Sum 7: 2022-05-31")) + # `!week n` should give the start of the Nth week in the Summer term + await asyncio.wait(bot_helper.receive(say("!week 3"))) + # TODO: should actually be bot_helper.assert_sent(lambda line: line.endswith("Sum 3: 2022-05-02")) + bot_helper.assert_sent(lambda line: line.endswith("Sum 3: 2022-05-03")) + + # TODO: currently just throws an exception when after the end of Summer term, fix code and enable these tests + # time_machine.move_to(datetime.datetime(2022, 8, 15, 12, 0)) + # # `!week` should give "Sum 18" + # await asyncio.wait(bot_helper.receive(say("!week"))) + # bot_helper.assert_sent(lambda line: line.endswith("Sum 18: 2022-08-15")) + # # `!week n` should give the start of the Nth week in the Summer term + # await asyncio.wait(bot_helper.receive(say("!week 3"))) + # # TODO: should actually be bot_helper.assert_sent(lambda line: line.endswith("Sum 3: 2022-05-02")) + # bot_helper.assert_sent(lambda line: line.endswith("Sum 3: 2022-05-03")) From 1e980e08f98c0f6cec1fd8878f059dcf5e11bf14 Mon Sep 17 00:00:00 2001 From: Alan Briolat Date: Wed, 16 Feb 2022 09:53:19 +0000 Subject: [PATCH 40/41] Update to pymongo 4.x, fix `last` and `termdates` plugins --- setup.py | 2 +- src/csbot/plugins/last.py | 3 +-- src/csbot/plugins/termdates.py | 12 ++++++++++-- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/setup.py b/setup.py index 954f38df..2c2826f2 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ install_requires=[ 'click>=6.2,<7.0', 'straight.plugin==1.4.0-post-1', - 'pymongo>=3.6.0,<4', + 'pymongo>=4.0.1', 'requests>=2.9.1,<3.0.0', 'lxml>=2.3.5', 'aiogoogle>=0.1.13', diff --git a/src/csbot/plugins/last.py b/src/csbot/plugins/last.py index 56f1cbd5..fbba774f 100644 --- a/src/csbot/plugins/last.py +++ b/src/csbot/plugins/last.py @@ -106,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..948adaa5 100644 --- a/src/csbot/plugins/termdates.py +++ b/src/csbot/plugins/termdates.py @@ -196,8 +196,16 @@ def termdates_set(self, e): # 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) + if '_id' in self.terms: + self.db_terms.replace_one({'_id': self.terms['_id']}, self.terms, upsert=True) + else: + res = self.db_terms.insert_one(self.terms) + self.terms['_id'] = res.inserted_id + if '_id' in self.weeks: + self.db_weeks.replace_one({'_id': self.weeks['_id']}, self.weeks, upsert=True) + else: + res = self.db_weeks.insert_one(self.weeks) + self.weeks['_id'] = res.inserted_id # Finally, we're initialised! self.initialised = True From 4ae8c558342152194f80f4d8e0cec47fb371a995 Mon Sep 17 00:00:00 2001 From: Alan Briolat Date: Thu, 17 Feb 2022 12:29:15 +0000 Subject: [PATCH 41/41] termdates: improve and refactor the plugin, fixing a couple of subtle bugs --- src/csbot/plugins/termdates.py | 231 +++++++++++++++++---------------- tests/conftest.py | 40 +++--- tests/test_plugin_termdates.py | 77 ++++++----- 3 files changed, 184 insertions(+), 164 deletions(-) diff --git a/src/csbot/plugins/termdates.py b/src/csbot/plugins/termdates.py index 948adaa5..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: - offset = timedelta(weeks=week) - return (start + offset).strftime(self.DATE_FORMAT) + term = self._current_or_next_term() + if term: + return term, term.get_week_number(datetime.date.today()) + else: + return None, None @Plugin.command('termdates.set', help='termdates.set : set the term dates') @@ -165,47 +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). - if '_id' in self.terms: - self.db_terms.replace_one({'_id': self.terms['_id']}, self.terms, upsert=True) - else: - res = self.db_terms.insert_one(self.terms) - self.terms['_id'] = res.inserted_id - if '_id' in self.weeks: - self.db_weeks.replace_one({'_id': self.weeks['_id']}, self.weeks, upsert=True) - else: - res = self.db_weeks.insert_one(self.weeks) - self.weeks['_id'] = res.inserted_id + terms[key] = Term(key, term_start) - # Finally, we're initialised! - self.initialised = True + self.terms = terms + self._save() diff --git a/tests/conftest.py b/tests/conftest.py index f0165617..e25f9be5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -81,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 @@ -155,21 +179,7 @@ def assert_sent(self, matchers, *, any_order=False, reset_mock=True): if reset_mock: self.client.send_line.reset_mock() - -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}`") + match_line = LineMatcher @pytest.fixture diff --git a/tests/test_plugin_termdates.py b/tests/test_plugin_termdates.py index bbdc0773..643db70a 100644 --- a/tests/test_plugin_termdates.py +++ b/tests/test_plugin_termdates.py @@ -25,7 +25,7 @@ async def test_term_dates(bot_helper, time_machine): # Nothing configured yet, !termdates should error await asyncio.wait(bot_helper.receive(say("!termdates"))) - bot_helper.assert_sent(lambda line: line.endswith("error: no term dates (see termdates.set)")) + bot_helper.assert_sent(bot_helper.match_line.endswith("error: no term dates (see termdates.set)")) # Save dates await asyncio.wait(bot_helper.receive([ @@ -33,9 +33,9 @@ async def test_term_dates(bot_helper, time_machine): say("!termdates"), ])) bot_helper.assert_sent([ - lambda line: line.endswith("Aut 2021-09-27 -- 2021-12-03, " - "Spr 2022-01-10 -- 2022-03-18, " - "Sum 2022-04-19 -- 2022-06-24"), + 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"), ]) @@ -44,93 +44,90 @@ async def test_week_command(bot_helper, time_machine): # Nothing configured yet, !week should error await asyncio.wait(bot_helper.receive(say("!week"))) - bot_helper.assert_sent(lambda line: line.endswith("error: no term dates (see termdates.set)")) + 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(lambda line: line.endswith("Aut 3: 2021-10-11")) + 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(lambda line: line.endswith("Spr 10: 2022-03-14")) + bot_helper.assert_sent(bot_helper.match_line.endswith("Spr 10: 2022-03-14")) await asyncio.wait(bot_helper.receive(say("!week sum 4"))) - # TODO: should actually be bot_helper.assert_sent(lambda line: line.endswith("Sum 4: 2022-05-09")) - bot_helper.assert_sent(lambda line: line.endswith("Sum 4: 2022-05-10")) + 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(lambda line: line.endswith("Aut 3: 2021-10-11")) + 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(lambda line: line.endswith("Spr 10: 2022-03-14")) + bot_helper.assert_sent(bot_helper.match_line.endswith("Spr 10: 2022-03-14")) await asyncio.wait(bot_helper.receive(say("!week 4 sum"))) - # TODO: should actually be bot_helper.assert_sent(lambda line: line.endswith("Sum 4: 2022-05-09")) - bot_helper.assert_sent(lambda line: line.endswith("Sum 4: 2022-05-10")) + 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(lambda line: line.endswith("9th week before Aut (starts 2021-09-27)")) + 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(lambda line: line.endswith("Aut 3: 2021-10-11")) + 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(lambda line: line.endswith("Aut 4: 2021-10-18")) + 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(lambda line: line.endswith("Aut 3: 2021-10-11")) + 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(lambda line: line.endswith("4th week before Spr (starts 2022-01-10)")) + 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(lambda line: line.endswith("Spr 3: 2022-01-24")) + 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(lambda line: line.endswith("Spr 10: 2022-03-14")) + 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(lambda line: line.endswith("Spr 3: 2022-01-24")) + 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"))) - # TODO: should actually be - # bot_helper.assert_sent(lambda line: line.endswith("2nd week before Sum (starts 2022-04-18)")) - bot_helper.assert_sent(lambda line: line.endswith("3rd week before Sum (starts 2022-04-19)")) + 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"))) - # TODO: should actually be bot_helper.assert_sent(lambda line: line.endswith("Sum 3: 2022-05-02")) - bot_helper.assert_sent(lambda line: line.endswith("Sum 3: 2022-05-03")) + 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"))) - # TODO: should actually be bot_helper.assert_sent(lambda line: line.endswith("Sum 7: 2022-05-30")) - bot_helper.assert_sent(lambda line: line.endswith("Sum 7: 2022-05-31")) + 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"))) - # TODO: should actually be bot_helper.assert_sent(lambda line: line.endswith("Sum 3: 2022-05-02")) - bot_helper.assert_sent(lambda line: line.endswith("Sum 3: 2022-05-03")) - - # TODO: currently just throws an exception when after the end of Summer term, fix code and enable these tests - # time_machine.move_to(datetime.datetime(2022, 8, 15, 12, 0)) - # # `!week` should give "Sum 18" - # await asyncio.wait(bot_helper.receive(say("!week"))) - # bot_helper.assert_sent(lambda line: line.endswith("Sum 18: 2022-08-15")) - # # `!week n` should give the start of the Nth week in the Summer term - # await asyncio.wait(bot_helper.receive(say("!week 3"))) - # # TODO: should actually be bot_helper.assert_sent(lambda line: line.endswith("Sum 3: 2022-05-02")) - # bot_helper.assert_sent(lambda line: line.endswith("Sum 3: 2022-05-03")) + 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"))