diff --git a/.prospector.yaml b/.prospector.yaml new file mode 100644 index 0000000..9ec98d4 --- /dev/null +++ b/.prospector.yaml @@ -0,0 +1,51 @@ +strictness: veryhigh +test-warnings: false +doc-warnings: false +member-warnings: true +inherits: + - default +autodetect: false +max-line-length: 88 +ignore-paths: + - doc + +bandit: + run: true + +dodgy: + run: true + +frosted: + run: false + +mccabe: + run: true + options: + max-complexity: 5 + +pycodestyle: + run: true + disable: + - E203 + +pydocstyle: + run: false + +pyflakes: + run: true + +pylint: + run: true + disable: + - W1203 + +pyroma: + run: false + +mypy: + run: true + options: + strict: true + +vulture: + run: false diff --git a/README.md b/README.md new file mode 100644 index 0000000..1048b03 --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +# pytest-reserial + +Pytest plugin for recording and replaying serial port traffic during tests. + +## Installation + +`pip install pytest-reserial` + +## Usage + +1. Write your tests as if they would run with the device connected. +2. When your tests pass with the device connected, run `pytest --record` to record serial traffic + from the passing tests. +3. Now you can disconnect the device and run your tests with `pytest --replay`. + +A simple example: + +```python +# my_serial_app.py +from serial import Serial + +def my_serial_app(): + with Serial(port=/dev/ttyUSB0) as s: + # When we send '1' to the device, it responds with '2'. + s.write(b"\x01") + return s.read() + +# Just use the 'reserial' fixture! +def test_my_serial_app(reserial): + assert my_serial_app() == b"\x02" +``` + +Next: + +1. Connect the device. +2. Run `pytest my_serial_app.py` and verify that the test passes with the device connected. +3. Run `pytest --record my_serial_app.py`. The test will run again, and the traffic will be recorded. +4. Disconnect the device. +5. Run `pytest --replay my_serial_app.py`. The test will pass! + +The logged traffic will be stored in JSON files in the same directory as your test files, and will have the same names as the test files except with a .json extension instead of .py. For example, if your project layout is: + +``` +├── src +│ ├── myproject +│ │ ├── ... +├── tests +│ ├── test_myproject.py +``` + +Then after running `pytest --record`, the test/ directory will contain a new file, test_myproject.json, containing the recorded serial traffic from the tests. + +## Why + +Have you ever tried to write tests for a program that talks to an external device over serial (like an Arduino or something)? You probably wrote the tests assuming that the device is question would always be connected when running the tests, right? And later you got bit by one or more of the pitfalls of that approach: + +- You wanted to run the tests when the device wasn't connected. Perhaps you were travelling, or + someone had borrowed it. Whatever the reason, you found yourself unable to run the tests, and + therefore unable to continue development, until you could connect the device again. +- You made a change to your program, and one of your tests failed. So far so good, right? That's + what tests are for, after all. Only, you can't figure out why the test is failing. You spend + several hours trying to fix it, but eventually give up and revert your changes. + But the test still fails. + So you try another device, and sure enough, now it passes. Turns out, what you thought was a + problem with your code was actually a hardware failure. +- Some of the tests depend on the device being in a certain state, and some of the tests depend on + the device being in *another* state. So you can't run the entire test suite all at once, instad + being forced to stop it halfway through and mess with a bunch of wires and buttons before you can + run the rest of the tests. + +And then you asked yourself, 'How do I write my tests so that the device doesn't need to be connected?' You may have gone down the rabbit hole that is mocking, and then replaced large parts of pyserial with mock interfaces, and ultimately ended up with a test suite that was significantly more complex than the program it was meant to test. + +With pytest-reserial, you don't have to worry about any of that. Just write your tests as if the device is always connected. Then, simply use the `reserial` fixture to record the serial traffic from passing tests, and replay it when the device isn't connected. + +## Requirements + +pytest-reserial depends on pytest and pyserial. + +## Copyright + +MIT License, (C) 2022 Alexander Bessman diff --git a/README.rst b/README.rst deleted file mode 100644 index 62a2f6c..0000000 --- a/README.rst +++ /dev/null @@ -1,133 +0,0 @@ -=============== -pytest-reserial -=============== - -.. image:: https://img.shields.io/pypi/v/pytest-reserial.svg - :target: https://pypi.org/project/pytest-reserial - :alt: PyPI version - -.. image:: https://img.shields.io/pypi/pyversions/pytest-reserial.svg - :target: https://pypi.org/project/pytest-reserial - :alt: Python versions - -.. image:: https://ci.appveyor.com/api/projects/status/github/bessman/pytest-reserial?branch=master - :target: https://ci.appveyor.com/project/bessman/pytest-reserial/branch/master - :alt: See Build Status on AppVeyor - -Record and replay serial port traffic with pytest. - -Features --------- - -pytest-reserial adds a pytest fixture, 'reserial'. With this fixture, tests which -communicate with external devices over a serial port can record any traffic generated -during the test. After serial traffic for a certain test has been recorded, that traffic -can be replayed when said test is running, eliminating the need for the external devices -to be connected. - -Recording ---------- - -To record serial port traffic, call pytest with the '--record' option:: - - $ pytest --record - -Traffic recordings are stored as JSON files. One file is created for each test. The log -files are stored in a directory structure which matches the layout of the test files, -like so:: - - test_one.py - def test_a_thing(): - ... - def test_other_thing(): - ... - test_two.py - def test_third_thing() - ... - -Results in:: - - / - test_one/ - test_a_thing.json - test_other_thing.json - test_two/ - test_third_thing.json - -Where defaults to /traffic_logs, where is the location of -the test scripts. The base directory for storing log files can be changed with the -'--logdir' option:: - - $ pytest --record --logdir=mylogdir - -Result:: - - mylogdir/ - test_one/ - test_a_thing.json - test_other_thing.json - test_two/ - test_third_thing.json - -Replaying ---------- -To replay recorded serial traffic, simply pass the '--replay' option:: - - $ pytest --replay - -If the log directory is not the default (/traffic_logs), you must also pass -the location of the recordings with the '--logdir' option:: - - $ pytest --replay --logdir=mylogdir - -Requirements ------------- - -'reserial' depends on 'pyserial'. - - -Installation ------------- - -You can install "pytest-reserial" via `pip`_ from `PyPI`_:: - - $ pip install pytest-reserial - - -Usage ------ - -* TODO - -Contributing ------------- -Contributions are very welcome. Tests can be run with `tox`_, please ensure -the coverage at least stays the same before you submit a pull request. - -License -------- - -Distributed under the terms of the `MIT`_ license, "pytest-reserial" is free and open source software. - - -Issues ------- - -If you encounter any problems, please `file an issue`_ along with a detailed description. - -.. _`Cookiecutter`: https://github.com/audreyr/cookiecutter -.. _`@hackebrot`: https://github.com/hackebrot -.. _`MIT`: http://opensource.org/licenses/MIT -.. _`BSD-3`: http://opensource.org/licenses/BSD-3-Clause -.. _`GNU GPL v3.0`: http://www.gnu.org/licenses/gpl-3.0.txt -.. _`Apache Software License 2.0`: http://www.apache.org/licenses/LICENSE-2.0 -.. _`cookiecutter-pytest-plugin`: https://github.com/pytest-dev/cookiecutter-pytest-plugin -.. _`file an issue`: https://github.com/bessman/pytest-reserial/issues -.. _`pytest`: https://github.com/pytest-dev/pytest -.. _`tox`: https://tox.readthedocs.io/en/latest/ -.. _`pip`: https://pypi.org/project/pip/ -.. _`PyPI`: https://pypi.org/project - ----- - -This `pytest`_ plugin was generated with `Cookiecutter`_ along with `@hackebrot`_'s `cookiecutter-pytest-plugin`_ template. diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index 2d87ff4..0000000 --- a/docs/Makefile +++ /dev/null @@ -1,192 +0,0 @@ -# Makefile for Sphinx documentation -# - -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = sphinx-build -PAPER = -BUILDDIR = _build - -# User-friendly check for sphinx-build -ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) -$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) -endif - -# Internal variables. -PAPEROPT_a4 = -D latex_paper_size=a4 -PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . -# the i18n builder cannot share the environment and doctrees with the others -I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . - -.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext - -help: - @echo "Please use \`make ' where is one of" - @echo " html to make standalone HTML files" - @echo " dirhtml to make HTML files named index.html in directories" - @echo " singlehtml to make a single large HTML file" - @echo " pickle to make pickle files" - @echo " json to make JSON files" - @echo " htmlhelp to make HTML files and a HTML help project" - @echo " qthelp to make HTML files and a qthelp project" - @echo " applehelp to make an Apple Help Book" - @echo " devhelp to make HTML files and a Devhelp project" - @echo " epub to make an epub" - @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" - @echo " latexpdf to make LaTeX files and run them through pdflatex" - @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" - @echo " text to make text files" - @echo " man to make manual pages" - @echo " texinfo to make Texinfo files" - @echo " info to make Texinfo files and run them through makeinfo" - @echo " gettext to make PO message catalogs" - @echo " changes to make an overview of all changed/added/deprecated items" - @echo " xml to make Docutils-native XML files" - @echo " pseudoxml to make pseudoxml-XML files for display purposes" - @echo " linkcheck to check all external links for integrity" - @echo " doctest to run all doctests embedded in the documentation (if enabled)" - @echo " coverage to run coverage check of the documentation (if enabled)" - -clean: - rm -rf $(BUILDDIR)/* - -html: - $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." - -dirhtml: - $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." - -singlehtml: - $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml - @echo - @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." - -pickle: - $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle - @echo - @echo "Build finished; now you can process the pickle files." - -json: - $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json - @echo - @echo "Build finished; now you can process the JSON files." - -htmlhelp: - $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp - @echo - @echo "Build finished; now you can run HTML Help Workshop with the" \ - ".hhp project file in $(BUILDDIR)/htmlhelp." - -qthelp: - $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp - @echo - @echo "Build finished; now you can run "qcollectiongenerator" with the" \ - ".qhcp project file in $(BUILDDIR)/qthelp, like this:" - @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/pytest-cookiecutterplugin_name.qhcp" - @echo "To view the help file:" - @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/pytest-cookiecutterplugin_name.qhc" - -applehelp: - $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp - @echo - @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." - @echo "N.B. You won't be able to view it unless you put it in" \ - "~/Library/Documentation/Help or install it in your application" \ - "bundle." - -devhelp: - $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp - @echo - @echo "Build finished." - @echo "To view the help file:" - @echo "# mkdir -p $$HOME/.local/share/devhelp/pytest-cookiecutterplugin_name" - @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/pytest-cookiecutterplugin_name" - @echo "# devhelp" - -epub: - $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub - @echo - @echo "Build finished. The epub file is in $(BUILDDIR)/epub." - -latex: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo - @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." - @echo "Run \`make' in that directory to run these through (pdf)latex" \ - "(use \`make latexpdf' here to do that automatically)." - -latexpdf: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through pdflatex..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -latexpdfja: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through platex and dvipdfmx..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -text: - $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text - @echo - @echo "Build finished. The text files are in $(BUILDDIR)/text." - -man: - $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man - @echo - @echo "Build finished. The manual pages are in $(BUILDDIR)/man." - -texinfo: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo - @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." - @echo "Run \`make' in that directory to run these through makeinfo" \ - "(use \`make info' here to do that automatically)." - -info: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo "Running Texinfo files through makeinfo..." - make -C $(BUILDDIR)/texinfo info - @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." - -gettext: - $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale - @echo - @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." - -changes: - $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes - @echo - @echo "The overview file is in $(BUILDDIR)/changes." - -linkcheck: - $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck - @echo - @echo "Link check complete; look for any errors in the above output " \ - "or in $(BUILDDIR)/linkcheck/output.txt." - -doctest: - $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest - @echo "Testing of doctests in the sources finished, look at the " \ - "results in $(BUILDDIR)/doctest/output.txt." - -coverage: - $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage - @echo "Testing of coverage in the sources finished, look at the " \ - "results in $(BUILDDIR)/coverage/python.txt." - -xml: - $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml - @echo - @echo "Build finished. The XML files are in $(BUILDDIR)/xml." - -pseudoxml: - $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml - @echo - @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." diff --git a/docs/conf.py b/docs/conf.py deleted file mode 100644 index 7afee33..0000000 --- a/docs/conf.py +++ /dev/null @@ -1,286 +0,0 @@ -# -*- coding: utf-8 -*- -# -# pytest-reserial documentation build configuration file, created by -# sphinx-quickstart on Thu Oct 1 00:43:18 2015. -# -# This file is execfile()d with the current directory set to its -# containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. - -import sys -import os -import shlex - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.insert(0, os.path.abspath('.')) - -# -- General configuration ------------------------------------------------ - -# If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. -extensions = [ - 'sphinx.ext.ifconfig', -] - -# Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] - -# The suffix(es) of source filenames. -# You can specify multiple suffix as a list of string: -# source_suffix = ['.rst', '.md'] -source_suffix = '.rst' - -# The encoding of source files. -#source_encoding = 'utf-8-sig' - -# The master toctree document. -master_doc = 'index' - -# General information about the project. -project = 'pytest-reserial' -copyright = '2015, Alexander Bessman' -author = 'Alexander Bessman' - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# -# The short X.Y version. -version = '0.1.0' -# The full version, including alpha/beta/rc tags. -release = '0.1.0' - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -# -# This is also used if you do content translation via gettext catalogs. -# Usually you set "language" from the command line for these cases. -language = None - -# There are two options for replacing |today|: either, you set today to some -# non-false value, then it is used: -#today = '' -# Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -exclude_patterns = ['_build'] - -# The reST default role (used for this markup: `text`) to use for all -# documents. -#default_role = None - -# If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True - -# If true, the current module name will be prepended to all description -# unit titles (such as .. function::). -#add_module_names = True - -# If true, sectionauthor and moduleauthor directives will be shown in the -# output. They are ignored by default. -#show_authors = False - -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' - -# A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] - -# If true, keep warnings as "system message" paragraphs in the built documents. -#keep_warnings = False - -# If true, `todo` and `todoList` produce output, else they produce nothing. -todo_include_todos = False - - -# -- Options for HTML output ---------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -html_theme = 'alabaster' - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -#html_theme_options = {} - -# Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] - -# The name for this set of Sphinx documents. If None, it defaults to -# " v documentation". -#html_title = None - -# A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None - -# The name of an image file (relative to this directory) to place at the top -# of the sidebar. -#html_logo = None - -# The name of an image file (within the static path) to use as favicon of the -# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 -# pixels large. -#html_favicon = None - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] - -# Add any extra paths that contain custom files (such as robots.txt or -# .htaccess) here, relative to this directory. These files are copied -# directly to the root of the documentation. -#html_extra_path = [] - -# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, -# using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' - -# If true, SmartyPants will be used to convert quotes and dashes to -# typographically correct entities. -#html_use_smartypants = True - -# Custom sidebar templates, maps document names to template names. -#html_sidebars = {} - -# Additional templates that should be rendered to pages, maps page names to -# template names. -#html_additional_pages = {} - -# If false, no module index is generated. -#html_domain_indices = True - -# If false, no index is generated. -#html_use_index = True - -# If true, the index is split into individual pages for each letter. -#html_split_index = False - -# If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True - -# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True - -# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True - -# If true, an OpenSearch description file will be output, and all pages will -# contain a tag referring to it. The value of this option must be the -# base URL from which the finished HTML is served. -#html_use_opensearch = '' - -# This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None - -# Language to be used for generating the HTML full-text search index. -# Sphinx supports the following languages: -# 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' -# 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' -#html_search_language = 'en' - -# A dictionary with options for the search language support, empty by default. -# Now only 'ja' uses this config value -#html_search_options = {'type': 'default'} - -# The name of a javascript file (relative to the configuration directory) that -# implements a search results scorer. If empty, the default will be used. -#html_search_scorer = 'scorer.js' - -# Output file base name for HTML help builder. -htmlhelp_basename = 'pytest-cookiecutterplugin_namedoc' - -# -- Options for LaTeX output --------------------------------------------- - -latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', - -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', - -# Additional stuff for the LaTeX preamble. -#'preamble': '', - -# Latex figure (float) alignment -#'figure_align': 'htbp', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, -# author, documentclass [howto, manual, or own class]). -latex_documents = [ - (master_doc, 'pytest-cookiecutterplugin_name.tex', 'pytest-\\{\\{cookiecutter.plugin\\_name\\}\\} Documentation', - '\\{\\{cookiecutter.full\\_name\\}\\}', 'manual'), -] - -# The name of an image file (relative to this directory) to place at the top of -# the title page. -#latex_logo = None - -# For "manual" documents, if this is true, then toplevel headings are parts, -# not chapters. -#latex_use_parts = False - -# If true, show page references after internal links. -#latex_show_pagerefs = False - -# If true, show URL addresses after external links. -#latex_show_urls = False - -# Documents to append as an appendix to all manuals. -#latex_appendices = [] - -# If false, no module index is generated. -#latex_domain_indices = True - - -# -- Options for manual page output --------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [ - (master_doc, 'pytest-cookiecutterplugin_name', 'pytest-reserial Documentation', - [author], 1) -] - -# If true, show URL addresses after external links. -#man_show_urls = False - - -# -- Options for Texinfo output ------------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - (master_doc, 'pytest-cookiecutterplugin_name', 'pytest-reserial Documentation', - author, 'pytest-cookiecutterplugin_name', 'One line description of project.', - 'Miscellaneous'), -] - -# Documents to append as an appendix to all manuals. -#texinfo_appendices = [] - -# If false, no module index is generated. -#texinfo_domain_indices = True - -# How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' - -# If true, do not generate a @detailmenu in the "Top" node's menu. -#texinfo_no_detailmenu = False diff --git a/docs/index.rst b/docs/index.rst deleted file mode 100644 index 79d0485..0000000 --- a/docs/index.rst +++ /dev/null @@ -1,22 +0,0 @@ -.. pytest-reserial documentation master file, created by - sphinx-quickstart on Thu Oct 1 00:43:18 2015. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. - -Welcome to pytest-reserial's documentation! -=============================================================== - -Contents: - -.. toctree:: - :maxdepth: 2 - - - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` - diff --git a/docs/make.bat b/docs/make.bat deleted file mode 100644 index 28beda1..0000000 --- a/docs/make.bat +++ /dev/null @@ -1,263 +0,0 @@ -@ECHO OFF - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set BUILDDIR=_build -set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . -set I18NSPHINXOPTS=%SPHINXOPTS% . -if NOT "%PAPER%" == "" ( - set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% - set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% -) - -if "%1" == "" goto help - -if "%1" == "help" ( - :help - echo.Please use `make ^` where ^ is one of - echo. html to make standalone HTML files - echo. dirhtml to make HTML files named index.html in directories - echo. singlehtml to make a single large HTML file - echo. pickle to make pickle files - echo. json to make JSON files - echo. htmlhelp to make HTML files and a HTML help project - echo. qthelp to make HTML files and a qthelp project - echo. devhelp to make HTML files and a Devhelp project - echo. epub to make an epub - echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter - echo. text to make text files - echo. man to make manual pages - echo. texinfo to make Texinfo files - echo. gettext to make PO message catalogs - echo. changes to make an overview over all changed/added/deprecated items - echo. xml to make Docutils-native XML files - echo. pseudoxml to make pseudoxml-XML files for display purposes - echo. linkcheck to check all external links for integrity - echo. doctest to run all doctests embedded in the documentation if enabled - echo. coverage to run coverage check of the documentation if enabled - goto end -) - -if "%1" == "clean" ( - for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i - del /q /s %BUILDDIR%\* - goto end -) - - -REM Check if sphinx-build is available and fallback to Python version if any -%SPHINXBUILD% 2> nul -if errorlevel 9009 goto sphinx_python -goto sphinx_ok - -:sphinx_python - -set SPHINXBUILD=python -m sphinx.__init__ -%SPHINXBUILD% 2> nul -if errorlevel 9009 ( - echo. - echo.The 'sphinx-build' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-build' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.http://sphinx-doc.org/ - exit /b 1 -) - -:sphinx_ok - - -if "%1" == "html" ( - %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/html. - goto end -) - -if "%1" == "dirhtml" ( - %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. - goto end -) - -if "%1" == "singlehtml" ( - %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. - goto end -) - -if "%1" == "pickle" ( - %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can process the pickle files. - goto end -) - -if "%1" == "json" ( - %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can process the JSON files. - goto end -) - -if "%1" == "htmlhelp" ( - %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can run HTML Help Workshop with the ^ -.hhp project file in %BUILDDIR%/htmlhelp. - goto end -) - -if "%1" == "qthelp" ( - %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can run "qcollectiongenerator" with the ^ -.qhcp project file in %BUILDDIR%/qthelp, like this: - echo.^> qcollectiongenerator %BUILDDIR%\qthelp\pytest-cookiecutterplugin_name.qhcp - echo.To view the help file: - echo.^> assistant -collectionFile %BUILDDIR%\qthelp\pytest-cookiecutterplugin_name.ghc - goto end -) - -if "%1" == "devhelp" ( - %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. - goto end -) - -if "%1" == "epub" ( - %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The epub file is in %BUILDDIR%/epub. - goto end -) - -if "%1" == "latex" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "latexpdf" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - cd %BUILDDIR%/latex - make all-pdf - cd %~dp0 - echo. - echo.Build finished; the PDF files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "latexpdfja" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - cd %BUILDDIR%/latex - make all-pdf-ja - cd %~dp0 - echo. - echo.Build finished; the PDF files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "text" ( - %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The text files are in %BUILDDIR%/text. - goto end -) - -if "%1" == "man" ( - %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The manual pages are in %BUILDDIR%/man. - goto end -) - -if "%1" == "texinfo" ( - %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. - goto end -) - -if "%1" == "gettext" ( - %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The message catalogs are in %BUILDDIR%/locale. - goto end -) - -if "%1" == "changes" ( - %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes - if errorlevel 1 exit /b 1 - echo. - echo.The overview file is in %BUILDDIR%/changes. - goto end -) - -if "%1" == "linkcheck" ( - %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck - if errorlevel 1 exit /b 1 - echo. - echo.Link check complete; look for any errors in the above output ^ -or in %BUILDDIR%/linkcheck/output.txt. - goto end -) - -if "%1" == "doctest" ( - %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest - if errorlevel 1 exit /b 1 - echo. - echo.Testing of doctests in the sources finished, look at the ^ -results in %BUILDDIR%/doctest/output.txt. - goto end -) - -if "%1" == "coverage" ( - %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage - if errorlevel 1 exit /b 1 - echo. - echo.Testing of coverage in the sources finished, look at the ^ -results in %BUILDDIR%/coverage/python.txt. - goto end -) - -if "%1" == "xml" ( - %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The XML files are in %BUILDDIR%/xml. - goto end -) - -if "%1" == "pseudoxml" ( - %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. - goto end -) - -:end diff --git a/pyproject.toml b/pyproject.toml index bc904e0..b43ca43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,12 @@ [build-system] -requires = ["flit_core >=3.2,<4"] +requires = ["flit_core >=3.8,<4"] build-backend = "flit_core.buildapi" [project] -name = "pytest_reserial" +name = "pytest-reserial" authors = [{name = "Alexander Bessman", email = "alexander.bessman@gmail.com"}] dynamic = ["version", "description"] -readme = "README.rst" +readme = "README.md" requires-python = ">=3.7" license = {file = "LICENSE"} dependencies = [ @@ -26,10 +26,68 @@ keywords = [ "serial", "testing", "logging", + "mocking", + "patching", + "stubbing", + "record", + "replay", ] [project.urls] Home = "https://github.com/bessman/pytest-reserial" [project.entry-points."pytest11"] -reserial = "pytest_reserial.reserial" \ No newline at end of file +reserial = "pytest_reserial.reserial" + +[tool.tox] +legacy_tox_ini = """ +[tox] +isolated_build = true +envlist = + isort + black + prospector + pydocstyle + test + report + +[testenv:isort] +deps = isort +commands = isort --check-only . + +[testenv:black] +deps = black +commands = black --check . + +[testenv:prospector] +deps = + prospector + mypy + bandit +commands = prospector . + +[testenv:pydocstyle] +deps = pydocstyle +commands = pydocstyle --convention=numpy src/ + +[testenv:test] +deps = + pytest + coverage +commands = coverage run --source=pytest_reserial -m pytest + +[testenv:report] +deps = coverage +commands = coverage report +depends = test + +[gh-actions] +python = + 3.7: test + 3.8: test + 3.9: test + 3.10: black, prospector, test, report +""" + +[tool.isort] +profile = "black" diff --git a/src/pytest_reserial/__init__.py b/src/pytest_reserial/__init__.py index 5b56991..7077fcc 100644 --- a/src/pytest_reserial/__init__.py +++ b/src/pytest_reserial/__init__.py @@ -1,3 +1,3 @@ -"Pytest fixture for recording and replaying serial port traffic." +"""Pytest fixture for recording and replaying serial port traffic.""" -__version__ = "0.1.0" +__version__ = "0.2.0" diff --git a/src/pytest_reserial/reserial.py b/src/pytest_reserial/reserial.py index e51c918..78df661 100644 --- a/src/pytest_reserial/reserial.py +++ b/src/pytest_reserial/reserial.py @@ -1,93 +1,298 @@ +"""Record or replay serial traffic when running tests.""" + import json +from enum import IntEnum from pathlib import Path +from typing import Callable, Dict, Generator, List, Tuple import pytest -from serial import Serial +from serial import Serial # type: ignore[import] + +def pytest_addoption(parser: pytest.Parser) -> None: # noqa: D103 + group = parser.getgroup("reserial") + group.addoption( + "--record", action="store_true", default=False, help="Record serial traffic." + ) + group.addoption( + "--replay", action="store_true", default=False, help="Replay serial traffic." + ) -@pytest.fixture(scope="module") -def default_logdir(request): - return Path(request.fspath).parent / "traffic_logs" +class Mode(IntEnum): + """Mode of operation, selected by the 'replay' and 'record' flags to pytest.""" -def pytest_addoption(parser): - parser.addoption("--record", action="store_true", default=False) - parser.addoption("--replay", action="store_true", default=False) - parser.addoption("--logdir", action="store", default=None) + DONT_PATCH = 0 + REPLAY = 1 + RECORD = 2 + INVALID = 3 @pytest.fixture -def reserial(monkeypatch, request, default_logdir): +def reserial( + monkeypatch: pytest.MonkeyPatch, + request: pytest.FixtureRequest, +) -> Generator[None, None, None]: + """Record or replay serial traffic. + + Raises + ------ + ValueError + If less data than expected was read or written during replay. + """ record = request.config.getoption("--record") replay = request.config.getoption("--replay") - logdir = request.config.getoption("--logdir") - logdir = logdir if logdir else default_logdir - logdir = Path(logdir) / Path(request.fspath).stem - logname = request.node.name + ".json" - logpath = logdir / logname - - if record: - TRAFFIC_LOG = {"tx": [], "rx": []} - elif replay: - with open(logpath, "r") as log: - TRAFFIC_LOG = json.load(log) - - def patch_open(self): - self.is_open = True - - def patch_close(self): - self.is_open = False - - monkeypatch.setattr(Serial, "open", patch_open) - monkeypatch.setattr(Serial, "close", patch_close) - else: - # Neither 'record' or 'replay' was requested, so don't patch Serial. - yield + mode = Mode(replay | record << 1) + + logpath = Path(request.path).stem + ".json" + testname = request.node.name + log = get_traffic_log(mode, logpath, testname) + + read_patch, write_patch, open_patch, close_patch = get_patched_methods(mode, log) + monkeypatch.setattr(Serial, "read", read_patch) + monkeypatch.setattr(Serial, "write", write_patch) + monkeypatch.setattr(Serial, "open", open_patch) + monkeypatch.setattr(Serial, "close", close_patch) + + yield + + if mode == Mode.RECORD: + write_log(log, logpath, testname) return - real_write = Serial.write - real_read = Serial.read + if log["rx"] or log["tx"]: + raise ValueError("Not empty") - # Store any outgoing data before writing it to the bus. - def rec_write(self, data): - nonlocal TRAFFIC_LOG - TRAFFIC_LOG["tx"] += list(data) - real_write(self, data) - - # Store any incoming data. - def rec_read(self, size=1): - data = real_read(self, size) - nonlocal TRAFFIC_LOG - TRAFFIC_LOG["rx"] += list(data) - return data - # Check that outgoing data matches recorded data. - def replay_write(self, data): - nonlocal TRAFFIC_LOG - if list(data) == TRAFFIC_LOG["tx"][: len(data)]: - TRAFFIC_LOG["tx"] = TRAFFIC_LOG["tx"][len(data) :] +def get_traffic_log(mode: Mode, logpath: str, testname: str) -> Dict[str, List[int]]: + """Load recorded traffic (replay) or create an empty log (record). + + Parameters + ---------- + mode : Mode + The requested mode of operation, i.e. `REPLAY`, `RECORD`, or `DONT_PATCH`. + logpath: str + The name of the file where recorded traffic is logged. + testname: str + The name of the currently running test, which is used as a key in the log file. + + Returns + ------- + log : dict[str, list[int]] + Dictionary with keys "rx" and "tx", with corresponding lists of received and + transmitted data. If `mode` is `RECORD` or `DONT_PATCH`, the lists are empty. + + Raises + ------ + ValueError + If both '--replay' and '--record' were specified. + """ + if mode == Mode.INVALID: + raise ValueError("Choose one of 'replay' or 'record', not both.") + + log: Dict[str, List[int]] = {"rx": [], "tx": []} + + if mode == Mode.REPLAY: + with open(logpath, "r", encoding="utf-8") as logfile: + logs = json.load(logfile) + log = logs[testname] + + return log + + +def get_patched_methods( + mode: Mode, log: Dict[str, List[int]] +) -> Tuple[ + Callable[[Serial, int], bytes], + Callable[[Serial, bytes], int], + Callable[[Serial], None], + Callable[[Serial], None], +]: + """Return patched read, write, open, and closed methods. + + The methods should be monkeypatched over the corresponding `Serial` methods. + + Parameters + ---------- + mode: Mode + The requested mode of operation, i.e. `REPLAY`, `RECORD`, or `DONT_PATCH`. + log: dict[str, list[int]] + Dictionary holding logged traffic (replay) / where traffic will be logged to + (record). If mode is `DONT_PATCH`, this parameter is ignored. + + Returns + ------- + read_patch: Callable[[Serial, int], bytes] + Monkeypatch this over `Serial.read`. + write_patch: Callable[[Serial, bytes], int] + Monkeypatch this over `Serial.write`. + open_patch: Callable[[Serial], None] + Monkeypatch this over `Serial.open`. + close_patch: Callable[[Serial], None] + Monkeypatch this over `Serial.close`. + """ + if mode == Mode.REPLAY: + return get_replay_methods(log) + if mode == Mode.RECORD: + return get_record_methods(log) + return Serial.read, Serial.write, Serial.open, Serial.close + + +def get_replay_methods( + log: Dict[str, List[int]] +) -> Tuple[ + Callable[[Serial, int], bytes], + Callable[[Serial, bytes], int], + Callable[[Serial], None], + Callable[[Serial], None], +]: + """Return patched read, write, open, and close methods for replaying logged traffic. + + Parameters + ---------- + log: dict[str, list[int]] + Dictionary holding logged traffic. + + Returns + ------- + replay_read: Callable[[Serial, int], bytes] + Reads RX traffic from log file instead of from bus. + replay_write: Callable[[Serial, bytes], int] + Compares written data with logged TX traffic instead of writing to bus. + replay_open: Callable[[Serial], None] + Sets `Serial.is_open` to `True`. + replay_close: Callable[[Serial], None] + Sets `Serial.is_open` to `False`. + """ + + def replay_write( + self: Serial, # pylint: disable=unused-argument + data: bytes, + ) -> int: + """Compare TX data to recording instead of writing to the bus. + + Monkeypatch this method over Serial.write to replay traffic. Parameters and + return values are identical to Serial.write. + + Raises + ------ + ValueError + If written data does not match recorded data. + """ + nonlocal log + + if list(data) == log["tx"][: len(data)]: + log["tx"] = log["tx"][len(data) :] else: raise ValueError( "Written data does not match recorded data: " - "f{data} != {TRAFFIC_LOG['tx'][: len(data)]}" + "f{data} != {traffic_log['tx'][: len(data)]}" ) - # Return recorded incoming data. - def replay_read(self, size=1): - nonlocal TRAFFIC_LOG - data = TRAFFIC_LOG["rx"][:size] - TRAFFIC_LOG["rx"] = TRAFFIC_LOG["rx"][size:] + return len(data) + + def replay_read( + self: Serial, # pylint: disable=unused-argument + size: int = 1, + ) -> bytes: + """Replay RX data from recording instead of reading from the bus. + + Monkeypatch this method over Serial.read to replay traffic. Parameters and + return values are identical to Serial.read. + """ + nonlocal log + data = log["rx"][:size] + log["rx"] = log["rx"][size:] return bytes(data) - monkeypatch.setattr(Serial, "write", rec_write if record else replay_write) - monkeypatch.setattr(Serial, "read", rec_read if record else replay_read) + return replay_read, replay_write, replay_open, replay_close - yield - if record: - with open(logpath, "w") as log: - json.dump(TRAFFIC_LOG, log) - return +# The open/close method patches don't need any nonlocals, so they can stay down here. +def replay_open(self: Serial) -> None: + """Pretend that port was opened.""" + self.is_open = True - if TRAFFIC_LOG["rx"] or TRAFFIC_LOG["tx"]: - raise ValueError("Not empty") + +def replay_close(self: Serial) -> None: + """Pretend that port was closed.""" + self.is_open = False + + +def get_record_methods( + log: Dict[str, List[int]] +) -> Tuple[ + Callable[[Serial, int], bytes], + Callable[[Serial, bytes], int], + Callable[[Serial], None], + Callable[[Serial], None], +]: + """Return patched read, write, open, and close methods for recording traffic. + + Parameters + ---------- + log: dict[str, list[int]] + Dictionary where recorded traffic will be logged. + + Returns + ------- + record_read: Callable[[Serial, int], bytes] + Logs RX data read from the bus. + record_write: Callable[[Serial, bytes], int] + Logs TX data before writing it to the bus. + record_open: Callable[[Serial], None] + Does not need to be patched when recording, so this is `Serial.open`. + record_close: Callable[[Serial], None] + Does not need to be patched when recording, so this is `Serial.close`. + """ + real_read = Serial.read + real_write = Serial.write + + def record_write(self: Serial, data: bytes) -> int: + """Record TX data before writing to the bus. + + Monkeypatch this method over Serial.write to record traffic. Parameters and + return values are identical to Serial.write. + """ + nonlocal log + log["tx"] += list(data) + written: int = real_write(self, data) + return written + + def record_read(self: Serial, size: int = 1) -> bytes: + """Record RX data after reading from the bus. + + Monkeypatch this method over Serial.read to record traffic. Parameters and + return values are identical to Serial.read. + """ + data: bytes = real_read(self, size) + nonlocal log + log["rx"] += list(data) + return data + + return record_read, record_write, Serial.open, Serial.close + + +def write_log( + log: Dict[str, List[int]], + logpath: str, + testname: str, +) -> None: + """Write recorded traffic to log file. + + Parameters + ---------- + log: dict[str, list[int]] + Dictionary holding recorded traffic. + logpath: str + The name of the file where recorded traffic is logged. + testname: str + The name of the currently running test, which is used as a key in the log file. + """ + with open(logpath, "w+", encoding="utf-8") as logfile: + try: + logs = json.load(logfile) + except json.decoder.JSONDecodeError: + logs = {} + logs[testname] = log + json.dump(logs, logfile) diff --git a/tests/conftest.py b/tests/conftest.py index bc711e5..694d7d5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1 +1 @@ -pytest_plugins = 'pytester' +pytest_plugins = "pytester" diff --git a/tests/test_reserial.py b/tests/test_reserial.py index ae3f4be..78a5fd1 100644 --- a/tests/test_reserial.py +++ b/tests/test_reserial.py @@ -1,70 +1,101 @@ -def test_bar_fixture(testdir): - """Make sure that pytest accepts our fixture.""" +import json - # create a temporary pytest test module - testdir.makepyfile( - """ - def test_sth(bar): - assert bar == "europython2015" - """ - ) +from serial import Serial - # run pytest with the following cmd args - result = testdir.runpytest("--foo=europython2015", "-v") +TEST_RX = b"\x01" +TEST_TX = b"\x02" +TEST_FILE = f""" + import serial + def test_reserial(reserial): + s = serial.Serial(port="/dev/ttyUSB0") + s.write({TEST_TX}) + assert s.read() == {TEST_RX} + """ +TEST_FILE_BAD_TX = f""" + import serial + def test_reserial(reserial): + s = serial.Serial(port="/dev/ttyUSB0") + s.write({TEST_RX}) + assert s.read() == {TEST_RX} + """ +TEST_JSON = f""" + {{ + "test_reserial": {{ + "tx": {list(TEST_TX)}, + "rx": {list(TEST_RX)} + }} + }} + """ - # fnmatch_lines does an assertion internally - result.stdout.fnmatch_lines( - [ - "*::test_sth PASSED*", - ] - ) - # make sure that that we get a '0' exit code for the testsuite +def test_record(monkeypatch, pytester): + pytester.makepyfile(TEST_FILE) + + def patch_write(self: Serial, data: bytes) -> int: + return len(data) + + def patch_read(self: Serial, size: int = 1) -> bytes: + return TEST_RX + + def patch_open(self: Serial) -> None: + self.is_open = True + + def patch_close(self: Serial) -> None: + self.is_open = False + + monkeypatch.setattr(Serial, "write", patch_write) + monkeypatch.setattr(Serial, "read", patch_read) + monkeypatch.setattr(Serial, "open", patch_open) + monkeypatch.setattr(Serial, "close", patch_close) + result = pytester.runpytest("--record") + + with open("test_record.json", "r") as f: + recording = json.load(f) + + assert recording == json.loads(TEST_JSON) assert result.ret == 0 -def test_help_message(testdir): - result = testdir.runpytest( - "--help", - ) - # fnmatch_lines does an assertion internally - result.stdout.fnmatch_lines( - [ - "reserial:", - '*--foo=DEST_FOO*Set the value for the fixture "bar".', - ] - ) +def test_replay(pytester): + pytester.makefile(".json", test_replay=TEST_JSON) + pytester.makepyfile(TEST_FILE) + result = pytester.runpytest("--replay") + assert result.ret == 0 -def test_hello_ini_setting(testdir): - testdir.makeini( +def test_dont_patch(pytester): + pytester.makepyfile( + """ + from serial import Serial + real_read = Serial.read + def test_something(reserial): + assert Serial.read == real_read """ - [pytest] - HELLO = world - """ ) + result = pytester.runpytest() + assert result.ret == 0 - testdir.makepyfile( - """ - import pytest - @pytest.fixture - def hello(request): - return request.config.getini('HELLO') +def test_invalid_option(pytester): + pytester.makepyfile(TEST_FILE) + result = pytester.runpytest("--replay", "--record") + result.assert_outcomes(errors=1) - def test_hello_world(hello): - assert hello == 'world' - """ - ) - result = testdir.runpytest("-v") +def test_bad_tx(pytester): + pytester.makefile(".json", test_bad_tx=TEST_JSON) + pytester.makepyfile(TEST_FILE_BAD_TX) + result = pytester.runpytest("--replay") + result.assert_outcomes(errors=1, failed=1) - # fnmatch_lines does an assertion internally + +def test_help_message(pytester): + result = pytester.runpytest("--help") result.stdout.fnmatch_lines( [ - "*::test_hello_world PASSED*", + "reserial:", + "*--record * Record serial traffic.", + "*--replay * Replay serial traffic.", ] ) - - # make sure that that we get a '0' exit code for the testsuite assert result.ret == 0 diff --git a/tox.ini b/tox.ini deleted file mode 100644 index a28327a..0000000 --- a/tox.ini +++ /dev/null @@ -1,12 +0,0 @@ -# For more information about tox, see https://tox.readthedocs.io/en/latest/ -[tox] -envlist = py37,py38,py39,py310 - -[testenv] -deps = pytest>=3.0 -commands = pytest {posargs:tests} - -[testenv:flake8] -skip_install = true -deps = flake8 -commands = flake8 tests