diff --git a/08_packages_docs_tests/08a_pkg_doc.ipynb b/08_packages_docs_tests/08a_pkg_doc.ipynb new file mode 100644 index 0000000..9580d98 --- /dev/null +++ b/08_packages_docs_tests/08a_pkg_doc.ipynb @@ -0,0 +1,657 @@ +{ + "cells": [ + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Lecture 8\n", + "\n", + "by **Martin Hronec** \n", + "\n", + "Contents:\n", + "1. [How to structure your projects](#Repository-structure)\n", + "2. [Python packaging](#Packaging)\n", + "3. [Documentation](#Documentation)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "## Repository structure\n", + "\n", + "* “structure” means making clean code whose logic and dependencies are clear as well as how the files and folders are organized in the filesystem\n", + "\n", + "* a repository template:\n", + "\n", + " ```\n", + " README.md\n", + " LICENSE\n", + " setup.py\n", + " requirements.txt\n", + " app/__init__.py\n", + " app/main.py\n", + " app/helpers.py\n", + " docs/conf.py\n", + " docs/index.rst\n", + " tests/test_basic.py\n", + " tests/test_advanced.py\n", + " data/\n", + " .gitignore ```\n", + " \n", + "* `./app/`\n", + " * module package (if module consists of only a single file, it can be placed in the root of your repository\n", + " ( `./sample.py`)\n", + "* `./LICENSE`\n", + " * the full license text and copyright claims\n", + " * you are also free to publish code without a license, but this would prevent many people from potentially using or contributing to your code\n", + " * more on licenses [here](https://choosealicense.com/licenses/)\n", + "* `./setup.py`\n", + " * package and distribution management\n", + " * more in [the next section](#Packaging)\n", + "* `./requirements.txt`\n", + " * a pip requirements file\n", + " * should be placed at the root of the repository\n", + " * should specify the dependencies required to contribute to the project (testing, building, and generating documentation)\n", + "* `./docs/`\n", + " * package reference documentation\n", + " * more in [the documentation section](#Documentation)\n", + "* `./tests/`\n", + "\n", + " * more in [the testing section](#Tests)\n", + "* `./Makefile`\n", + " * for generic management tasks\n", + " * other generic management scrips (e.g. `manage.py`) belong at the root of the repository as well" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Packaging \n", + "\n", + "* why packaging?\n", + " * because we want modular programming\n", + " \n", + "* why modularing (modules)?\n", + " * simplicity\n", + " * maintainability\n", + " * reusability\n", + " * scoping - separate namespace\n", + "\n", + "* functions, modules and packages already offer modularization\n", + "\n", + "* Python is a general-purpose programming language => can be used in many ways\n", + " * scientific computing\n", + " * websites\n", + " * scraping, etc.\n", + " \n", + "* this flexibility is the reason you need to think about:\n", + " * the project's customers/users\n", + " * the environment where the project will run\n", + "\n", + "* not necessary bad idea to think about packaging before starting to code\n", + "* what is a package? ... a collection of:\n", + " * modules \n", + " * documentation\n", + " * tests\n", + " * tools to build and install it, etc. \n", + "\n", + "### Deployment \n", + "* projects (packages) exist to be deployed (installed)\n", + "* before you package anything, ask questions like:\n", + "\n", + " * who are your users? (software (python) developers, business people)\n", + " * where will your software run? (servers, desktops, mobiles)\n", + " * how is your software deployed? (part of the large software stack, individually, etc.)\n", + "* packaging libraries and tools (technical audience) vs. packaging applications (non-technical audience)\n", + "\n", + "### Packaging libraries and tools\n", + "\n", + "* you've probably heard about PyPI, `setup.py` and [wheels](https://pythonwheels.com/) \n", + "\n", + "* **modules**\n", + " * simply a python file - can be distributed \n", + " * care about the right version of Python (and only relies on the standard library)\n", + " * great for sharing simple scripts and snippets (email, StackOverflow, [GitHub gists](https://gist.github.com/)\n", + " * ! this does not scale for projects with multiple files, need additional libraries or specific Python versions\n", + "\n", + "* let's look at what's going on with modules\n", + " * look at the objects defined in example_module.py (below)\n", + " * text (string)\n", + " * f (function)\n", + " * AClass (class)" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "# %load example_module.py\n", + "text = \"modularity is the key\"\n", + "\n", + "def f(arg):\n", + " print(f'This function takes as an argument: {arg}')\n", + "\n", + "class AClass:\n", + " pass" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "* (if example_module.py is in appropriate location) these objects can be imported using `import` call in python\n", + " * (delete them before trying with import)" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "del AClass, f, text" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "ename": "NameError", + "evalue": "name 'f' is not defined", + "output_type": "error", + "traceback": [ + "\u001b[1;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[1;31mNameError\u001b[0m Traceback (most recent call last)", + "Cell \u001b[1;32mIn [3], line 1\u001b[0m\n\u001b[1;32m----> 1\u001b[0m \u001b[43mf\u001b[49m\n", + "\u001b[1;31mNameError\u001b[0m: name 'f' is not defined" + ] + } + ], + "source": [ + "f" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "import example_module" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "['AClass',\n", + " '__builtins__',\n", + " '__cached__',\n", + " '__doc__',\n", + " '__file__',\n", + " '__loader__',\n", + " '__name__',\n", + " '__package__',\n", + " '__spec__',\n", + " 'f',\n", + " 'text']" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "dir(example_module)" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'modularity is the key'" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "example_module.text" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "* what happens when the interpreter executes the above `import` statement? \n", + "* interpreter searches for *example_module.py* in **the module search path** (list of directories ):\n", + " * the current working directory\n", + " * the list of directories contained in the PYTHONPATH environment variable\n", + " * an installation-dependent list of directories configured at the time Python is installed\n", + "* the resulting search path is accessible in the Python variable `sys.path`" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "['c:\\\\Users\\\\Martin Hronec\\\\Projects\\\\phd\\\\teaching\\\\PythonDataIES\\\\08_packages_docs_tests',\n", + " 'c:\\\\Users\\\\Martin Hronec\\\\AppData\\\\Local\\\\Programs\\\\Python\\\\Python310\\\\python310.zip',\n", + " 'c:\\\\Users\\\\Martin Hronec\\\\AppData\\\\Local\\\\Programs\\\\Python\\\\Python310\\\\DLLs',\n", + " 'c:\\\\Users\\\\Martin Hronec\\\\AppData\\\\Local\\\\Programs\\\\Python\\\\Python310\\\\lib',\n", + " 'c:\\\\Users\\\\Martin Hronec\\\\AppData\\\\Local\\\\Programs\\\\Python\\\\Python310',\n", + " '',\n", + " 'C:\\\\Users\\\\Martin Hronec\\\\AppData\\\\Roaming\\\\Python\\\\Python310\\\\site-packages',\n", + " 'C:\\\\Users\\\\Martin Hronec\\\\AppData\\\\Roaming\\\\Python\\\\Python310\\\\site-packages\\\\win32',\n", + " 'C:\\\\Users\\\\Martin Hronec\\\\AppData\\\\Roaming\\\\Python\\\\Python310\\\\site-packages\\\\win32\\\\lib',\n", + " 'C:\\\\Users\\\\Martin Hronec\\\\AppData\\\\Roaming\\\\Python\\\\Python310\\\\site-packages\\\\Pythonwin',\n", + " 'c:\\\\Users\\\\Martin Hronec\\\\AppData\\\\Local\\\\Programs\\\\Python\\\\Python310\\\\lib\\\\site-packages',\n", + " 'c:\\\\users\\\\martin hronec\\\\projects\\\\phd\\\\teaching\\\\dd',\n", + " 'c:\\\\Users\\\\Martin Hronec\\\\AppData\\\\Local\\\\Programs\\\\Python\\\\Python310\\\\lib\\\\site-packages\\\\redata-0.1-py3.10.egg']" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import sys\n", + "sys.path" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "* to ensure that your module is found, you need to do one of the following:\n", + " * put example_module.py in the directory where the input script is located or the current working directory\n", + " * add directory where `example_module.py` is located to PYTHONPATH environment variable \n", + " * put example_module.py anywhere you like and modify `sys.path` at runtime so that it contains that directory (see below)" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "['c:\\\\Users\\\\Martin Hronec\\\\Projects\\\\phd\\\\teaching\\\\PythonDataIES\\\\08_packages_docs_tests',\n", + " 'c:\\\\Users\\\\Martin Hronec\\\\AppData\\\\Local\\\\Programs\\\\Python\\\\Python310\\\\python310.zip',\n", + " 'c:\\\\Users\\\\Martin Hronec\\\\AppData\\\\Local\\\\Programs\\\\Python\\\\Python310\\\\DLLs',\n", + " 'c:\\\\Users\\\\Martin Hronec\\\\AppData\\\\Local\\\\Programs\\\\Python\\\\Python310\\\\lib',\n", + " 'c:\\\\Users\\\\Martin Hronec\\\\AppData\\\\Local\\\\Programs\\\\Python\\\\Python310',\n", + " '',\n", + " 'C:\\\\Users\\\\Martin Hronec\\\\AppData\\\\Roaming\\\\Python\\\\Python310\\\\site-packages',\n", + " 'C:\\\\Users\\\\Martin Hronec\\\\AppData\\\\Roaming\\\\Python\\\\Python310\\\\site-packages\\\\win32',\n", + " 'C:\\\\Users\\\\Martin Hronec\\\\AppData\\\\Roaming\\\\Python\\\\Python310\\\\site-packages\\\\win32\\\\lib',\n", + " 'C:\\\\Users\\\\Martin Hronec\\\\AppData\\\\Roaming\\\\Python\\\\Python310\\\\site-packages\\\\Pythonwin',\n", + " 'c:\\\\Users\\\\Martin Hronec\\\\AppData\\\\Local\\\\Programs\\\\Python\\\\Python310\\\\lib\\\\site-packages',\n", + " 'c:\\\\users\\\\martin hronec\\\\projects\\\\phd\\\\teaching\\\\dd',\n", + " 'c:\\\\Users\\\\Martin Hronec\\\\AppData\\\\Local\\\\Programs\\\\Python\\\\Python310\\\\lib\\\\site-packages\\\\redata-0.1-py3.10.egg',\n", + " 'C:\\\\Users\\\\Martin Hronec\\\\Projects']" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "sys.path.append(r'C:\\Users\\Martin Hronec\\Projects')\n", + "sys.path" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "* once a module has been imported, you can determine the location where it was found with the module's `__file__` attribute" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'c:\\\\Users\\\\Martin Hronec\\\\Projects\\\\phd\\\\teaching\\\\PythonDataIES\\\\08_packages_docs_tests\\\\example_module.py'" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import example_module\n", + "example_module.__file__" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "* possible to do `from import *`\n", + " * this is not recommended (especially in production code)\n", + "* also possible to use aliases\n", + " * `import pandas as pd` - `pd` is alias\n", + "* ! modules are loaded only once per session\n", + " * if you make a change to a module and need to reload it, you need to either restart the interpreter" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Continuing with the distribution options you have ...\n", + "\n", + "* **PACKAGES**\n", + "\n", + " * a \"package\" is essentially a module with other modules (potentially in it)\n", + " * ↑ number of modules => ↑ mess\n", + " * packages allow hierarchical structuring of the module namespace\n", + "\n", + "* package = a directory with an `__init__.py` and any number of other python files or other package directories\n", + " ```\n", + " a_package\n", + " __init__.py\n", + " module_a.py\n", + " a_sub_package\n", + " __init__.py\n", + " module_b.py\n", + " ```\n", + "\n", + "* `__init__.py` can be empty or not (it will be run when the package is imported)\n", + "* example project from the Python Packaging Authority (real thing) [here](https://github.com/pypa/sampleproject)" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### setuptools\n", + "\n", + "* `setup.py` tells setuptools how to package, build and install the package\n" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [], + "source": [ + "# %load setup.py\n", + "from setuptools import setup\n", + "\n", + "setup(\n", + " name='PackageName',\n", + " version='0.1',\n", + " author='YoursTruly',\n", + " author_email='yourstruly@fsv.cuni.cz',\n", + " #packages=['package_name','package_name.test'],\n", + " url='',\n", + " license='LICENSE.txt',\n", + " description='Exemplatory package.',\n", + " #long_description=open('README.md').read(),\n", + " install_requires=[\n", + " \"Django >= 1.1.1\",\n", + " \"pytest\",\n", + " ],)" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "* with a `setup.py` script, setuptools can:\n", + " * build a source distribution `python setup.py sdist`\n", + " * build wheels `./setup.py bdist_wheel` (the wheel package needed)\n", + " * build from source `python setup.py build`\n", + " * install `python setup.py install`\n", + " \n", + "* we can also install in develop/editable mode: `python setup.py develop` or `pip install -e ./`\n", + " * your package is installed, but any changes will immediately take effect\n", + " * no `sys.path` manipulation!\n", + "\n", + "* you can also upload your package to [PyPI](https://pypi.org/)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "* **Quick exercise**: Create a new package\n", + " 1. create the basic package structure\n", + " 2. write a setup.py\n", + " 3. install the package with a `setup.py`\n", + " 4. import it from somewhere else" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "* **Notes**:\n", + " * for larger projects, it is good idea tu use templates, e.g. from [Cookie Cutter](https://cookiecutter.readthedocs.io/en/latest/)\n", + " * quality packaging materials:\n", + " * from the Python Packaging authority [here](https://packaging.python.org/)\n", + " * [practical tutorial](https://python-packaging-tutorial.readthedocs.io/en/latest/setup_py.html)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "* **Discussion**: Is data science different?\n", + " * https://docs.microsoft.com/en-us/azure/machine-learning/team-data-science-process/overview" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Documentation\n", + "\n", + "* why documentation?\n", + " * let's ask [write-the-docs community](https://www.writethedocs.org/guide/writing/beginners-guide-to-docs/)\n", + "\n", + "* write docstrings at minimum:\n", + "\n", + "* example from [sphinx](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html) below\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "def function_with_types_in_docstring(param1, param2):\n", + " \"\"\"Example function with types documented in the docstring.\n", + "\n", + " `PEP 484`_ type annotations are supported. If attribute, parameter, and\n", + " return types are annotated according to `PEP 484`_, they do not need to be\n", + " included in the docstring:\n", + "\n", + " Args:\n", + " param1 (int): The first parameter.\n", + " param2 (str): The second parameter.\n", + "\n", + " Returns:\n", + " bool: The return value. True for success, False otherwise.\n", + "\n", + " .. _PEP 484:\n", + " https://www.python.org/dev/peps/pep-0484/\n", + "\n", + " \"\"\"\n", + "\n", + "\n", + "def function_with_pep484_type_annotations(param1: int, param2: str) -> bool:\n", + " \"\"\"Example function with PEP 484 type annotations.\n", + "\n", + " Args:\n", + " param1: The first parameter.\n", + " param2: The second parameter.\n", + "\n", + " Returns:\n", + " The return value. True for success, False otherwise.\n", + "\n", + " \"\"\"\n", + "\n", + "\n", + "def module_level_function(param1, param2=None, *args, **kwargs):\n", + " \"\"\"This is an example of a module level function.\n", + "\n", + " Function parameters should be documented in the ``Args`` section. The name\n", + " of each parameter is required. The type and description of each parameter\n", + " is optional, but should be included if not obvious.\n", + "\n", + " If \\*args or \\*\\*kwargs are accepted,\n", + " they should be listed as ``*args`` and ``**kwargs``.\n", + "\n", + " The format for a parameter is::\n", + "\n", + " name (type): description\n", + " The description may span multiple lines. Following\n", + " lines should be indented. The \"(type)\" is optional.\n", + "\n", + " Multiple paragraphs are supported in parameter\n", + " descriptions.\n", + "\n", + " Args:\n", + " param1 (int): The first parameter.\n", + " param2 (:obj:`str`, optional): The second parameter. Defaults to None.\n", + " Second line of description should be indented.\n", + " *args: Variable length argument list.\n", + " **kwargs: Arbitrary keyword arguments.\n", + "\n", + " Returns:\n", + " bool: True if successful, False otherwise.\n", + "\n", + " The return type is optional and may be specified at the beginning of\n", + " the ``Returns`` section followed by a colon.\n", + "\n", + " The ``Returns`` section may span multiple lines and paragraphs.\n", + " Following lines should be indented to match the first line.\n", + "\n", + " The ``Returns`` section supports any reStructuredText formatting,\n", + " including literal blocks::\n", + "\n", + " {\n", + " 'param1': param1,\n", + " 'param2': param2\n", + " }\n", + "\n", + " Raises:\n", + " AttributeError: The ``Raises`` section is a list of all exceptions\n", + " that are relevant to the interface.\n", + " ValueError: If `param2` is equal to `param1`.\n", + "\n", + " \"\"\"\n", + " if param1 == param2:\n", + " raise ValueError('param1 may not be equal to param2')\n", + " return True" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### mkdocs\n", + "* nothing wrong with sphinx, however mkdocs more user-friendly -> we will look at the example\n", + "* if you want to use markdown, look at [mkdocs](https://www.mkdocs.org/)\n", + "* example config\n", + "\n", + "```\n", + "site_name: example\n", + "nav:\n", + " - \"Home\" : index.md\n", + " - \"About\" : about.md\n", + " - \"Pipeline\" : pipeline.md\n", + "\n", + "docs_dir: docs\n", + "plugins:\n", + " - search\n", + " - mkdocstrings:\n", + " default_handler : python\n", + " handlers:\n", + " python:\n", + " setup_commands:\n", + " - import sys\n", + " - sys.path.append(\"app/\")\n", + " rendering:\n", + " show_source: true\n", + " show_root_heading: true\n", + "extra_css:\n", + " - stylesheets/extra.css\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "* **Ex**: build basic docs structure for yourself\n", + " * (later) host it on GitHub pages - https://pages.github.com/\n", + " " + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3.10.7 64-bit", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.7" + }, + "vscode": { + "interpreter": { + "hash": "1f0e6d99f3103fd78365fe1cf7b2d51239fa0878786db9cbdfe89bc88a3151d3" + } + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/08_packages_docs_tests/08b_testing.ipynb b/08_packages_docs_tests/08b_testing.ipynb new file mode 100644 index 0000000..f3deb00 --- /dev/null +++ b/08_packages_docs_tests/08b_testing.ipynb @@ -0,0 +1,1133 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Lecture 8b\n", + "by Martin Hronec\n", + "\n", + "[Testing](#Testing)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Testing\n", + "\n", + "* many ways to test code\n", + "* you've all done an exploratory/manual testing\n", + "* to cover the whole codebase with manual tests, it is necessary:\n", + " * list all the code/projects features\n", + " * collect all (different) types of inputs it \n", + " * collect the corresponding expected results\n", + "* !problem: change in code => change the above\n", + " * not fun => **automated testing**\n", + " * running test from script instead of manually\n", + " \n", + "* 2 main test categories:\n", + " * integration tests - testing multiple if multiple components work together\n", + " * unit tests - testing a single component\n", + "\n", + "* (most) functional tests consist of:\n", + " 1. **Arrange** - conditions in/for which we test\n", + " 2. **Act** - running the behaviour we want to test\n", + " 3. **Assert** - check if behaviour produced expected result\n", + " 4. **Cleanup** - don't influence other tests\n", + "\n", + "* the most basic test can be done using `assert` method\n", + " * e.g. lets check/test if `len` method is the same as `__len__`" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "a_list = [1,2,3,5] \n", + "assert len(a_list) == a_list.__len__(), \"Function len returned differnt result than method __len__\"" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "* we could try different data-structure" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "a_tuple = (1,2,3,5)\n", + "assert len(a_tuple) == a_tuple.__len__(), \"Function len returned differnt result than method __len__\"" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "a_tuple = (1,2,3,5)\n", + "assert len(a_tuple) == a_tuple.__len__(), \"Function len returned differnt result than method __len__\"" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "ename": "AssertionError", + "evalue": "2. Your result is off.", + "output_type": "error", + "traceback": [ + "\u001b[1;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[1;31mAssertionError\u001b[0m Traceback (most recent call last)", + "Cell \u001b[1;32mIn [4], line 1\u001b[0m\n\u001b[1;32m----> 1\u001b[0m \u001b[38;5;28;01massert\u001b[39;00m \u001b[38;5;28msum\u001b[39m([\u001b[38;5;241m1\u001b[39m,\u001b[38;5;241m1\u001b[39m]) \u001b[38;5;241m==\u001b[39m \u001b[38;5;241m3\u001b[39m, \u001b[38;5;124m'\u001b[39m\u001b[38;5;124m2. Your result is off.\u001b[39m\u001b[38;5;124m'\u001b[39m\n", + "\u001b[1;31mAssertionError\u001b[0m: 2. Your result is off." + ] + } + ], + "source": [ + "assert sum([1,1]) == 3, '2. Your result is off.'" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "* instead of testing on the REPL, we can put our tests into a test script and run it " + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "All tests passed.\n" + ] + } + ], + "source": [ + "# %load test_1.py\n", + "def test_sum():\n", + " assert sum([1,1]) == 2, \"Should be 2\"\n", + " \n", + "def test_len_vs__len__():\n", + " a_tuple = (1,2,3,5)\n", + " assert len(a_tuple) == a_tuple.__len__(), \"Function len returned differnt result than method __len__\"\n", + " \n", + "if __name__ == \"__main__\":\n", + " test_sum()\n", + " test_len_vs__len__()\n", + " print('All tests passed.')" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "All tests passed.\n" + ] + } + ], + "source": [ + "%run test_1.py" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "* OK for simple check, cumbersome for more tests\n", + " * => **test runners**\n", + "* test runner = application designed for running tests\n", + " * check the output\n", + " * offer tools for diagnosing\n", + " \n", + "* many test runners available for Python\n", + " * *unittest* (built into the Python standard library)\n", + " * nose/nose2\n", + " * doctest\n", + " * robot\n", + " * **pytest**, ...\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## pytest" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "* a framework for building simple and scalable tests\n", + "* one of the most popular Python testing frameworks\n", + " * feature-rich\n", + " * a lot of available [plugins](https://docs.pytest.org/en/latest/reference/plugin_list.html)\n", + " \n", + "* pytest works with the simple assert statements\n", + " * not necessarily the case with other test runners\n", + "\n", + "* how does pytest know which tests to run?\n", + " * by default it runs all files of the form `test_*.py` or `*_test.py` in the current directory and subdirectories\n", + " * however check [conventions for test discovery rules](https://docs.pytest.org/en/6.2.x/goodpractices.html#test-discovery)" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "============================= test session starts =============================\n", + "platform win32 -- Python 3.10.7, pytest-7.1.3, pluggy-1.0.0\n", + "rootdir: c:\\Users\\Martin Hronec\\Projects\\phd\\teaching\\PythonDataIES\\06_packages_docs_tests, configfile: pytest.ini\n", + "plugins: anyio-3.6.1\n", + "collected 17 items\n", + "\n", + "test_1.py .. [ 11%]\n", + "test_2.py F. [ 23%]\n", + "test_naive.py .. [ 35%]\n", + "tests\\test_3.py F. [ 47%]\n", + "tests\\test_fixture_smtp.py . [ 52%]\n", + "tests\\test_fixtures_data.py E [ 58%]\n", + "tests\\test_mark_example.py .. [ 70%]\n", + "tests\\test_parametrize_example.py ..F.. [100%]\n", + "\n", + "=================================== ERRORS ====================================\n", + "______________________ ERROR at setup of test_addressing ______________________\n", + "\n", + " @pytest.fixture\n", + " def data_names():\n", + " import pandas as pd\n", + "> df = pd.read_csv('tests/data/test_data_names.csv')\n", + "\n", + "tests\\test_fixtures_data.py:6: \n", + "_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _\n", + "..\\..\\..\\..\\..\\AppData\\Local\\Programs\\Python\\Python310\\lib\\site-packages\\pandas\\util\\_decorators.py:311: in wrapper\n", + " return func(*args, **kwargs)\n", + "..\\..\\..\\..\\..\\AppData\\Local\\Programs\\Python\\Python310\\lib\\site-packages\\pandas\\io\\parsers\\readers.py:678: in read_csv\n", + " return _read(filepath_or_buffer, kwds)\n", + "..\\..\\..\\..\\..\\AppData\\Local\\Programs\\Python\\Python310\\lib\\site-packages\\pandas\\io\\parsers\\readers.py:575: in _read\n", + " parser = TextFileReader(filepath_or_buffer, **kwds)\n", + "..\\..\\..\\..\\..\\AppData\\Local\\Programs\\Python\\Python310\\lib\\site-packages\\pandas\\io\\parsers\\readers.py:932: in __init__\n", + " self._engine = self._make_engine(f, self.engine)\n", + "..\\..\\..\\..\\..\\AppData\\Local\\Programs\\Python\\Python310\\lib\\site-packages\\pandas\\io\\parsers\\readers.py:1216: in _make_engine\n", + " self.handles = get_handle( # type: ignore[call-overload]\n", + "_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _\n", + "\n", + "path_or_buf = 'tests/data/test_data_names.csv', mode = 'r'\n", + "\n", + " @doc(compression_options=_shared_docs[\"compression_options\"] % \"path_or_buf\")\n", + " def get_handle(\n", + " path_or_buf: FilePath | BaseBuffer,\n", + " mode: str,\n", + " *,\n", + " encoding: str | None = None,\n", + " compression: CompressionOptions = None,\n", + " memory_map: bool = False,\n", + " is_text: bool = True,\n", + " errors: str | None = None,\n", + " storage_options: StorageOptions = None,\n", + " ) -> IOHandles[str] | IOHandles[bytes]:\n", + " \"\"\"\n", + " Get file handle for given path/buffer and mode.\n", + " \n", + " Parameters\n", + " ----------\n", + " path_or_buf : str or file handle\n", + " File path or object.\n", + " mode : str\n", + " Mode to open path_or_buf with.\n", + " encoding : str or None\n", + " Encoding to use.\n", + " {compression_options}\n", + " \n", + " .. versionchanged:: 1.0.0\n", + " May now be a dict with key 'method' as compression mode\n", + " and other keys as compression options if compression\n", + " mode is 'zip'.\n", + " \n", + " .. versionchanged:: 1.1.0\n", + " Passing compression options as keys in dict is now\n", + " supported for compression modes 'gzip', 'bz2', 'zstd' and 'zip'.\n", + " \n", + " .. versionchanged:: 1.4.0 Zstandard support.\n", + " \n", + " memory_map : bool, default False\n", + " See parsers._parser_params for more information.\n", + " is_text : bool, default True\n", + " Whether the type of the content passed to the file/buffer is string or\n", + " bytes. This is not the same as `\"b\" not in mode`. If a string content is\n", + " passed to a binary file/buffer, a wrapper is inserted.\n", + " errors : str, default 'strict'\n", + " Specifies how encoding and decoding errors are to be handled.\n", + " See the errors argument for :func:`open` for a full list\n", + " of options.\n", + " storage_options: StorageOptions = None\n", + " Passed to _get_filepath_or_buffer\n", + " \n", + " .. versionchanged:: 1.2.0\n", + " \n", + " Returns the dataclass IOHandles\n", + " \"\"\"\n", + " # Windows does not default to utf-8. Set to utf-8 for a consistent behavior\n", + " encoding = encoding or \"utf-8\"\n", + " \n", + " # read_csv does not know whether the buffer is opened in binary/text mode\n", + " if _is_binary_mode(path_or_buf, mode) and \"b\" not in mode:\n", + " mode += \"b\"\n", + " \n", + " # validate encoding and errors\n", + " codecs.lookup(encoding)\n", + " if isinstance(errors, str):\n", + " codecs.lookup_error(errors)\n", + " \n", + " # open URLs\n", + " ioargs = _get_filepath_or_buffer(\n", + " path_or_buf,\n", + " encoding=encoding,\n", + " compression=compression,\n", + " mode=mode,\n", + " storage_options=storage_options,\n", + " )\n", + " \n", + " handle = ioargs.filepath_or_buffer\n", + " handles: list[BaseBuffer]\n", + " \n", + " # memory mapping needs to be the first step\n", + " handle, memory_map, handles = _maybe_memory_map(\n", + " handle,\n", + " memory_map,\n", + " ioargs.encoding,\n", + " ioargs.mode,\n", + " errors,\n", + " ioargs.compression[\"method\"] not in _compression_to_extension,\n", + " )\n", + " \n", + " is_path = isinstance(handle, str)\n", + " compression_args = dict(ioargs.compression)\n", + " compression = compression_args.pop(\"method\")\n", + " \n", + " # Only for write methods\n", + " if \"r\" not in mode and is_path:\n", + " check_parent_directory(str(handle))\n", + " \n", + " if compression:\n", + " if compression != \"zstd\":\n", + " # compression libraries do not like an explicit text-mode\n", + " ioargs.mode = ioargs.mode.replace(\"t\", \"\")\n", + " elif compression == \"zstd\" and \"b\" not in ioargs.mode:\n", + " # python-zstandard defaults to text mode, but we always expect\n", + " # compression libraries to use binary mode.\n", + " ioargs.mode += \"b\"\n", + " \n", + " # GZ Compression\n", + " if compression == \"gzip\":\n", + " if is_path:\n", + " assert isinstance(handle, str)\n", + " # error: Incompatible types in assignment (expression has type\n", + " # \"GzipFile\", variable has type \"Union[str, BaseBuffer]\")\n", + " handle = gzip.GzipFile( # type: ignore[assignment]\n", + " filename=handle,\n", + " mode=ioargs.mode,\n", + " **compression_args,\n", + " )\n", + " else:\n", + " handle = gzip.GzipFile(\n", + " # No overload variant of \"GzipFile\" matches argument types\n", + " # \"Union[str, BaseBuffer]\", \"str\", \"Dict[str, Any]\"\n", + " fileobj=handle, # type: ignore[call-overload]\n", + " mode=ioargs.mode,\n", + " **compression_args,\n", + " )\n", + " \n", + " # BZ Compression\n", + " elif compression == \"bz2\":\n", + " # No overload variant of \"BZ2File\" matches argument types\n", + " # \"Union[str, BaseBuffer]\", \"str\", \"Dict[str, Any]\"\n", + " handle = bz2.BZ2File( # type: ignore[call-overload]\n", + " handle,\n", + " mode=ioargs.mode,\n", + " **compression_args,\n", + " )\n", + " \n", + " # ZIP Compression\n", + " elif compression == \"zip\":\n", + " # error: Argument 1 to \"_BytesZipFile\" has incompatible type \"Union[str,\n", + " # BaseBuffer]\"; expected \"Union[Union[str, PathLike[str]],\n", + " # ReadBuffer[bytes], WriteBuffer[bytes]]\"\n", + " handle = _BytesZipFile(\n", + " handle, ioargs.mode, **compression_args # type: ignore[arg-type]\n", + " )\n", + " if handle.mode == \"r\":\n", + " handles.append(handle)\n", + " zip_names = handle.namelist()\n", + " if len(zip_names) == 1:\n", + " handle = handle.open(zip_names.pop())\n", + " elif len(zip_names) == 0:\n", + " raise ValueError(f\"Zero files found in ZIP file {path_or_buf}\")\n", + " else:\n", + " raise ValueError(\n", + " \"Multiple files found in ZIP file. \"\n", + " f\"Only one file per ZIP: {zip_names}\"\n", + " )\n", + " \n", + " # XZ Compression\n", + " elif compression == \"xz\":\n", + " handle = get_lzma_file()(handle, ioargs.mode)\n", + " \n", + " # Zstd Compression\n", + " elif compression == \"zstd\":\n", + " zstd = import_optional_dependency(\"zstandard\")\n", + " if \"r\" in ioargs.mode:\n", + " open_args = {\"dctx\": zstd.ZstdDecompressor(**compression_args)}\n", + " else:\n", + " open_args = {\"cctx\": zstd.ZstdCompressor(**compression_args)}\n", + " handle = zstd.open(\n", + " handle,\n", + " mode=ioargs.mode,\n", + " **open_args,\n", + " )\n", + " \n", + " # Unrecognized Compression\n", + " else:\n", + " msg = f\"Unrecognized compression type: {compression}\"\n", + " raise ValueError(msg)\n", + " \n", + " assert not isinstance(handle, str)\n", + " handles.append(handle)\n", + " \n", + " elif isinstance(handle, str):\n", + " # Check whether the filename is to be opened in binary mode.\n", + " # Binary mode does not support 'encoding' and 'newline'.\n", + " if ioargs.encoding and \"b\" not in ioargs.mode:\n", + " # Encoding\n", + "> handle = open(\n", + " handle,\n", + " ioargs.mode,\n", + " encoding=ioargs.encoding,\n", + " errors=errors,\n", + " newline=\"\",\n", + " )\n", + "E FileNotFoundError: [Errno 2] No such file or directory: 'tests/data/test_data_names.csv'\n", + "\n", + "..\\..\\..\\..\\..\\AppData\\Local\\Programs\\Python\\Python310\\lib\\site-packages\\pandas\\io\\common.py:786: FileNotFoundError\n", + "================================== FAILURES ===================================\n", + "__________________________________ test_sum ___________________________________\n", + "\n", + " def test_sum():\n", + "> assert sum([1,1]) == 3, \"Should be 2\"\n", + "E AssertionError: Should be 2\n", + "E assert 2 == 3\n", + "E + where 2 = sum([1, 1])\n", + "\n", + "test_2.py:3: AssertionError\n", + "__________________________________ test_sum ___________________________________\n", + "\n", + " def test_sum():\n", + "> assert sum([1,1]) == 3, \"Should be 2\"\n", + "E AssertionError: Should be 2\n", + "E assert 2 == 3\n", + "E + where 2 = sum([1, 1])\n", + "\n", + "tests\\test_3.py:3: AssertionError\n", + "______________________________ test_eval[6*9-42] ______________________________\n", + "\n", + "test_input = '6*9', expected = 42\n", + "\n", + " @pytest.mark.parametrize(\"test_input,expected\", [(\"3+5\", 8), (\"2+4\", 6), (\"6*9\", 42)])\n", + " def test_eval(test_input, expected):\n", + "> assert eval(test_input) == expected\n", + "E AssertionError: assert 54 == 42\n", + "E + where 54 = eval('6*9')\n", + "\n", + "tests\\test_parametrize_example.py:32: AssertionError\n", + "=========================== short test summary info ===========================\n", + "FAILED test_2.py::test_sum - AssertionError: Should be 2\n", + "FAILED tests/test_3.py::test_sum - AssertionError: Should be 2\n", + "FAILED tests/test_parametrize_example.py::test_eval[6*9-42] - AssertionError:...\n", + "ERROR tests/test_fixtures_data.py::test_addressing - FileNotFoundError: [Errn...\n", + "==================== 3 failed, 13 passed, 1 error in 3.98s ====================\n" + ] + } + ], + "source": [ + "!pytest" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "* what does it tell us:\n", + " * the system tests are run on (Python, pytest version, and any pluggins\n", + " * *rootdir* : where are we running things from\n", + " * [XX%] next to each test script shows success rate of all tests\n", + " * it will show you a failure report with detailed explanation (not here)\n", + " * lets fail" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [], + "source": [ + "#%%writefile test_2.py\n", + "#%%read test_2.py\n", + "\n", + "def test_sum():\n", + " assert sum([1,1]) == 3, \"Should be 2\"\n", + "\n", + "def test_len_vs__len__():\n", + " a_tuple = (1,2,3,5)\n", + " assert len(a_tuple) == a_tuple.__len__(), \"Function len returned differnt result than method __len__\"" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "============================= test session starts =============================\n", + "platform win32 -- Python 3.7.3, pytest-6.0.1, py-1.9.0, pluggy-0.13.1\n", + "rootdir: C:\\Users\\Martin Hronec\\Projects\\phd\\teaching\\DPP_IES\\10_testing, configfile: pytest.ini\n", + "collected 15 items\n", + "\n", + "test_2.py F. [ 13%]\n", + "test_naive.py .. [ 26%]\n", + "tests\\test_3.py F. [ 40%]\n", + "tests\\test_fixture_smtp.py . [ 46%]\n", + "tests\\test_fixtures_data.py F [ 53%]\n", + "tests\\test_mark_example.py .. [ 66%]\n", + "tests\\test_parametrize_example.py ..F.. [100%]\n", + "\n", + "================================== FAILURES ===================================\n", + "__________________________________ test_sum ___________________________________\n", + "\n", + " def test_sum():\n", + "> assert sum([1,1]) == 3, \"Should be 2\"\n", + "E AssertionError: Should be 2\n", + "E assert 2 == 3\n", + "E + where 2 = sum([1, 1])\n", + "\n", + "test_2.py:3: AssertionError\n", + "__________________________________ test_sum ___________________________________\n", + "\n", + " def test_sum():\n", + "> assert sum([1,1]) == 3, \"Should be 2\"\n", + "E AssertionError: Should be 2\n", + "E assert 2 == 3\n", + "E + where 2 = sum([1, 1])\n", + "\n", + "tests\\test_3.py:3: AssertionError\n", + "_______________________________ test_addressing _______________________________\n", + "\n", + "data_names = Title Surname Addressing\n", + "0 Mgr. Kalerab Mgr. Kalerab\n", + "1 Ing. Mrkvicka Ing. Mrkvicka\n", + "2 NaN Slanina Slanina\n", + "\n", + " def test_addressing(data_names):\n", + " df = data_names\n", + " titles = df['Title']\n", + " surnames = df['Surname']\n", + " expected = df['Addressing']\n", + "> assert (titles + ' ' + expected == surnames).all()\n", + "E assert False\n", + "E + where False = ()\n", + "E + where = 0 Mgr. Mg...\\ndtype: object == 0 Kalerab... dtype: object\n", + "E Use -v to get the full diff.all\n", + "\n", + "tests\\test_fixtures_data.py:14: AssertionError\n", + "______________________________ test_eval[6*9-42] ______________________________\n", + "\n", + "test_input = '6*9', expected = 42\n", + "\n", + " @pytest.mark.parametrize(\"test_input,expected\", [(\"3+5\", 8), (\"2+4\", 6), (\"6*9\", 42)])\n", + " def test_eval(test_input, expected):\n", + "> assert eval(test_input) == expected\n", + "E AssertionError: assert 54 == 42\n", + "E + where 54 = eval('6*9')\n", + "\n", + "tests\\test_parametrize_example.py:32: AssertionError\n", + "=========================== short test summary info ===========================\n", + "FAILED test_2.py::test_sum - AssertionError: Should be 2\n", + "FAILED tests/test_3.py::test_sum - AssertionError: Should be 2\n", + "FAILED tests/test_fixtures_data.py::test_addressing - assert False\n", + "FAILED tests/test_parametrize_example.py::test_eval[6*9-42] - AssertionError:...\n", + "======================== 4 failed, 11 passed in 0.84s =========================\n" + ] + } + ], + "source": [ + "!pytest" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "* output next to the script indecates the status of each test:\n", + " * \".\" - test passed\n", + " * \"F\" - test failed\n", + " * \"E\" - test raised an unexcpected exception\n", + "\n", + "* it does not only show you the AssertionError though\n", + " * what does it show us (compared to the simple assert statement)?\n", + "\n", + "* if we want to run only some tests, we can specify which to ignore\n", + " * `--ignore`\n", + " * `--ignore-glob` - using glob (wildcard like patterns)" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Overwriting tests/test_3.py\n" + ] + } + ], + "source": [ + "%%writefile tests/test_3.py\n", + "\n", + "def test_sum():\n", + " assert sum([1,1]) == 3, \"Should be 2\"\n", + "\n", + "def test_len_vs__len__():\n", + " a_tuple = (1,2,3,5)\n", + " assert len(a_tuple) == a_tuple.__len__(), \"Function len returned differnt result than method __len__\"" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "C:\\Users\\pcz02m4h\\Projects\\DPP_IES\\10_testing\n" + ] + } + ], + "source": [ + "# checking where we are\n", + "!cd" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "============================= test session starts =============================\n", + "platform win32 -- Python 3.9.4, pytest-6.2.3, py-1.10.0, pluggy-0.13.1\n", + "rootdir: C:\\Users\\pcz02m4h\\Projects\\DPP_IES\\10_testing\n", + "plugins: anyio-2.2.0\n", + "collected 4 items\n", + "\n", + "test_1.py .. [ 50%]\n", + "test_2.py F. [100%]\n", + "\n", + "================================== FAILURES ===================================\n", + "__________________________________ test_sum ___________________________________\n", + "\n", + " def test_sum():\n", + "> assert sum([1,1]) == 3, \"Should be 2\"\n", + "E AssertionError: Should be 2\n", + "E assert 2 == 3\n", + "E + where 2 = sum([1, 1])\n", + "\n", + "test_2.py:3: AssertionError\n", + "=========================== short test summary info ===========================\n", + "FAILED test_2.py::test_sum - AssertionError: Should be 2\n", + "========================= 1 failed, 3 passed in 0.11s =========================\n" + ] + } + ], + "source": [ + "!pytest --ignore=tests/" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "============================= test session starts =============================\n", + "platform win32 -- Python 3.9.4, pytest-6.2.3, py-1.10.0, pluggy-0.13.1\n", + "rootdir: C:\\Users\\pcz02m4h\\Projects\\DPP_IES\\10_testing\n", + "plugins: anyio-2.2.0\n", + "collected 5 items\n", + "\n", + "test_1.py .. [ 40%]\n", + "test_2.py F. [ 80%]\n", + "tests\\test_not_to_run.py . [100%]\n", + "\n", + "================================== FAILURES ===================================\n", + "__________________________________ test_sum ___________________________________\n", + "\n", + " def test_sum():\n", + "> assert sum([1,1]) == 3, \"Should be 2\"\n", + "E AssertionError: Should be 2\n", + "E assert 2 == 3\n", + "E + where 2 = sum([1, 1])\n", + "\n", + "test_2.py:3: AssertionError\n", + "=========================== short test summary info ===========================\n", + "FAILED test_2.py::test_sum - AssertionError: Should be 2\n", + "========================= 1 failed, 4 passed in 0.11s =========================\n" + ] + } + ], + "source": [ + "# when not ignoring\n", + "!pytest --ignore-glob=*_3.py" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "* in most modern code editors, managing a set of tests is more user friendly than from command line\n", + "\n", + "\n", + "* tests often depend on:\n", + " * data\n", + " * test doubles\n", + "* we don't want to mess with the originals => pytest **fixtures**\n", + "\n", + "### Fixtures\n", + "* \"arranging\" part of the test\n", + "\n", + "* a method for providing:\n", + " * data\n", + " * test doubles\n", + " * state setup \n", + "\n", + "* more tests using the same underlying dataset -> use fixture\n", + " * (repeating) data provided by a single function [decorated](#Decorators) with `@pytest.fixture`\n", + " \n", + "* test depending on a fixture needs to have a fixture as an argument\n", + "\n", + "* let's look at the test double first" + ] + }, + { + "cell_type": "code", + "execution_count": 37, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "C:\\Users\\pcz02m4h\\Projects\\DPP_IES\\10_testing\n" + ] + } + ], + "source": [ + "!cd" + ] + }, + { + "cell_type": "code", + "execution_count": 50, + "metadata": {}, + "outputs": [], + "source": [ + "# %load test_fixture_smtp.py\n", + "import pytest\n", + "\n", + "@pytest.fixture\n", + "def smtp():\n", + " \"\"\"Initialize and return SMTP client session object\"\"\"\n", + " import smtplib\n", + " return smtplib.SMTP(\"smtp.gmail.com\")\n", + "\n", + "def test_ehlo(smtp):\n", + " \"\"\"Test response from sending Extended Helo (EHLO) is 250.\"\"\"\n", + " response, msg = smtp.ehlo()\n", + " assert response == 250\n", + " # assert 0 " + ] + }, + { + "cell_type": "code", + "execution_count": 51, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "============================= test session starts =============================\n", + "platform win32 -- Python 3.9.4, pytest-6.2.3, py-1.10.0, pluggy-0.13.1\n", + "rootdir: C:\\Users\\pcz02m4h\\Projects\\DPP_IES\\10_testing\n", + "plugins: anyio-2.2.0\n", + "collected 1 item\n", + "\n", + "test_fixture_smtp.py . [100%]\n", + "\n", + "============================== 1 passed in 0.16s ==============================\n" + ] + } + ], + "source": [ + "!pytest test_fixture_smtp.py" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "* now fixture for providing data\n", + " * note: when providing path, think about the sourcedirectory! " + ] + }, + { + "cell_type": "code", + "execution_count": 95, + "metadata": {}, + "outputs": [], + "source": [ + "# %load tests/test_fixtures_data.py\n", + "import pytest \n", + "\n", + "@pytest.fixture\n", + "def data_names():\n", + " import pandas as pd\n", + " df = pd.read_csv('data/test_data_names.csv')\n", + " return df\n", + "\n", + "def test_addressing(data_names):\n", + " df = data_names\n", + " titles = df['Title']\n", + " surnames = df['Surname']\n", + " expected = df[['Addressing']]\n", + " assert (titles + ' ' + expected == surnames).all()" + ] + }, + { + "cell_type": "code", + "execution_count": 94, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "============================= test session starts =============================\n", + "platform win32 -- Python 3.9.4, pytest-6.2.3, py-1.10.0, pluggy-0.13.1\n", + "rootdir: C:\\Users\\pcz02m4h\\Projects\\DPP_IES\\10_testing\n", + "plugins: anyio-2.2.0\n", + "collected 1 item\n", + "\n", + "tests\\test_fixtures_data.py F [100%]\n", + "\n", + "================================== FAILURES ===================================\n", + "_______________________________ test_addressing _______________________________\n", + "\n", + "data_names = Title Surname Addressing\n", + "0 Mgr. Kalerab Mgr. Kalerab\n", + "1 Ing. Mrkvicka Ing. Mrkvicka\n", + "2 NaN Slanina Slanina\n", + "\n", + " def test_addressing(data_names):\n", + " df = data_names\n", + " titles = df['Title']\n", + " surnames = df['Surname']\n", + " expected = df['Addressing']\n", + "> assert (titles + ' ' + expected == surnames).all()\n", + "E assert False\n", + "E + where False = .all of 0 False\\n1 False\\n2 False\\ndtype: bool>()\n", + "E + where .all of 0 False\\n1 False\\n2 False\\ndtype: bool> = 0 Mgr. Mg...\\ndtype: object == 0 Kalerab... dtype: object\n", + "E Use -v to get the full diff.all\n", + "\n", + "tests\\test_fixtures_data.py:14: AssertionError\n", + "=========================== short test summary info ===========================\n", + "FAILED tests/test_fixtures_data.py::test_addressing - assert False\n", + "============================== 1 failed in 0.53s ==============================\n" + ] + } + ], + "source": [ + "!pytest tests/test_fixtures_data.py" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "* when to avoid fixtures:\n", + " * using fixtures fixtures is as bas as using tests redundantly\n", + " * => **marks**" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Marks - test filtering\n", + "\n", + "* you might want to only run couple of your tests\n", + " * full suite of tests only sometimes\n", + " \n", + "* to filter which tests to run:\n", + " * name-based filtering\n", + " * directory scoping \n", + " * **test categorization** (`-m` parameter)\n", + " \n", + "* create **marks** (custom labels) to label any test you like (can have multiple labels)\n", + " * e.g. you can categorize your tests by dependencies (e.g. access to database - could be `@pytest.mark.database_access`\n", + "* to run only tests in specific category (mark) `pytest -m `\n", + "* to *not* run tests with specific mark `pytest -m \"not \"`\n", + "\n", + "* you should also [register the custom markers](https://stackoverflow.com/questions/60806473/pytestunknownmarkwarning-unknown-pytest-mark-xxx-is-this-a-typo) in *pytest.ini* file" + ] + }, + { + "cell_type": "code", + "execution_count": 101, + "metadata": {}, + "outputs": [], + "source": [ + "# %load tests/test_mark_example.py\n", + "import pytest \n", + "\n", + "@pytest.mark.database\n", + "def test_pg_read():\n", + " pass\n", + "\n", + "@pytest.mark.database\n", + "def test_pg_write():\n", + " pass" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [], + "source": [ + "!pytest -m database" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "* there are few marks out of the box:\n", + " * **skip** skips a test unconditionally\n", + " * **skipif** skips a test if the expression passed to it evaluates to True\n", + " * **parametrize** creates multiple variants of a test with different values as arguments\n", + " \n", + "* you can see a list of all the marks pytest knows about by running `pytest --markers`" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [], + "source": [ + "!pytest --markers" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Test parametrization\n", + "\n", + "* using only slightly different input and output would lead to repeating test definitions\n", + " * DRY!\n", + "* fixtures not very good with only slightly different inputs and expected outputs\n", + " * **parametrize** a single test definition a get variants of the test for you with the parameters you specify\n", + " * mind the syntax\n" + ] + }, + { + "cell_type": "code", + "execution_count": 131, + "metadata": {}, + "outputs": [], + "source": [ + "# %load tests/test_parametrize_example.py\n", + "import pytest\n", + "import unicodedata\n", + "\n", + "#######\n", + "# Function we would like to test should be defined in package code, not here.\n", + "########\n", + "def drop_diacritics(text: str) -> str:\n", + " \"\"\"\n", + " Strip accents from input String.\n", + " \n", + " :param text: The input string.\n", + " :returns: The processed string.\n", + " \"\"\"\n", + " if not isinstance(text, str):\n", + " raise TypeError(f'Input text should be a string, not %s', type(text))\n", + " \n", + " # Return the normal form for the Unicode string\n", + " # 'NFKD' stands for the normal form KD \n", + " text = unicodedata.normalize('NFKD',text)\n", + " output = ''\n", + " \n", + " for char in text:\n", + " if not unicodedata.combining(char):\n", + " output += char\n", + " \n", + " return output\n", + "#### \n", + "\n", + "\n", + "@pytest.mark.parametrize(\"test_input,expected\", [(\"3+5\", 8), (\"2+4\", 6), (\"6*9\", 42)])\n", + "def test_eval(test_input, expected):\n", + " assert eval(test_input) == expected\n", + " \n", + "@pytest.mark.parametrize(\n", + " 'original,output',\n", + " [\n", + " ('řeřicha', 'rericha'),\n", + " ('čeština', 'cestina')\n", + " ]\n", + ") \n", + "def test_drop_diacritics(original:str, output:str) -> None:\n", + " assert drop_diacritics(original) == output\n", + " " + ] + }, + { + "cell_type": "code", + "execution_count": 132, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "============================= test session starts =============================\n", + "platform win32 -- Python 3.9.4, pytest-6.2.3, py-1.10.0, pluggy-0.13.1\n", + "rootdir: C:\\Users\\pcz02m4h\\Projects\\DPP_IES\\10_testing, configfile: pytest.ini\n", + "plugins: anyio-2.2.0\n", + "collected 5 items\n", + "\n", + "tests\\test_parametrize_example.py ..F.. [100%]\n", + "\n", + "================================== FAILURES ===================================\n", + "______________________________ test_eval[6*9-42] ______________________________\n", + "\n", + "test_input = '6*9', expected = 42\n", + "\n", + " @pytest.mark.parametrize(\"test_input,expected\", [(\"3+5\", 8), (\"2+4\", 6), (\"6*9\", 42)])\n", + " def test_eval(test_input, expected):\n", + "> assert eval(test_input) == expected\n", + "E AssertionError: assert 54 == 42\n", + "E + where 54 = eval('6*9')\n", + "\n", + "tests\\test_parametrize_example.py:32: AssertionError\n", + "=========================== short test summary info ===========================\n", + "FAILED tests/test_parametrize_example.py::test_eval[6*9-42] - AssertionError:...\n", + "========================= 1 failed, 4 passed in 0.10s =========================\n" + ] + } + ], + "source": [ + "!pytest tests/test_parametrize_example.py" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Testing features to explore\n", + "\n", + "* [plugins](https://docs.pytest.org/en/latest/reference/plugin_list.html)\n", + " * requests-mock\n", + " * database-mock\n", + "\n", + "* [CI/CD](https://docs.github.com/en/actions/guides/about-continuous-integration)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3.10.7 64-bit", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.7" + }, + "vscode": { + "interpreter": { + "hash": "1f0e6d99f3103fd78365fe1cf7b2d51239fa0878786db9cbdfe89bc88a3151d3" + } + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/08_packages_docs_tests/pytest.ini b/08_packages_docs_tests/pytest.ini new file mode 100644 index 0000000..de66922 --- /dev/null +++ b/08_packages_docs_tests/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +markers = + database: mark a test needing access to database \ No newline at end of file diff --git a/08_packages_docs_tests/setup.py b/08_packages_docs_tests/setup.py new file mode 100644 index 0000000..c50707d --- /dev/null +++ b/08_packages_docs_tests/setup.py @@ -0,0 +1,13 @@ +from setuptools import setup, find_packages + +setup( + name='PackageName', + version='0.1', + author='YoursTruly', + author_email='yourstruly@fsv.cuni.cz', + packages= ["src"], #find_packages(), + description='Exemplatory package.', + #long_description=open('README.md').read(), + install_requires=[ + "pytest", + ],) \ No newline at end of file diff --git a/08_packages_docs_tests/src/__init_.py b/08_packages_docs_tests/src/__init_.py new file mode 100644 index 0000000..e69de29 diff --git a/08_packages_docs_tests/src/example_module.py b/08_packages_docs_tests/src/example_module.py new file mode 100644 index 0000000..2696bd7 --- /dev/null +++ b/08_packages_docs_tests/src/example_module.py @@ -0,0 +1,7 @@ +text = "modularity is the key" + +def f(arg): + print(f'This function takes as an argument: {arg}') + +class AClass: + pass \ No newline at end of file diff --git a/08_packages_docs_tests/test_1.py b/08_packages_docs_tests/test_1.py new file mode 100644 index 0000000..cf948b7 --- /dev/null +++ b/08_packages_docs_tests/test_1.py @@ -0,0 +1,11 @@ +def test_sum(): + assert sum([1,1]) == 2, "Should be 2" + +def test_len_vs__len__(): + a_tuple = (1,2,3,5) + assert len(a_tuple) == a_tuple.__len__(), "Function len returned differnt result than method __len__" + +if __name__ == "__main__": + test_sum() + test_len_vs__len__() + print('All tests passed.') diff --git a/08_packages_docs_tests/test_2.py b/08_packages_docs_tests/test_2.py new file mode 100644 index 0000000..f228521 --- /dev/null +++ b/08_packages_docs_tests/test_2.py @@ -0,0 +1,7 @@ + +def test_sum(): + assert sum([1,1]) == 3, "Should be 2" + +def test_len_vs__len__(): + a_tuple = (1,2,3,5) + assert len(a_tuple) == a_tuple.__len__(), "Function len returned differnt result than method __len__" diff --git a/08_packages_docs_tests/test_naive.py b/08_packages_docs_tests/test_naive.py new file mode 100644 index 0000000..300fc2a --- /dev/null +++ b/08_packages_docs_tests/test_naive.py @@ -0,0 +1,11 @@ +def test_sum(): + assert sum([1,1]) == 2, "Should be 2" + +def test_len_vs__len__(): + a_tuple = (1,2,3,5) + assert len(a_tuple) == a_tuple.__len__(), "Function len returned differnt result than method __len__" + +if __name__ == "__main__": + test_sum() + test_len_vs__len__() + print('All tests passed.') \ No newline at end of file diff --git a/08_packages_docs_tests/tests/test_3.py b/08_packages_docs_tests/tests/test_3.py new file mode 100644 index 0000000..8718d6f --- /dev/null +++ b/08_packages_docs_tests/tests/test_3.py @@ -0,0 +1,7 @@ + +def test_sum(): + assert sum([1,1]) == 3, "Should be 2" + +def test_len_vs__len__(): + a_tuple = (1,2,3,5) + assert len(a_tuple) == a_tuple.__len__(), "Function len returned differnt result than method __len__" \ No newline at end of file diff --git a/08_packages_docs_tests/tests/test_fixture_smtp.py b/08_packages_docs_tests/tests/test_fixture_smtp.py new file mode 100644 index 0000000..240572d --- /dev/null +++ b/08_packages_docs_tests/tests/test_fixture_smtp.py @@ -0,0 +1,13 @@ +import pytest + +@pytest.fixture +def smtp(): + """Initialize and return SMTP client session object""" + import smtplib + return smtplib.SMTP("smtp.gmail.com") + +def test_ehlo(smtp): + """Test response from sending Extended Helo (EHLO) is 250.""" + response, msg = smtp.ehlo() + assert response == 250 + # assert 0 \ No newline at end of file diff --git a/08_packages_docs_tests/tests/test_fixtures_data.py b/08_packages_docs_tests/tests/test_fixtures_data.py new file mode 100644 index 0000000..14cf1b5 --- /dev/null +++ b/08_packages_docs_tests/tests/test_fixtures_data.py @@ -0,0 +1,14 @@ +import pytest + +@pytest.fixture +def data_names(): + import pandas as pd + df = pd.read_csv('tests/data/test_data_names.csv') + return df + +def test_addressing(data_names): + df = data_names + titles = df['Title'] + surnames = df['Surname'] + expected = df['Addressing'] + assert (titles + ' ' + expected == surnames).all() \ No newline at end of file diff --git a/08_packages_docs_tests/tests/test_mark_example.py b/08_packages_docs_tests/tests/test_mark_example.py new file mode 100644 index 0000000..7c2eae7 --- /dev/null +++ b/08_packages_docs_tests/tests/test_mark_example.py @@ -0,0 +1,9 @@ +import pytest + +@pytest.mark.database +def test_pg_read(): + pass + +@pytest.mark.database +def test_pg_write(): + pass \ No newline at end of file diff --git a/08_packages_docs_tests/tests/test_parametrize_example.py b/08_packages_docs_tests/tests/test_parametrize_example.py new file mode 100644 index 0000000..d166eaa --- /dev/null +++ b/08_packages_docs_tests/tests/test_parametrize_example.py @@ -0,0 +1,43 @@ +import pytest +import unicodedata + +####### +# Function we would like to test should be defined in package code, not here. +######## +def drop_diacritics(text: str) -> str: + """ + Strip accents from input String. + + :param text: The input string. + :returns: The processed string. + """ + if not isinstance(text, str): + raise TypeError(f'Input text should be a string, not %s', type(text)) + + # Return the normal form for the Unicode string + # 'NFKD' stands for the normal form KD + text = unicodedata.normalize('NFKD',text) + output = '' + + for char in text: + if not unicodedata.combining(char): + output += char + + return output +#### + + +@pytest.mark.parametrize("test_input,expected", [("3+5", 8), ("2+4", 6), ("6*9", 42)]) +def test_eval(test_input, expected): + assert eval(test_input) == expected + +@pytest.mark.parametrize( + 'original,output', + [ + ('řeřicha', 'rericha'), + ('čeština', 'cestina') + ] +) +def test_drop_diacritics(original:str, output:str) -> None: + assert drop_diacritics(original) == output + \ No newline at end of file