diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f8be509 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,127 @@ +# GitHub Actions configuration **EXAMPLE**, +# MODIFY IT ACCORDING TO YOUR NEEDS! +# Reference: https://docs.github.com/en/actions + +name: tests + +on: + push: + # Avoid using all the resources/limits available by checking only + # relevant branches and tags. Other branches can be checked via PRs. + branches: [main] + tags: ['v[0-9]*', '[0-9]+.[0-9]+*'] # Match tags that resemble a version + pull_request: # Run in every PR + workflow_dispatch: # Allow manually triggering the workflow + # schedule: + # Run roughly every 15 days at 00:00 UTC + # (useful to check if updates on dependencies break the package) + # - cron: '0 0 1,16 * *' + +permissions: + contents: read + +concurrency: + group: >- + ${{ github.workflow }}-${{ github.ref_type }}- + ${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +jobs: + prepare: + runs-on: ubuntu-latest + outputs: + wheel-distribution: ${{ steps.wheel-distribution.outputs.path }} + steps: + - uses: actions/checkout@v3 + with: {fetch-depth: 0} # deep clone for setuptools-scm + - uses: actions/setup-python@v4 + id: setup-python + with: {python-version: "3.11"} + - name: Run static analysis and format checkers + run: pipx run pre-commit run --all-files --show-diff-on-failure + - name: Build package distribution files + run: >- + pipx run --python '${{ steps.setup-python.outputs.python-path }}' + tox -e clean,build + - name: Record the path of wheel distribution + id: wheel-distribution + run: echo "path=$(ls dist/*.whl)" >> $GITHUB_OUTPUT + - name: Store the distribution files for use in other stages + # `tests` and `publish` will use the same pre-built distributions, + # so we make sure to release the exact same package that was tested + uses: actions/upload-artifact@v3 + with: + name: python-distribution-files + path: dist/ + retention-days: 1 + + test: + needs: prepare + strategy: + matrix: + python: + - "3.10" # oldest Python supported by PSF + - "3.11" # newest Python that is stable + platform: + - ubuntu-latest + - macos-latest + - windows-latest + runs-on: ${{ matrix.platform }} + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + id: setup-python + with: + python-version: ${{ matrix.python }} + - name: Retrieve pre-built distribution files + uses: actions/download-artifact@v3 + with: {name: python-distribution-files, path: dist/} + - name: Run tests + run: >- + pipx run --python '${{ steps.setup-python.outputs.python-path }}' + tox --installpkg '${{ needs.prepare.outputs.wheel-distribution }}' + -- -rFEx --durations 10 --color yes # pytest args + - name: Generate coverage report + run: pipx run coverage lcov -o coverage.lcov + - name: Upload partial coverage report + uses: coverallsapp/github-action@master + with: + path-to-lcov: coverage.lcov + github-token: ${{ secrets.GITHUB_TOKEN }} + flag-name: ${{ matrix.platform }} - py${{ matrix.python }} + parallel: true + + finalize: + needs: test + runs-on: ubuntu-latest + steps: + - name: Finalize coverage report + uses: coverallsapp/github-action@master + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + parallel-finished: true + + publish: + environment: + name: pypi-publish + needs: finalize + if: ${{ github.event_name == 'push' && contains(github.ref, 'refs/tags/') }} + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: {python-version: "3.11"} + - name: Retrieve pre-built distribution files + uses: actions/download-artifact@v3 + with: {name: python-distribution-files, path: dist/} + - name: Publish Package + env: + # TODO: Set your PYPI_TOKEN as a secret using GitHub UI + # - https://pypi.org/help/#apitoken + # - https://docs.github.com/en/actions/security-guides/encrypted-secrets + TWINE_REPOSITORY: pypi + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} + run: pipx run tox -e publish diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..f6eb9c2 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,76 @@ +exclude: '^docs/conf.py' + +default_install_hook_types: [pre-commit, commit-msg] + +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: trailing-whitespace + - id: check-added-large-files + - id: check-ast + - id: check-json + - id: check-merge-conflict + - id: check-xml + - id: check-yaml + - id: debug-statements + - id: end-of-file-fixer + - id: requirements-txt-fixer + - id: mixed-line-ending + args: ['--fix=lf'] # replace 'auto' with 'lf' to enforce Linux/Mac line endings or 'crlf' for Windows + +## If you want to automatically "modernize" your Python code: +# - repo: https://github.com/asottile/pyupgrade +# rev: v3.7.0 +# hooks: +# - id: pyupgrade +# args: ['--py37-plus'] + +## If you want to avoid flake8 errors due to unused vars or imports: +# - repo: https://github.com/PyCQA/autoflake +# rev: v2.1.1 +# hooks: +# - id: autoflake +# args: [ +# --in-place, +# --remove-all-unused-imports, +# --remove-unused-variables, +# ] + +- repo: https://github.com/PyCQA/isort + rev: 5.12.0 + hooks: + - id: isort + +- repo: https://github.com/psf/black + rev: 23.11.0 + hooks: + - id: black + language_version: python3 + +## If like to embrace black styles even in the docs: +# - repo: https://github.com/asottile/blacken-docs +# rev: v1.13.0 +# hooks: +# - id: blacken-docs +# additional_dependencies: [black] + +- repo: https://github.com/PyCQA/flake8 + rev: 6.1.0 + hooks: + - id: flake8 + ## You can add flake8 plugins via `additional_dependencies`: + # additional_dependencies: [flake8-bugbear] + +## Check for misspells in documentation files: +# - repo: https://github.com/codespell-project/codespell +# rev: v2.2.5 +# hooks: +# - id: codespell + +- repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook + rev: v9.10.0 + hooks: + - id: commitlint + stages: [commit-msg] + additional_dependencies: ['conventional-changelog-conventionalcommits'] diff --git a/README.rst b/README.rst index 670c739..a175736 100644 --- a/README.rst +++ b/README.rst @@ -81,13 +81,13 @@ List command line usage .. code-block:: shell - $ pysaleryd -h + $ pysaleryd -h Connect to system and capture websocket data to stdout .. code-block:: shell - $ pysaleryd --host WEBSOCKET_URL --port WEBSOCKET_PORT --listen [-t TIMEOUT] + $ pysaleryd --host WEBSOCKET_URL --port WEBSOCKET_PORT --listen [-t TIMEOUT] Send command to system @@ -107,7 +107,7 @@ Disclaimer Use at own risk. -This project is in no way affiliated with the manufacturer. +This project is in no way affiliated with the manufacturer. All product names, logos, and brands are property of their respective owners. All company, product and service names used are for identification purposes only. Use of these names, logos, and brands does not imply endorsement. diff --git a/commitlint.config.js b/commitlint.config.js new file mode 100644 index 0000000..a2e917d --- /dev/null +++ b/commitlint.config.js @@ -0,0 +1,135 @@ +module.exports = { + parserPreset: 'conventional-changelog-conventionalcommits', + rules: { + 'body-leading-blank': [1, 'always'], + 'body-max-line-length': [2, 'always', 100], + 'footer-leading-blank': [1, 'always'], + 'footer-max-line-length': [2, 'always', 100], + 'header-max-length': [2, 'always', 100], + 'subject-case': [ + 2, + 'never', + ['sentence-case', 'start-case', 'pascal-case', 'upper-case'], + ], + 'subject-empty': [2, 'never'], + 'subject-full-stop': [2, 'never', '.'], + 'type-case': [2, 'always', 'lower-case'], + 'type-empty': [2, 'never'], + 'type-enum': [ + 2, + 'always', + [ + 'build', + 'chore', + 'ci', + 'docs', + 'feat', + 'fix', + 'perf', + 'refactor', + 'revert', + 'style', + 'test', + ], + ], + }, + prompt: { + questions: { + type: { + description: "Select the type of change that you're committing", + enum: { + feat: { + description: 'A new feature', + title: 'Features', + emoji: '✨', + }, + fix: { + description: 'A bug fix', + title: 'Bug Fixes', + emoji: '🐛', + }, + docs: { + description: 'Documentation only changes', + title: 'Documentation', + emoji: '📚', + }, + style: { + description: + 'Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)', + title: 'Styles', + emoji: '💎', + }, + refactor: { + description: + 'A code change that neither fixes a bug nor adds a feature', + title: 'Code Refactoring', + emoji: '📦', + }, + perf: { + description: 'A code change that improves performance', + title: 'Performance Improvements', + emoji: '🚀', + }, + test: { + description: 'Adding missing tests or correcting existing tests', + title: 'Tests', + emoji: '🚨', + }, + build: { + description: + 'Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)', + title: 'Builds', + emoji: '🛠', + }, + ci: { + description: + 'Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs)', + title: 'Continuous Integrations', + emoji: '⚙️', + }, + chore: { + description: "Other changes that don't modify src or test files", + title: 'Chores', + emoji: '♻️', + }, + revert: { + description: 'Reverts a previous commit', + title: 'Reverts', + emoji: '🗑', + }, + }, + }, + scope: { + description: + 'What is the scope of this change (e.g. component or file name)', + }, + subject: { + description: + 'Write a short, imperative tense description of the change', + }, + body: { + description: 'Provide a longer description of the change', + }, + isBreaking: { + description: 'Are there any breaking changes?', + }, + breakingBody: { + description: + 'A BREAKING CHANGE commit requires a body. Please enter a longer description of the commit itself', + }, + breaking: { + description: 'Describe the breaking changes', + }, + isIssueAffected: { + description: 'Does this change affect any open issues?', + }, + issuesBody: { + description: + 'If issues are closed, the commit requires a body. Please enter a longer description of the commit itself', + }, + issues: { + description: 'Add issue references (e.g. "fix #123", "re #123".)', + }, + }, + }, +}; diff --git a/docs/changelog.rst b/docs/changelog.rst index 15d05c5..8030bcd 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -5,4 +5,4 @@ Changelog .. changelog:: :changelog-url: https://github.com/bj00rn/pysaleryd/releases/ :github: https://github.com/bj00rn/pysaleryd/releases/ - :pypi: https://pypi.org/project/pysaleryd/ \ No newline at end of file + :pypi: https://pypi.org/project/pysaleryd/ diff --git a/docs/requirements.txt b/docs/requirements.txt index 4c0c8a8..8da3199 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,7 +1,6 @@ # Requirements file for ReadTheDocs, check .readthedocs.yml. # To build the module reference correctly, make sure every external package # under `install_requires` in `setup.cfg` is also listed here! -aiohttp sphinx>=3.2.1 -sphinx_rtd_theme sphinx-github-changelog>=1.2.0 +sphinx_rtd_theme diff --git a/pyproject.toml b/pyproject.toml index 89a5bed..c96c1d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,3 +7,7 @@ build-backend = "setuptools.build_meta" # For smarter version schemes and other configuration options, # check out https://github.com/pypa/setuptools_scm version_scheme = "no-guess-dev" + +[tool.isort] +known_first_party = ["pysaleryd"] +profile = "black" diff --git a/setup.cfg b/setup.cfg index f5b04ad..4c6ae45 100644 --- a/setup.cfg +++ b/setup.cfg @@ -16,9 +16,14 @@ url = https://github.com/bj00rn/pysaleryd/ # Add here related links, for example: project_urls = Documentation = https://bj00rn.github.io/pysaleryd/ + Source = https://github.com/pyscaffold/pyscaffold/ + Changelog = https://bj00rn.github.io/pysaleryd/changelog.html + Tracker = https://bj00rn.github.io/pysaleryd/issues + Download = https://pypi.org/project/pysaleryd/#files + # Change if running only on Windows, Mac or Linux (comma-separated) -platforms = Mac,Linux +platforms = any # Add here all kinds of additional classifiers as defined under # https://pypi.org/classifiers/ @@ -120,5 +125,8 @@ exclude = [pyscaffold] # PyScaffold's parameters when the project was created. # This will be used when updating. Do not change! -version = 4.4 +version = 4.5 package = pysaleryd +extensions = + github_actions + pre_commit diff --git a/setup.py b/setup.py index bbfd7d2..6b134e6 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ Setup file for pysaleryd. Use setup.cfg to configure your project. - This file was generated with PyScaffold 4.4. + This file was generated with PyScaffold 4.5. PyScaffold helps you to put up the scaffold of your new Python project. Learn more under: https://pyscaffold.org/ """ diff --git a/src/pysaleryd/client.py b/src/pysaleryd/client.py index 4440ebd..6f1f085 100644 --- a/src/pysaleryd/client.py +++ b/src/pysaleryd/client.py @@ -3,10 +3,11 @@ import asyncio import logging from typing import Callable + import aiohttp -from .websocket import WSClient, Signal, State from .utils import ParseError, Parser +from .websocket import Signal, State, WSClient _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -39,6 +40,7 @@ def begin_frame(self): """Begin new frame""" self._is_collecting = True + class Client: """Client to manage communication with HRV""" diff --git a/src/pysaleryd/skeleton.py b/src/pysaleryd/skeleton.py index c56f28a..15d8aef 100644 --- a/src/pysaleryd/skeleton.py +++ b/src/pysaleryd/skeleton.py @@ -60,7 +60,12 @@ def parse_args(args): version=f"pysaleryd {__version__}", ) parser.add_argument( - "--port", dest="port", help="port number", type=int, metavar="INT", required=True + "--port", + dest="port", + help="port number", + type=int, + metavar="INT", + required=True, ) parser.add_argument("--host", dest="host", help="host", type=str, required=True) diff --git a/src/pysaleryd/utils.py b/src/pysaleryd/utils.py index 01ead74..b90f431 100644 --- a/src/pysaleryd/utils.py +++ b/src/pysaleryd/utils.py @@ -1,4 +1,3 @@ - import logging _LOGGER: logging.Logger = logging.getLogger(__package__) @@ -7,9 +6,10 @@ class ParseError(BaseException): pass -class Parser(): + +class Parser: def to_str(self, key, value): - return (f"#{key}:{value}\r") + return f"#{key}:{value}\r" def from_str(self, msg: str): """parse message""" diff --git a/src/pysaleryd/websocket.py b/src/pysaleryd/websocket.py index c2d5e9d..68891db 100644 --- a/src/pysaleryd/websocket.py +++ b/src/pysaleryd/websocket.py @@ -153,7 +153,7 @@ def retry(self) -> None: if self._state == State.RETRYING and self._previous_state == State.RUNNING: _LOGGER.info( - "Reconnecting to websocket (%s) failed, scheduling retry at an interval of %i seconds", + "Reconnecting to websocket (%s) failed, scheduling retry at an interval of %i seconds", # noqa: E501 self.host, RETRY_TIMER, ) diff --git a/tests/conftest.py b/tests/conftest.py index e9eb100..07b5334 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,6 +8,7 @@ import pytest_asyncio from aiohttp import web + @pytest.fixture(scope="session") def event_loop(): """Overrides pytest default function scoped event loop""" @@ -16,11 +17,12 @@ def event_loop(): yield loop loop.close() + @pytest_asyncio.fixture(scope="session", autouse=True) async def server(): """Websocket test server""" - async def websocket_handler(request): + async def websocket_handler(request): ws = web.WebSocketResponse() await ws.prepare(request) @@ -32,13 +34,9 @@ async def websocket_handler(request): return ws app = web.Application() - app.add_routes([web.get('/', websocket_handler)]) + app.add_routes([web.get("/", websocket_handler)]) runner = web.AppRunner(app) await runner.setup() - site = web.TCPSite(runner, 'localhost', 3001) + site = web.TCPSite(runner, "localhost", 3001) await site.start() return site - - - - diff --git a/tests/test_client.py b/tests/test_client.py index d7d47a4..34f9d08 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,6 +1,6 @@ import asyncio + import aiohttp -import logging import pytest import pytest_asyncio @@ -10,6 +10,7 @@ __copyright__ = "Björn Dalfors" __license__ = "MIT" + @pytest_asyncio.fixture async def hrv_client(): """HRV Client""" @@ -17,6 +18,7 @@ async def hrv_client(): async with Client("0.0.0.0", 3001, session) as client: yield client + @pytest.mark.asyncio async def test_client_connect(hrv_client: Client): """client tests""" @@ -25,34 +27,38 @@ async def test_client_connect(hrv_client: Client): @pytest.mark.asyncio async def test_client_connect_unsresponsive(): - async with aiohttp.ClientSession() as session: + async with aiohttp.ClientSession() as session: client = Client("0.0.0.0", 3002, session) try: await client.connect() - except: + except Exception: # noqa: W0718 pass await asyncio.sleep(10) assert client.state == State.STOPPED + @pytest.mark.asyncio async def test_handler(hrv_client: Client, mocker): """Test handler callback""" handler = mocker.Mock() + def broken_handler(data): raise Exception() - + hrv_client.add_handler(broken_handler) hrv_client.add_handler(handler) await asyncio.sleep(3) handler.assert_called() + @pytest.mark.asyncio async def test_get_data(hrv_client: Client, mocker): """Test get data""" await asyncio.sleep(1) assert isinstance(hrv_client.data, dict) + @pytest.mark.asyncio async def test_reconnect(hrv_client: Client, mocker): """Test reconnect""" @@ -60,11 +66,13 @@ async def test_reconnect(hrv_client: Client, mocker): await asyncio.sleep(1) assert hrv_client.state == State.RUNNING + @pytest.mark.asyncio async def test_send_command(hrv_client: Client, mocker): """Test send command""" await hrv_client.send_command("MF", "0") + @pytest.mark.asyncio async def test_disconnect(hrv_client: Client, mocker): """Test send command""" @@ -72,4 +80,4 @@ async def test_disconnect(hrv_client: Client, mocker): await asyncio.sleep(2) assert hrv_client.state == State.STOPPED await asyncio.sleep(2) - assert hrv_client._socket._ws.closed \ No newline at end of file + assert hrv_client._socket._ws.closed diff --git a/tests/test_skeleton.py b/tests/test_skeleton.py index 7013661..65ad50e 100644 --- a/tests/test_skeleton.py +++ b/tests/test_skeleton.py @@ -1,4 +1,3 @@ - from pysaleryd.skeleton import main __author__ = "Björn Dalfors" @@ -14,6 +13,18 @@ def test_main(capsys): captured = capsys.readouterr() assert not captured.err - main(["--host", "192.168.1.151", "--port", "3001", "--send", "--key", "MF", "--data", "0"]) + main( + [ + "--host", + "192.168.1.151", + "--port", + "3001", + "--send", + "--key", + "MF", + "--data", + "0", + ] + ) captured = capsys.readouterr() assert not captured.err diff --git a/tests/test_utils.py b/tests/test_utils.py index b849ff4..59281fb 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,7 +1,8 @@ import logging + import pytest -from pysaleryd.utils import Parser, ParseError +from pysaleryd.utils import ParseError, Parser __author__ = "Björn Dalfors" __copyright__ = "Björn Dalfors" @@ -10,10 +11,12 @@ _LOGGER = logging.getLogger(__name__) + @pytest.fixture def parser() -> Parser: return Parser() + def test_parse_list_from_str(parser: Parser): (key, value) = parser.from_str("#MF: 1+ 0+ 2\r") assert key == "MF" @@ -22,6 +25,7 @@ def test_parse_list_from_str(parser: Parser): assert value[1] == 0 assert value[2] == 2 + def test_parse_int_from_list_str(parser: Parser): (key, value) = parser.from_str("#MF: 1+ 0+ 2+ 30\r") assert key == "MF" @@ -31,12 +35,14 @@ def test_parse_int_from_list_str(parser: Parser): assert value[2] == 2 assert value[3] == 30 + def test_parse_int_from_str(parser: Parser): (key, value) = parser.from_str("#*XX:0\r") assert key == "*XX" assert isinstance(value, int) assert value == 0 + def test_parse_str_from_str(parser: Parser): (key, value) = parser.from_str("#*XX: xxx\r") assert key == "*XX" @@ -48,6 +54,7 @@ def test_parse_str_from_str(parser: Parser): assert isinstance(value, str) assert value == "1.x.1" + def test_parse_error(parser: Parser): did_throw = False try: diff --git a/tox.ini b/tox.ini index 9ab43da..69f8159 100644 --- a/tox.ini +++ b/tox.ini @@ -61,7 +61,6 @@ description = linkcheck: Check for broken links in the documentation passenv = SETUPTOOLS_* - SPHINX_GITHUB_CHANGELOG_TOKEN setenv = DOCSDIR = {toxinidir}/docs BUILDDIR = {toxinidir}/docs/_build