diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95a72cf2..e7033372 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,25 +5,36 @@ on: push: branches: [main] +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: {} + jobs: pre-commit: + name: pre-commit runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-python@v6 - - uses: actions/cache@v5 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.cache/pre-commit key: pre-commit|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }} - - uses: pre-commit/action@v3.0.1 + - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 docs: name: Build docs and check links runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: pandoc/actions/setup@v1 - - uses: actions/setup-python@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - uses: pandoc/actions/setup@86321b6dd4675f5014c611e05088e10d4939e09e # v1.1.1 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: # Keep in sync with .readthedocs.yaml python-version-file: .python-version @@ -31,12 +42,12 @@ jobs: run: | sudo apt install plantuml - name: Setup cached uv - uses: hynek/setup-cached-uv@v2 + uses: hynek/setup-cached-uv@4300ec2180bc77d705e626a34e381b81a4772c51 # v2.5.0 - name: Create venv and install docs dependencies run: | uv venv echo "$PWD/.venv/bin" >> $GITHUB_PATH - uv pip install --group=docs + uv sync --group docs - name: Build HTML and check links run: | uv run make html diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index dd542e36..1994f914 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -9,52 +9,55 @@ on: # Allows you to run this workflow manually from the Actions tab workflow_dispatch: -# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages -permissions: - contents: read - pages: write - id-token: write - # Allow one concurrent deployment concurrency: group: "pages" cancel-in-progress: true +permissions: {} + jobs: - # Single deploy job since we’re just deploying deploy: + name: Single deploy job since we’re just deploying + # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages + permissions: + contents: read # necessary for the deployment to GitHub Pages + pages: write # necessary for the deployment to GitHub Pages + id-token: write # necessary for the deployment to GitHub Pages environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: Install packages run: sudo apt install plantuml - name: Setup pandoc - uses: pandoc/actions/setup@v1 + uses: pandoc/actions/setup@86321b6dd4675f5014c611e05088e10d4939e09e # v1.1.1 - name: Setup python - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version-file: .python-version architecture: x64 - name: Setup cached uv - uses: hynek/setup-cached-uv@v2 + uses: hynek/setup-cached-uv@4300ec2180bc77d705e626a34e381b81a4772c51 # v2.5.0 - name: Build and activate venv run: | uv venv echo "$PWD/.venv/bin" >> $GITHUB_PATH uv pip install --group=docs - name: Setup Pages - uses: actions/configure-pages@v5 + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - name: Build HTML run: uv run make html working-directory: docs/ - name: Upload artifact - uses: actions/upload-pages-artifact@v4 + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: docs/_build/html - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml new file mode 100644 index 00000000..45646209 --- /dev/null +++ b/.github/workflows/zizmor.yml @@ -0,0 +1,31 @@ +# https://github.com/woodruffw/zizmor +name: Zizmor + +on: + push: + branches: ["main"] + pull_request: + branches: ["**"] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + zizmor: + name: Run zizmor + runs-on: ubuntu-latest + permissions: + security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files. + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Run zizmor + uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6 + with: + persona: pedantic diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7c57fdaa..b3ed0dae 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,7 +6,7 @@ ci: repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v6.0.0 + rev: v6.0.0 # v6.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer @@ -15,38 +15,38 @@ repos: - id: check-added-large-files args: ['--maxkb=1024'] - repo: https://github.com/tox-dev/pyproject-fmt - rev: v2.16.2 + rev: v2.25.1 # v2.23.0 hooks: - id: pyproject-fmt - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.25 + rev: v0.25 # v0.25 hooks: - id: validate-pyproject - repo: https://github.com/sphinx-contrib/sphinx-lint - rev: v1.0.2 + rev: v1.0.2 # v1.0.2 hooks: - id: sphinx-lint types: [rst] - repo: https://github.com/pycqa/isort - rev: 8.0.1 + rev: 9.0.0a3 # 9.0.0a3 hooks: - id: isort additional_dependencies: ["toml"] entry: isort --profile=black name: isort (python) - repo: https://github.com/psf/black-pre-commit-mirror - rev: 26.1.0 + rev: 26.5.1 # 26.5.1 hooks: - id: black - repo: https://github.com/adamchainz/blacken-docs - rev: "1.20.0" + rev: 1.20.0 # 1.20.0 hooks: - id: blacken-docs args: [--line-length=79] additional_dependencies: - black - repo: https://github.com/codespell-project/codespell - rev: v2.4.1 + rev: v2.4.2 # v2.4.2 hooks: - id: codespell - repo: local diff --git a/.python-version b/.python-version index 24ee5b1b..6324d401 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.13 +3.14 diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 4f589eab..f0a30b48 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -7,22 +7,21 @@ version: 2 # Set the version of Python and other tools you might need build: - os: ubuntu-24.04 + os: ubuntu-lts-latest apt_packages: # plantuml is required for sphinxcontrib.plantuml - plantuml + tools: - python: "3.13" - jobs: - install: - - python -m pip install --upgrade pip - - python -m pip install --group=docs + python: "3.14" + +python: + # See https://docs.readthedocs.com/platform/stable/build-customization.html#install-dependencies-with-uv + install: + - method: uv + command: sync + groups: + - docs -# Build documentation in the docs/ directory with Sphinx sphinx: configuration: docs/conf.py - -# If using Sphinx, optionally build your docs in additional formats such as PDF -formats: - - epub - - pdf diff --git a/README.rst b/README.rst index e3d164f3..60f55d5e 100644 --- a/README.rst +++ b/README.rst @@ -93,7 +93,7 @@ Installation .. code-block:: console - $ brew cask install mactex + $ brew install mactex … 🍺 mactex was successfully installed! $ curl --remote-name https://www.tug.org/fonts/getnonfreefonts/install-getnonfreefonts diff --git a/docs/appendix/checks.rst b/docs/appendix/checks.rst index adcaec3f..10c43489 100644 --- a/docs/appendix/checks.rst +++ b/docs/appendix/checks.rst @@ -112,7 +112,7 @@ Checks * ``1`` → True * ``0`` → False * ``-1`` → True - * ``[0]`` → True (List with one item) + * ``[0]`` → True (List with one task) * ``1 and 0`` → False * ``1 > 0 or []`` → True @@ -339,6 +339,9 @@ Checks .. blacken-docs:on +:doc:`/types/strings/built-in-modules/string` +--------------------------------------------- + * How can you change a heading such as ``variables and expressions`` so that it contains hyphens instead of spaces and can therefore be better used as a file name? @@ -349,15 +352,15 @@ Checks >>> "-".join(ve.split()) 'variables-and-expressions' -* If you want to check whether a line begins with ``.. note::``, which method +* If you wanted to check whether a filename starts with ``pylock``, which method would you use? Are there any other options? .. code-block:: pycon - >>> x.startswith(".. note::") - True - >>> x[:9] == ".. note::" + >>> filename = "pylock.prod.toml" + >>> filename.startswith("pylock") True + >>> filename[:6] == "pylock" * Suppose you have a string with exclamation marks, quotation marks and line breaks. How can these be removed from the string? @@ -398,6 +401,14 @@ Checks upper case ``x``, followed by one or more characters in the ranges ``0-9``, ``a-f`` or ``A-F``. +* What regular expression would you use for a :samp:`pylock.{NAME.}toml` + filename? + + ``pylock\.([^.]+\.)?toml$`` + + recognises both :file:`pylock.toml` and :file:`pylock.prod.toml`, as well as + similar filenames. + :doc:`/types/strings/input` --------------------------- @@ -476,6 +487,26 @@ Checks >>> print(personal_data[who]) 60 +:doc:`/control-flow/conditional` +-------------------------------- + +* If you want to check whether a filename starts with ``pylock.`` and ends with + ``.toml``, what method would you use to retrieve the name of the environment + it might contain, for example, ``prod`` in ``pylock.prod.toml``? + + .. code-block:: pycon + + >>> filename = "pylock.prod.toml" + >>> if ( + ... len(filename) > 11 + ... and filename.startswith("pylock.") + ... and filename.endswith(".toml") + ... ): + ... name = filename.removeprefix("pylock.").removesuffix(".toml") + ... + >>> name + 'prod' + :doc:`/control-flow/loops` -------------------------- @@ -858,18 +889,19 @@ Checks ├── README.rst ├── pyproject.toml └── src -    └── items -    ├── __init__.py -    ├── api.py -    ├── cli.py -    └── db.py +    └── cusy +    └── tasks +    ├── __init__.py +    ├── api.py +    ├── cli.py +    └── db.py * Think about how you want to fulfil the above tasks. Which libraries and modules can you think of that could fulfil this task? Sketch the code for the modules of the Python API, the command line interface and the database connection. - I would create a :class:`DB` class in :file:`src/items/db.py` for + I would create a :class:`DB` class in :file:`src/cusy/tasks/db.py` for communication with the database, in the following example for `tinydb `_: @@ -884,38 +916,38 @@ Checks db_path / f"{db_file_prefix}.json", create_dirs=True ) - def create(self, item: dict): - """Create an item + def create(self, task: dict): + """Create a task Returns: - id: The items id. + id: The tasks id. """ return id def read(self, id: int): - """Reads an item. + """Reads a task. Args: - id (int): The item id of an item. + id (int): The task id of an task. Returns: - item: The item object.""" - return item + task: The task object.""" + return task def update(self, id: int, mods): - """Update an item in the database. + """Update a task in the database. Args: - id (int): The item id of an item. - mods (Item): The modifications to be made to this item. + id (int): The task id of a task. + mods (Task): The modifications to be made to this task. """ self._db.update(changes, doc_ids=[id]) def delete(self, id: int): - """Deletes an item in the database. + """Deletes a task in the database. Args: - id (int): The item id of an item. + id (int): The task id of a task. """ self._db.remove(doc_ids=[id]) @@ -923,8 +955,8 @@ Checks """Closes the database connection.""" self._db.close() - Then I would use :func:`dataclass` in :file:`src/items/api` to create an - :class:`Item` class: + Then I would use :func:`dataclass` in :file:`src/cusy/tasks/api` to create an + :class:`Task` class: .. code-block:: python @@ -932,32 +964,32 @@ Checks @dataclass - class Item: + class Task: summary: str = None owner: str = None state: str = "todo" id: int = field(default=None, compare=False) - class ItemsException(Exception): + class TasksException(Exception): pass - class ItemsDB: + class TasksDB: def __init__(self, db_path): self._db_path = db_path - self._db = DB(db_path, ".items_db") + self._db = DB(db_path, ".tasks_db") - def add_item(self, item: Item): + def add_task(self, task: Task): return - def get_item(self, item: Item): + def get_task(self, task: Task): return - def update_item(self, item: Item): + def update_task(self, task: Task): return - def delete_item(self, item: Item): + def delete_task(self, task: Task): return def close(self): @@ -966,16 +998,16 @@ Checks def path(self): return self._db_path - :class:`ItemsException` Item and :class:`ItemsDB` are then provided in - :file:`src/items/__init__.py`: + :class:`TasksException` Task and :class:`TasksDB` are then provided in + :file:`src/cusy/tasks/__init__.py`: .. code-block:: python - from .api import ItemsException, Item, ItemsDB + from .api import TasksException, Task, TasksDB .. seealso:: - You can find a complete example at `github.com/veit/items - `_. + You can find a complete example at `github.com/veit/cusy.tasks + `_. :doc:`/save-data/files-directories` ----------------------------------- diff --git a/docs/appendix/glossary.rst b/docs/appendix/glossary.rst index 08e00da5..09c046e4 100644 --- a/docs/appendix/glossary.rst +++ b/docs/appendix/glossary.rst @@ -8,7 +8,7 @@ Glossary User Acceptance Test Verification that software functions as intended from the user’s perspective and that users accept the software. Acceptance tests are - primarily used in :term:` Extreme Programming`. + primarily used in :term:`Extreme Programming`. Argument A value that is passed to a function. There are two types of arguments: @@ -568,7 +568,7 @@ Glossary * :doc:`Python4DataScience:productive/envs/uv/cicd` * :doc:`Python4DataScience:productive/envs/uv/claude-cursor` * :doc:`Python4DataScience:productive/envs/uv/docker` - * Check vulnerabilities with :ref:`uv-secure ` + * Check vulnerabilities with :ref:`uv audit ` .. seealso:: * `Docs `__ @@ -630,8 +630,11 @@ Glossary * :ref:`wheels` whey - Simple Python :term:`wheel` builder with automation options for - :term:`trove-classifiers`. + A simulated version of a class or method. It has a functional + implementation but uses a kind of shortcut, making it unsuitable for + production use. For example, an in-memory database might be used for + testing purposes but not in production. In this context, the in-memory + database would act as a fake. .. _end-packaging: @@ -650,6 +653,20 @@ Glossary :doc:`Python4DataScience:productive/qa/pysa` and :doc:`Python4DataScience:productive/qa/wily`. + .. note:: + We also use static code analysis and linting for the generated code + in agent-based software development: + + .. code-block:: md + :caption: AGENTS.md + + # Code quality and linting + - Use ruff, ty, prek, wily for code quality and linting. + - Run appropriate tooling after making changes to your code to ensure it meets quality standards. + + .. seealso:: + * :ref:`agentic-software-engineering:code-quality` + Dynamic test procedures are used to find errors when executing the source code. A distinction is made between :term:`whitebox ` and :term:`blackbox @@ -668,7 +685,7 @@ Glossary :doc:`/test/unittest` supports you in the automation of tests. :doc:`/test/mock` - allows you to create and use mock objects. + allows you to create and use :term:`Mock` objects. :doc:`../document/doctest` allows you to test tests written in Python :term:`docstrings `. @@ -689,23 +706,41 @@ Glossary platforms. Dummy - Object that is passed around but never actually used. Normally dummies - are only used to fill :term:`parameter` lists. + An object that is passed along but is not intended for use in your tests. + It has no effect whatsoever on the behaviour of your tests. An example of + a dummy could be an attribute that is required to instantiate a class but + is not needed for the test. ``except`` Keyword used to intercept an :term:`exception` and handle it carefully. Fake - Object that has an implementation that actually works, but usually takes - a shortcut that makes it unsuitable for production. + A simulated version of a class or method. It has a functional + implementation but uses a kind of shortcut, making it unsuitable for + production use. For example, an in-memory database might be used for + testing purposes but not in production. In this context, the in-memory + database would act as a fake. Integration test Tests that check whether the different parts of the software work together as expected. Mock - Objects programmed with :term:`exceptions ` that form a - specification of the calls you are likely to receive. + A mock simulates attributes, classes and methods. This enables developers + to + + + * test certain aspects of their code more effectively + * test in a controlled environment + * test for external dependencies + + Unlike :term:`stubs `, a mock can be used not only to verify the + result, but also to check **how** the result was achieved or whether the + correct methods were called. + + The Python library for mocks is :doc:`unittest.mock <../test/mock>`. It + is also supported by :doc:`../test/pytest/index`. Alternatively, however, + you can also use `pytest-mock `_. .. seealso:: * `Mock object `_ @@ -720,30 +755,33 @@ Glossary Tests to protect against new errors or regressions that may occur as a result of new software and updates. - Stubs - provide ready-made responses to calls made during the test and usually - do not react at all to anything that has not been programmed for the - test. + Spy + Spies are used to wrap real objects and, by default, forward all method + calls to the original object. They therefore intercept all calls to a + real object and log them. This makes it possible to monitor calls to the + original object (for example, how often a particular method has been + called) without replacing the original object (as a :term:`mock` would + do, for instance). + + Stub + A non-real object with pre-programmed behaviour. In most cases, stubs + simply return fixed values, known as *canned data*. Test-driven development TDD - A technique for creating software that guides software development by + A software development technique that guides the development process by writing tests. It was developed in the late 1990s by Kent Beck as part of - Extreme Programming. Essentially, it involves repeating three simple - steps: + :term:`Extreme Programming`. Essentially, it involves repeating three + simple steps: * Write a test for the next feature to be added. * Write the function code until the test passes. - * Refactor both the new and old code to make it well structured. + * Revise both the new and the old code to ensure it is well structured. - Although these three steps, often summarised as *‘red – green – - refactor’*, form the core of the process, there is also an important - first step, in which a list of test cases is created. One of these tests - is then selected, *‘Red – Green – Refactor’* is applied to it, and the - next test is selected. During the process, further tests are added to - this list. + These three steps are often summarised as *‘Red – Green – Refactor’*. .. seealso:: + * :doc:`../test/tdd` * `Canon TDD `_ by Kent Beck * `Test-driven development by example diff --git a/docs/conf.py b/docs/conf.py index 76691d70..9beecc67 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -12,6 +12,8 @@ # All configuration values have a default; values that are commented out # serve to show the default. +import os + # 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 @@ -19,7 +21,6 @@ # # import sys # sys.path.insert(0, os.path.abspath(".")) -import os import re # Set canonical URL from the Read the Docs Domain @@ -62,6 +63,10 @@ "python3.14": ("https://docs.python.org/3.14/", None), "jupyter-tutorial": ("https://jupyter-tutorial.readthedocs.io/en/latest/", None), "Python4DataScience": ("https://www.python4data.science/en/latest/", None), + "agentic-software-engineering": ( + "https://agentic-software-engineering.readthedocs.io/en/latest/", + None, + ), "pytest": ("https://docs.pytest.org/en/latest/", None), "Sybil": ("https://sybil.readthedocs.io/en/latest/", None), } diff --git a/docs/control-flow/conditional.rst b/docs/control-flow/conditional.rst index 413c0578..46afd76b 100644 --- a/docs/control-flow/conditional.rst +++ b/docs/control-flow/conditional.rst @@ -38,3 +38,22 @@ Line 5 Line 8 The optional ``else`` clause denotes a code block that is only executed if the other conditional blocks, ``if`` and ``elif``, are all false. + +Furthermore, the conditions can be linked logically, for example: + +.. code-block:: pycon + + >>> if ( + ... len(filename) > 11 + ... and filename.startswith("pylock.") + ... and filename.endswith(".toml") + ... ): + ... name = filename.removeprefix("pylock.").removesuffix(".toml") + ... + +Checks +------ + +* If you want to check whether a filename starts with ``pylock.`` and ends with + ``.toml``, what method would you use to retrieve the name of the environment + it might contain, for example, ``prod`` in ``pylock.prod.toml``? diff --git a/docs/control-flow/with.rst b/docs/control-flow/with.rst index a4172bdd..b0f7d798 100644 --- a/docs/control-flow/with.rst +++ b/docs/control-flow/with.rst @@ -3,10 +3,10 @@ Context management with ``with`` A more rational way to encapsulate the ``try``-``except``-``finally`` pattern is to use the keyword ``with`` and a context manager. Python defines context -managers for things like accessing :doc:`files ` and custom -context managers. One advantage of context managers is that they can define -cleanup actions that are always executed, regardless of whether an exception -occurs or not. +managers for things like accessing :doc:`files ` +and custom context managers. One advantage of context managers is that they can +define cleanup actions that are always executed, regardless of whether an +exception occurs or not. .. seealso:: :doc:`python3:library/contextlib` @@ -30,7 +30,7 @@ closed. This code is equivalent to this code: :linenos: .. seealso:: - * :doc:`../save-data/files` + * :doc:`../save-data/files-directories` Locking ------- diff --git a/docs/document/doctest.rst b/docs/document/doctest.rst index 96242992..9a54c6bc 100644 --- a/docs/document/doctest.rst +++ b/docs/document/doctest.rst @@ -115,6 +115,17 @@ in a docstring or in another text file are fulfilled. :lines: 38- :lineno-start: 38 +.. note:: + We also have doctests written for agent-based software development: + + .. code-block:: md + :caption: AGENTS.md + + - Include doctests in the docstrings of your functions to provide examples + + .. seealso:: + * :ref:`agentic-software-engineering:documentation` + .. seealso:: :doc:`doctest ` can also be used to continuous test the documentation: :ref:`ci-docs`. diff --git a/docs/document/shot-scraper.rst b/docs/document/shot-scraper.rst index ab2ce741..42d74e57 100644 --- a/docs/document/shot-scraper.rst +++ b/docs/document/shot-scraper.rst @@ -9,8 +9,8 @@ Installation .. code-block:: console - $ python -m pip install shot-scraper - $ shot-scraper install + $ uv add --group docs shot-scraper + $ uv run shot-scraper install .. note:: The second line installs the required browser. diff --git a/docs/document/sphinx/docstrings.rst b/docs/document/sphinx/docstrings.rst index 14c4e12f..f0a40bf5 100644 --- a/docs/document/sphinx/docstrings.rst +++ b/docs/document/sphinx/docstrings.rst @@ -141,6 +141,40 @@ NumPy Style Guide: """ return True +.. tip:: + You can have your docstring syntax checked using the following :doc:`ruff + ` configuration: + + .. code-block:: toml + :caption: pyproject.toml + + [tool.ruff] + lint.pydocstyle.convention = "google" + + or: + + .. code-block:: toml + :caption: pyproject.toml + + [tool.ruff] + lint.pydocstyle.convention = "numpy" + + .. seealso:: + * `lint.pydocstyle.convention + `_ + +.. note:: + We also frequently use the NumPy Style Guide in agent-based software + development: + + .. code-block:: md + :caption: AGENTS.md + + - Use numpy-style docstrings for all functions and classes you create. + + .. seealso:: + * :ref:`agentic-software-engineering:documentation` + .. _napoleon: ``sphinx.ext.napoleon`` diff --git a/docs/document/sphinx/intersphinx.rst b/docs/document/sphinx/intersphinx.rst index 0757272d..9ed623a2 100644 --- a/docs/document/sphinx/intersphinx.rst +++ b/docs/document/sphinx/intersphinx.rst @@ -82,7 +82,7 @@ The error can be corrected with: .. code-block:: console - $ python -m pip install sphobjinv + $ uv add --group docs sphobjinv #. Then the original file can be downloaded with: diff --git a/docs/document/sphinx/main.py b/docs/document/sphinx/main.py index 4465d4b2..0113c60a 100644 --- a/docs/document/sphinx/main.py +++ b/docs/document/sphinx/main.py @@ -6,7 +6,7 @@ app = FastAPI() -class Item(BaseModel): +class Task(BaseModel): name: str price: float is_offer: Optional[bool] = None @@ -17,11 +17,11 @@ def read_root(): return {"Hello": "World"} -@app.get("/items/{item_id}") -def read_item(item_id: int, q: Optional[str] = None): - return {"item_id": item_id, "q": q} +@app.get("/cusy.tasks/{task_id}") +def read_task(task_id: int, q: Optional[str] = None): + return {"task_id": task_id, "q": q} -@app.put("/items/{item_id}") -def update_item(item_id: int, item: Item): - return {"item_name": item.name, "item_id": item_id} +@app.put("/cusy.tasks/{task_id}") +def update_task(task_id: int, task: Task): + return {"task_name": task.name, "task_id": task_id} diff --git a/docs/document/sphinx/main.py.orig b/docs/document/sphinx/main.py.orig index dbe90939..59f875a2 100644 --- a/docs/document/sphinx/main.py.orig +++ b/docs/document/sphinx/main.py.orig @@ -1,15 +1,12 @@ from typing import Optional - from fastapi import FastAPI app = FastAPI() - @app.get("/") def read_root(): return {"Hello": "World"} - -@app.get("/items/{item_id}") -def read_item(item_id: int, q: Optional[str] = None): - return {"item_id": item_id, "q": q} +@app.get("/cusy.tasks/{task_id}") +def read_task(task_id: int, q: Optional[str] = None): + return {"task_id": task_id, "q": q} diff --git a/docs/document/sphinx/start.rst b/docs/document/sphinx/start.rst index 10eeb541..731a98f1 100644 --- a/docs/document/sphinx/start.rst +++ b/docs/document/sphinx/start.rst @@ -8,105 +8,46 @@ Installation and start .. tab:: Linux/macOS - .. code-block:: console - - $ mkdir docs_proj - $ cd docs_proj - $ python3 -m venv .venv - - .. tab:: Windows - - .. code-block:: ps1con - - C:> mkdir docs_proj - C:> cd docs_proj - C:> py -m venv .venv + $ uv init --package docs_proj + $ cd docs_proj #. Switch to the virtual environment and install Sphinx there: - .. tab:: Linux/macOS - - .. code-block:: console - - $ . .venv/bin/activate - $ (.venv) python -m pip install sphinx - Creating a virtualenv for this project… - … - - .. tab:: Windows - - .. code-block:: ps1con + .. code-block:: console - C:> .venv\Scripts\activate.bat - C:> (.venv) python -m pip install sphinx - Creating a virtualenv for this project… - … + $ uv add --group docs sphinx #. Create your Sphinx documentation project: - .. tab:: Linux/macOS + .. code-block:: console - .. code-block:: console - - $ sphinx-quickstart docs - Selected root path: docs - > Separate source and build directories (y/n) [n]: - > Name prefix for templates and static dir [_]: - > Project name: my.package - > Author name(s): Veit Schiele - > Project release []: 1.0 - > Project language [en]: - > Source file suffix [.rst]: - > Name of your master document (without suffix) [index]: - > autodoc: automatically insert docstrings from modules (y/n) [n]: y - > doctest: automatically test code snippets in doctest blocks (y/n) [n]: y - > intersphinx: link between Sphinx documentation of different projects (y/n) [n]: y - > todo: write "todo" entries that can be shown or hidden on build (y/n) [n]: y - > coverage: checks for documentation coverage (y/n) [n]: - > imgmath: include math, rendered as PNG or SVG images (y/n) [n]: - > mathjax: include math, rendered in the browser by MathJax (y/n) [n]: - > ifconfig: conditional inclusion of content based on config values (y/n) [n]: - > viewcode: include links to the source code of documented Python objects (y/n) [n]: y - > githubpages: create .nojekyll file to publish the document on GitHub pages (y/n) [n]: - > Create Makefile? (y/n) [y]: - > Create Windows command file? (y/n) [y]: - - Creating file docs/source/conf.py. - Creating file docs/source/index.rst. - Creating file docs/Makefile. - Creating file docs/make.bat. - - .. tab:: Windows - - .. code-block:: ps1con - - C:> sphinx-quickstart docs - Selected root path: docs - > Separate source and build directories (y/n) [n]: - > Name prefix for templates and static dir [_]: - > Project name: my.package - > Author name(s): Veit Schiele - > Project release []: 1.0 - > Project language [en]: - > Source file suffix [.rst]: - > Name of your master document (without suffix) [index]: - > autodoc: automatically insert docstrings from modules (y/n) [n]: y - > doctest: automatically test code snippets in doctest blocks (y/n) [n]: y - > intersphinx: link between Sphinx documentation of different projects (y/n) [n]: y - > todo: write "todo" entries that can be shown or hidden on build (y/n) [n]: y - > coverage: checks for documentation coverage (y/n) [n]: - > imgmath: include math, rendered as PNG or SVG images (y/n) [n]: - > mathjax: include math, rendered in the browser by MathJax (y/n) [n]: - > ifconfig: conditional inclusion of content based on config values (y/n) [n]: - > viewcode: include links to the source code of documented Python objects (y/n) [n]: y - > githubpages: create .nojekyll file to publish the document on GitHub pages (y/n) [n]: - > Create Makefile? (y/n) [y]: - > Create Windows command file? (y/n) [y]: - - Creating file docs\conf.py. - Creating file docs\index.rst. - Creating file docs\Makefile. - Creating file docs\make.bat. + $ uv run sphinx-quickstart docs + Selected root path: docs + > Separate source and build directories (y/n) [n]: + > Name prefix for templates and static dir [_]: + > Project name: my.package + > Author name(s): Veit Schiele + > Project release []: 1.0 + > Project language [en]: + > Source file suffix [.rst]: + > Name of your master document (without suffix) [index]: + > autodoc: automatically insert docstrings from modules (y/n) [n]: y + > doctest: automatically test code snippets in doctest blocks (y/n) [n]: y + > intersphinx: link between Sphinx documentation of different projects (y/n) [n]: y + > todo: write "todo" entries that can be shown or hidden on build (y/n) [n]: y + > coverage: checks for documentation coverage (y/n) [n]: + > imgmath: include math, rendered as PNG or SVG images (y/n) [n]: + > mathjax: include math, rendered in the browser by MathJax (y/n) [n]: + > ifconfig: conditional inclusion of content based on config values (y/n) [n]: + > viewcode: include links to the source code of documented Python objects (y/n) [n]: y + > githubpages: create .nojekyll file to publish the document on GitHub pages (y/n) [n]: + > Create Makefile? (y/n) [y]: + > Create Windows command file? (y/n) [y]: + + Creating file docs/source/conf.py. + Creating file docs/source/index.rst. + Creating file docs/Makefile. + Creating file docs/make.bat. Sphinx layout ------------- diff --git a/docs/document/sphinx/test.rst b/docs/document/sphinx/test.rst index a85ede2c..d877ddda 100644 --- a/docs/document/sphinx/test.rst +++ b/docs/document/sphinx/test.rst @@ -100,7 +100,7 @@ whether the documentation is being built and the links are valid. In [testenv:docs] # Keep base_python in sync with ci.yml and .readthedocs.yaml. - base_python = py312 + base_python = py314 dependency_groups = docs commands = sphinx-build -n -T -W -b html -d {envtmpdir}/doctrees docs docs/_build/html @@ -117,23 +117,26 @@ You can then define the following jobs for GitHub, for example: docs: name: Build docs and run doctests - needs: build-package runs-on: ubuntu-latest + needs: build-package + steps: - - name: Download pre-built packages - uses: actions/download-artifact@v4 - with: - name: Packages - path: dist - - run: tar xf dist/*.tar.gz --strip-components=1 - - - uses: actions/setup-python@v5 - with: - # Keep in sync with tox.ini/docs and .readthedocs.yaml - python-version: "3.12" - cache: pip - - run: python -m pip install tox - - run: python -m tox run -e docs + - name: Download pre-built packages + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: Packages + path: dist + - run: tar xf dist/*.tar.gz --strip-components=1 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + # Keep in sync with tox.ini/docs & .readthedocs.yaml + python-version: "3.14" + - uses: hynek/setup-cached-uv@4300ec2180bc77d705e626a34e381b81a4772c51 # v2.5.0 + + - run: > + uvx --with=tox-uv + tox run + -e docs reST formatting --------------- @@ -148,7 +151,7 @@ format can be checked with `sphinx-lint :caption: .pre-commit-config.yaml - repo: https://github.com/sphinx-contrib/sphinx-lint - rev: v1.0.0 + rev: c883505f64b59c3c5c9375191e4ad9f98e727ccd # v1.0.2 hooks: - id: sphinx-lint types: [rst] @@ -197,7 +200,7 @@ via the :doc:`pre-commit .. code-block:: yaml - repo: https://github.com/adamchainz/blacken-docs - rev: "v1.12.1" + rev: 7ae9389351f4090e3993de28015a05a18ca6b8a7 # v1.12.1 hooks: - id: blacken-docs additional_dependencies: diff --git a/docs/document/sphinx/uml/index.rst b/docs/document/sphinx/uml/index.rst index e77d1bc5..dc5e9e71 100644 --- a/docs/document/sphinx/uml/index.rst +++ b/docs/document/sphinx/uml/index.rst @@ -27,23 +27,9 @@ Installation #. Install `sphinxcontrib-plantuml `_: - .. tab:: Linux - - .. code-block:: console - - $ python -m pip install sphinxcontrib-plantuml - - .. tab:: macOS - - .. code-block:: console - - $ python -m pip install sphinxcontrib-plantuml - - .. tab:: Windows - - .. code-block:: ps1con + .. code-block:: console - C:> python -m pip install sphinxcontrib-plantuml + $ uv add --grpup docs sphinxcontrib-plantuml #. Configure Sphinx in the ``conf.py`` file: diff --git a/docs/editors.rst b/docs/editors.rst index 8a273c55..c819b9d4 100644 --- a/docs/editors.rst +++ b/docs/editors.rst @@ -18,7 +18,7 @@ file as a module. .. code-block:: console $ python3 - Python 3.13.0 (main, Oct 7 2024, 05:02:14) on linux + Python 3.14.0 (main, Oct 14 2025, 21:10:22) [Clang 20.1.4 ] on linux Type "help", "copyright", "credits" or "license" for more information. >>> @@ -30,7 +30,7 @@ file as a module. $ python3 - Python 3.13.0 (main, Oct 7 2024, 05:02:14) [Clang 15.0.0 (clang-1500.1.0.2.5)] on darwin + Python 3.14.0 (main, Oct 14 2025, 21:10:22) [Clang 20.1.4 ] on darwin Type "help", "copyright", "credits" or "license" for more information. >>> @@ -42,11 +42,11 @@ file as a module. .. tab:: Windows You can start the interactive Python shell in :menuselection:`Start --> - Applications --> Python 3.13`. + Applications --> Python 3.14`. Alternatively, you can search for the directly executable file :file:`Python.exe`, for example in - :file:`C:\\Users\\VEIT\\AppData\\Local\\Programs\\Python\\Python310-64` and + :file:`C:\\Users\\VEIT\\AppData\\Local\\Programs\\Python\\Python314-64` and then double-click. You can scroll through previous entries with the arrow keys :kbd:`Home`, @@ -74,7 +74,7 @@ code editing and debugging. It is very easy to run on the various platforms: .. code-block:: console - $ idle-python3.13 + $ idle-python3.14 .. tab:: Windows diff --git a/docs/install.rst b/docs/install.rst index 607b8047..df08c006 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -3,7 +3,7 @@ Installation Installing Python can be easy. The first step is to download the latest version from `www.python.org/downloads `_. The -tutorial is based on Python 3.13.0, but if you have Python 3.10 or newer +tutorial is based on Python 3.14.0, but if you have Python 3.10 or newer installed, this should not be a problem. .. tab:: Linux @@ -19,9 +19,9 @@ installed, this should not be a problem. .. code-block:: console - $ wget https://www.python.org/ftp/python/3.12.4/Python-3.13.0.tgz - $ tar xf Python-3.13.0.tgz - $ cd Python-3.13.0 + $ wget https://www.python.org/ftp/python/3.14.0/Python-3.14.0.tgz + $ tar xf Python-3.14.0.tgz + $ cd Python-3.14.0 $ ./configure --enable-optimizations $ sudo make altinstall @@ -51,11 +51,11 @@ installed, this should not be a problem. #. Download the latest Python Releases for Windows installer, for example `Windows installer (64-bit) - `_. + `_. #. Start the installation programme. If you have the necessary authorisations, install Python with the option *Install launcher for all users*. This should install Python in - :file:`C:\\Program Files\\Python313-64`. In addition, *Add Python 3.13 to + :file:`C:\\Program Files\\Python314-64`. In addition, *Add Python 3.14 to PATH* should be activated so that this path to the Python installation is also entered in the list of ``PATH`` environment variables. #. Finally, you can now check the installation by entering the following in @@ -64,7 +64,7 @@ installed, this should not be a problem. .. code-block:: ps1con C:\> python -V - Python 3.13.0 + Python 3.14.0 .. warning:: One disadvantage is that you have to return to the website regularly to @@ -75,8 +75,8 @@ installed, this should not be a problem. If different Python versions are required, for example to test libraries with :doc:`test/tox`, I use :term:`uv`. This allows not only older CPython versions to be installed but also `PyPy `_ or free-threaded Python -3.13 with ``uv python install pypy@3.12`` or ``uv python install ---python-preference only-managed 3.13t``. +3.14 with ``uv python install pypy@3.14`` or ``uv python install +--python-preference only-managed 3.14t``. .. tip:: `direnv `_ allows you to set environment variables diff --git a/docs/libs/install.rst b/docs/libs/install.rst index 4879b610..c9fa25ab 100644 --- a/docs/libs/install.rst +++ b/docs/libs/install.rst @@ -84,8 +84,8 @@ two-step process: > .venv\Scripts\activate -#. Install Python packages only for this virtual environment, for example the - popular ``pandas`` library: +#. Install Python packages only for for this virtual environment, for example, + the popular ``pandas`` library: .. tab:: Linux/macOS @@ -126,20 +126,20 @@ The basic syntax of ``pip`` is quite simple: .. code-block:: console - $ python -m pip install pandas + (.venv) $ python -m pip install pandas If you want to specify a particular version of a package, you can simply append the version numbers: .. code-block:: console - $ python -m pip install pandas==2.2.2 + (.venv) $ python -m pip install pandas==2.2.2 or .. code-block:: console - $ python -m pip install "pandas>=2" + (.venv) $ python -m pip install "pandas>=2" Proxy server ~~~~~~~~~~~~ @@ -157,7 +157,7 @@ You can also save the proxy server permanently as an environment variable: .. code-block:: bash - export HTTP_PROXY=http://{USER_NAME}:{PASSWORD}@{PROXYSERVER_NAME}:{PORT} + $ export HTTP_PROXY=http://{USER_NAME}:{PASSWORD}@{PROXYSERVER_NAME}:{PORT} .. tab:: Windows @@ -165,26 +165,10 @@ You can also save the proxy server permanently as an environment variable: .. code-block:: ps1 - set HTTP_PROXY={PROXYSERVER_NAME}:{PORT} + > set HTTP_PROXY={PROXYSERVER_NAME}:{PORT} -Pinning the version numbers -~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -… of packages -::::::::::::: - -For a stable environment, it makes sense to specify the version numbers of the -dependencies. - -.. tip:: - In none of our library projects does so much happen that the :doc:`Git - history ` should mainly consist of - updates. We only restrict the version numbers to be used in the event of - problems. For apps, however, we specify the version numbers. - -To pin versions for our applications and maintain cross-platform lock files, we -use :ref:`uv`. ``uv`` also helps us maintain :ref:`reproducible Python -environments `. +Pinning … +~~~~~~~~~ … of Python ::::::::::: @@ -201,14 +185,75 @@ input for `setup-python `_: .. literalinclude:: ../../.github/workflows/ci.yml :caption: .github/workflows/ci.yml - :lines: 20-28 - :emphasize-lines: 9 + :lines: 14, 29-35, 37-40 + :emphasize-lines: 12 In our :doc:`Python4DataScience:productive/git/advanced/gitlab/ci-cd/index` pipelines, however, we use ``requires-python`` from the :ref:`pyproject-toml` file to build :doc:`Docker containers with the appropriate Python version `. +… of packages +::::::::::::: + +To ensure a stable environment, it is advisable to specify the exact versions of +the dependencies. + +.. tip:: + In none of our library projects does so much happen that the :doc:`Git + history ` should mainly consist of + updates. We only restrict the version numbers to be used in the event of + problems. For apps, however, we specify the version numbers. + +To pin versions for our applications and maintain cross-platform lock files, we +usually use :ref:`uv`. ``uv`` also helps us to ensure :ref:`reproducible Python +environments `. + +However, packages can also be pinned using pip≥26.1: + +.. code-block:: + + $ python -m pip install --upgrade pip + $ python -m pip --version + +:pep:`751` defined the format for the :file:`pylock.toml` file, which can be +generated using the following command, for example: + +.. code-block:: console + + $ python -m pip lock -e . -o pylock.prod.toml + +Alternatively, :file:`a pylock.toml` file can be generated from a +:file:`requirements.txt` file: + +.. code-block:: console + + $ python -m pip lock -r requirements.txt -o pylock.prod.toml + +.. warning:: + In both cases, however, only the packages for the current platform and Python + version are specified. + +.. tip:: + If ``pip`` is not the only tool available, the cross-platform output of ``uv + export`` is the smoother option. + +The packages specified in :samp:`pylock{.NAME}.toml` can then be installed in a +different environment using: + +.. code-block:: console + + $ python -m pip install -r pylock.prod.toml --no-deps + +``--no-deps`` + ensures that transitive dependencies are not resolved in addition to the + :samp:`pylock{.NAME}.toml`. + +.. seealso:: + * `pip lock `_ + * `pylock.toml Specification + `_ + .. _uv: ``uv`` @@ -217,11 +262,25 @@ file to build :doc:`Docker containers with the appropriate Python version :term:`uv` simplifies the creation of an initial project structure and the management of your dependencies. +.. note:: + Many coding agents typically use ``pip`` when installing packages or running + scripts. We therefore need to configure them first to use ``uv``: + + .. code-block:: md + :caption: AGENTS.md + + - Use `uv` to manage Python environments and dependencies. + - Use `uv run` to execute Python scripts and commands. + - Don't edit `pyproject.toml` directly. Instead, use `uv add` and `uv add --dev` to manage dependencies. + + .. seealso:: + * :ref:`agentic-software-engineering:uv` + Installation ~~~~~~~~~~~~ -``uv`` ``uv`` does not depend on Python. Pre-compiled, standalone binaries can -be installed on Linux, macOS and Windows: +``uv`` does not depend on Python. Pre-compiled, standalone binaries can be +installed on Linux, macOS and Windows: .. tab:: Linux/macOS @@ -252,14 +311,31 @@ following steps: $ echo 'eval "$(uv generate-shell-completion bash)"' >> ~/.bashrc $ echo 'eval "$(uv generate-shell-completion zsh)"' >> ~/.zshrc - $ echo 'uv generate-shell-completion fish | source' >> ~/.config/fish/config.fish + $ echo 'uv generate-shell-completion fish | source' > ~/.config/fish/completions/uv.fish $ echo 'eval (uv generate-shell-completion elvish | slurp)' >> ~/.elvish/rc.elv + .. code-block:: console + + $ echo 'eval "$(uvx --generate-shell-completion bash)"' >> ~/.bashrc + $ echo 'eval "$(uvx --generate-shell-completion zsh)"' >> ~/.zshrc + $ echo 'uvx --generate-shell-completion fish | source' > ~/.config/fish/completions/uvx.fish + $ echo 'eval (uvx --generate-shell-completion elvish | slurp)' >> ~/.elvish/rc.elv + .. tab:: Windows + .. code-block:: ps1 + if (!(Test-Path -Path $PROFILE)) { + New-Item -ItemType File -Path $PROFILE -Force + } Add-Content -Path $PROFILE -Value '(& uv generate-shell-completion powershell) | Out-String | Invoke-Expression' + + .. code-block:: ps1 + + if (!(Test-Path -Path $PROFILE)) { + New-Item -ItemType File -Path $PROFILE -Force + } Add-Content -Path $PROFILE -Value '(& uvx --generate-shell-completion powershell) | Out-String | Invoke-Expression' Then restart the shell or call up ``source`` with your shell configuration file. @@ -280,12 +356,12 @@ Python installation The current Python version can be installed with ``uv python install``. Alternatively, a specific version can be installed with :samp:`uv python install -{3.12}`. However, you can install not only older CPython versions, but also -`PyPy `_ with :samp:`uv python install pypy@{3.12}` or +{3.14}`. However, you can install not only older CPython versions, but also +`PyPy `_ with :samp:`uv python install pypy@{3.14}` or Free-threaded Python with :samp:`uv python install --python-preferenc -only-managed {3.13t}`. You can see the Python versions that are already +only-managed {3.14t}`. You can see the Python versions that are already installed with ``uv python list``. You can call up an installed Python version -with :samp:`uv run --python {3.12} python`. +with :samp:`uv run --python {3.14} python`. Create project structure ~~~~~~~~~~~~~~~~~~~~~~~~ @@ -293,3 +369,24 @@ Create project structure Depending on whether you want to create a :doc:`library <../packs/distribution>` or an :doc:`application <../packs/apps>`, ``uv`` can create a suitable project structure. + +Installing dependencies +~~~~~~~~~~~~~~~~~~~~~~~ + +Using ``uv sync --frozen`` you can install your project’s dependencies in the +exact variants specified in the :file:`uv.lock` file. + +.. seealso:: + * :ref:`uv_lock` + * :ref:`reproduce-virtual-env` + +Using :samp:`uv pip install --pylock pylock{.NAME}.toml`, you can also install +dependencies from an existing :samp:`pylock{.NAME}.toml` file. + +Adding dependencies +~~~~~~~~~~~~~~~~~~~ + +Using :samp:`uv add {PACKAGE}`, you can add further dependencies to your +project. This adds the package to the ``dependencies`` section of the +:file:`pyproject.toml` file and writes the exact variant to the :file:`uv.lock` +file. diff --git a/docs/logging/index.rst b/docs/logging/index.rst index 13951ab0..8f027e2c 100644 --- a/docs/logging/index.rst +++ b/docs/logging/index.rst @@ -37,6 +37,21 @@ What are the advantages of ``logging`` over ``print``? `logging.disabled `_. + +.. note:: + We also use logging in agent-based software development to gain a more + detailed insight into errors: + + .. code-block:: md + :caption: AGENTS.md + + # Logging + - Use logging to provide insight into failures. Don’t use print for debugging. Don’t use logging to hide stack traces if you are going to fail anyway. + - Don't hide exceptions. Let them propagate up to the caller. If you need to catch an exception, log it and re-raise it. + + .. seealso:: + * :ref:`agentic-software-engineering:logging` + .. seealso:: * `loguru `_, which makes logging almost as diff --git a/docs/modules/index.rst b/docs/modules/index.rst index 25338559..0115a528 100644 --- a/docs/modules/index.rst +++ b/docs/modules/index.rst @@ -87,7 +87,7 @@ Lines 25 to 26 >>> import wc, importlib >>> importlib.reload(wc) - + * as a script it is executed with the name ``__main__`` and the function ``words_occur()```` is called: diff --git a/docs/oop/dataclasses.rst b/docs/oop/dataclasses.rst index 25b70f33..be3373ae 100644 --- a/docs/oop/dataclasses.rst +++ b/docs/oop/dataclasses.rst @@ -12,14 +12,14 @@ create such a class. matrices with numbers I use :doc:`Numpy `. -Let’s say we want to store a class that represents an item with ``summary``, +Let’s say we want to store a class that represents a task with ``summary``, ``owner``, ``state`` and ``id``. We can define such a class with: .. code-block:: pycon >>> from dataclasses import dataclass >>> @dataclass - ... class Item: + ... class Task: ... summary: str = None ... owner: str = None ... state: str = "todo" @@ -32,30 +32,30 @@ If I display the instance of the class, I get the class name and the attributes: .. code-block:: pycon >>> i1 - Item(summary='My first item', owner='veit', state='todo', id=1) + Task(summary='My first task', owner='veit', state='todo', id=1) In general, data classes are used as syntactic sugar for creating classes that store data. You can add extra functionality to your classes by defining methods. -We will add a method to the class that creates an Item object from a +We will add a method to the class that creates a Task object from a :doc:`Dict <../types/dicts>`: .. code-block:: pycon >>> @dataclass - ... class Item: + ... class Task: ... ... ... @classmethod ... def from_dict(cls, d): - ... return Item(**d) + ... return Task(**d) ... - >>> item_dict = { - ... "summary": "My first item", + >>> task_dict = { + ... "summary": "My first task", ... "owner": "veit", ... "state": "todo", ... "id": 1, ... } - >>> Item.from_dict(item_dict) - Item(summary='My first item', owner='veit', state='todo', id=1) + >>> Task.from_dict(task_dict) + Task(summary='My first task', owner='veit', state='todo', id=1) .. tip:: `cusy seminar: Advanced Python diff --git a/docs/packs/.github/workflows/build_wheels.yml b/docs/packs/.github/workflows/build_wheels.yml index 46552cd5..2de74cd8 100644 --- a/docs/packs/.github/workflows/build_wheels.yml +++ b/docs/packs/.github/workflows/build_wheels.yml @@ -1,10 +1,18 @@ name: Build on: - workflow_dispatch: + push: + branches: [main] release: types: - published + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: {} jobs: build_wheels: @@ -16,12 +24,14 @@ jobs: os: [ubuntu-latest, windows-latest, macos-13, macos-14] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Build wheels - uses: pypa/cibuildwheel@v2.21.3 + uses: pypa/cibuildwheel@8d2b08b68458a16aeb24b64e68a09ab1c8e82084 # v3.4.1 - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: cibw-wheels-${{ matrix.os }}-${{ strategy.job-index }} path: ./wheelhouse/*.whl diff --git a/docs/packs/.gitlab-ci.yml b/docs/packs/.gitlab-ci.yml index 8a685688..e33f4d4b 100644 --- a/docs/packs/.gitlab-ci.yml +++ b/docs/packs/.gitlab-ci.yml @@ -1,6 +1,5 @@ linux: - image: python:latest - # make a docker daemon available for cibuildwheel to use + image: python:3.13 services: - name: docker:dind entrypoint: ["env", "-u", "DOCKER_HOST"] @@ -10,36 +9,50 @@ linux: DOCKER_DRIVER: overlay2 # See https://github.com/docker-library/docker/pull/166 DOCKER_TLS_CERTDIR: "" + + # skip all but the basic tests + # (comment the below line in a PR to debug a Gitlab-specific issue) + PYTEST_ADDOPTS: -k "unit_test or test_0_basic" --suppress-no-test-exit-code + rules: + - if: '$CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH =~ /^gitlab/' + variables: + CIBW_ENABLE: "all" script: - curl -sSL https://get.docker.com/ | sh - - python -m pip install cibuildwheel==2.21.3 - - cibuildwheel --output-dir wheelhouse - artifacts: - paths: - - wheelhouse/ + - docker run --rm --privileged docker.io/tonistiigi/binfmt:latest --install all + - python -m pip install -U pip + - python -m pip install -e. pytest-custom-exit-code --group test + - python ./bin/run_tests.py windows: - image: mcr.microsoft.com/windows/servercore:1809 - before_script: - - choco install python -y --version 3.12.4 - - choco install git.install -y - - py -m pip install cibuildwheel==2.21.3 + variables: + PYTEST_ADDOPTS: -k "unit_test or test_0_basic" --suppress-no-test-exit-code + rules: + - if: '$CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH =~ /^gitlab/' + variables: + # Everything except graalpy. GraalPy is JVM-based and very slow to + # start; on the small (2-core) GitLab SaaS Windows runners, + # virtualenv's interpreter query for graalpy.exe times out, failing + # the build. (Other platforms run the full "all" group.) + CIBW_ENABLE: "cpython-prerelease pypy pypy-eol pyodide-eol pyodide-prerelease" script: - - py -m cibuildwheel --output-dir wheelhouse --platform windows - artifacts: - paths: - - wheelhouse/ + - python -m pip install -U pip + - python -m pip install -e. pytest-custom-exit-code --group test + - python bin\run_tests.py tags: - saas-windows-medium-amd64 macos: - image: macos-14-xcode-15 - before_script: - - python3 -m pip install cibuildwheel==2.21.3 + image: macos-15-xcode-16 + variables: + PYTEST_ADDOPTS: -k "unit_test or test_0_basic" --suppress-no-test-exit-code + rules: + - if: '$CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH =~ /^gitlab/' + variables: + CIBW_ENABLE: "all" script: - - python3 -m cibuildwheel --output-dir wheelhouse - artifacts: - paths: - - wheelhouse/ + - python3 -m pip install -U pip + - python3 -m pip install -e. pytest-custom-exit-code --group test + - python3 ./bin/run_tests.py tags: - saas-macos-medium-m1 diff --git a/docs/packs/apps.rst b/docs/packs/apps.rst index a87f1a1c..07cbf5f7 100644 --- a/docs/packs/apps.rst +++ b/docs/packs/apps.rst @@ -157,7 +157,7 @@ specific version. :caption: .pre-commit-config.yaml - repo: https://github.com/astral-sh/uv-pre-commit - rev: 0.5.21 + rev: 6a280ba12b7901e47757c868c8c13c6a624c9ecb # 0.11.7 hooks: - id: uv-lock diff --git a/docs/packs/binary-extensions.rst b/docs/packs/binary-extensions.rst index feef97cd..db2dd125 100644 --- a/docs/packs/binary-extensions.rst +++ b/docs/packs/binary-extensions.rst @@ -260,8 +260,8 @@ Alternatively, you can install our ``dataprep`` package and use ``mean``: .. code-block:: console - $ python -m pip install dataprep/dist/dataprep-0.1.0-cp39-cp39-macosx_10_9_x86_64.whl - $ python + $ uv add dataprep/dist/dataprep-0.1.0-cp39-cp39-macosx_10_9_x86_64.whl + $ uv run python .. code-block:: pycon diff --git a/docs/packs/cibuildwheel.rst b/docs/packs/cibuildwheel.rst index f0268204..34d8f9c3 100644 --- a/docs/packs/cibuildwheel.rst +++ b/docs/packs/cibuildwheel.rst @@ -28,7 +28,14 @@ Finally, the tests can also run against the wheels. .. literalinclude:: .github/workflows/build_wheels.yml :caption: .github/workflows/build_wheels.yml :language: yaml - :lines: 1-7 + :lines: 1-15 + + ``release`` + is executed when a tagged version is transferred. + + .. seealso:: + * `release + `_ ``workflow_dispatch`` allows you to click a button in the graphical user interface to trigger @@ -39,18 +46,11 @@ Finally, the tests can also run against the wheels. * `workflow_dispatch `_ - ``release`` - is executed when a tagged version is transferred. - - .. seealso:: - * `release - `_ - Now the :term:`wheels ` can be built with: .. literalinclude:: .github/workflows/build_wheels.yml :language: yaml - :lines: 9-22 + :lines: 17-32 This runs the CI workflow with the following default settings: @@ -66,7 +66,7 @@ Finally, the tests can also run against the wheels. .. literalinclude:: .github/workflows/build_wheels.yml :language: yaml - :lines: 24- + :lines: 34- .. tab:: GitLab CI/CD diff --git a/docs/packs/dataprep/README.rst b/docs/packs/dataprep/README.rst index 0128df93..688a63c8 100644 --- a/docs/packs/dataprep/README.rst +++ b/docs/packs/dataprep/README.rst @@ -19,7 +19,7 @@ Quickstart .. code-block:: console - $ python3 -m pip install datagrep + $ uv add datagrep #. Import the datagrep modules, for example with: diff --git a/docs/packs/dataprep/pyproject.toml b/docs/packs/dataprep/pyproject.toml index 038e953b..70a5363d 100644 --- a/docs/packs/dataprep/pyproject.toml +++ b/docs/packs/dataprep/pyproject.toml @@ -10,11 +10,10 @@ readme = "README.rst" authors = [ { name = "Veit Schiele", email = "veit@cusy.io" }, ] -requires-python = ">=3.9" +requires-python = ">=3.10" classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -25,9 +24,8 @@ dependencies = [ "cython", "pandas", ] - urls."Bug Tracker" = "https://github.com/veit/dataprep/issues" -urls."Homepage" = "https://github.com/veit/dataprep" +urls.Homepage = "https://github.com/veit/dataprep" License-Expression = "BSD-3-Clause" License-File = [ "LICENSE" ] diff --git a/docs/packs/distribution.rst b/docs/packs/distribution.rst index fbdc8acd..e2b26092 100644 --- a/docs/packs/distribution.rst +++ b/docs/packs/distribution.rst @@ -274,6 +274,8 @@ as: Upload"``. :term:`PyPI` will always reject packages whose classifier starts with ``"Private ::"``. +.. _dependencies: + ``dependencies`` specifies the dependencies for your package in an array. @@ -324,25 +326,10 @@ take over all dependencies from ``docs`` and ``test`` in addition to You can install these dependency groups, for example with: -.. tab:: Linux/macOS - - .. code-block:: console - - $ cd /PATH/TO/YOUR/DISTRIBUTION_PACKAGE - $ python3 -m venv .venv - $ . .venv/bin/activate - $ python -m pip install --upgrade pip - $ python -m pip install --group dev - -.. tab:: Windows - - .. code-block:: ps1 +.. code-block:: console - > cd C:\PATH\TO\YOUR\DISTRIBUTION_PACKAGE - > python3 -m venv .venv - > .venv\Scripts\activate.bat - > python -m pip install --upgrade pip - > python -m pip install --group dev + $ cd /PATH/TO/YOUR/DISTRIBUTION_PACKAGE + $ uv sync --group dev .. seealso:: * :pep:`735` @@ -656,8 +643,8 @@ This file should contain information on .. seealso:: * GitHub DocsDokumentation: `Adding a security policy to your repository `_ - * `github.com/veit/items/security - `_ + * `github.com/veit/cusy.tasks/security + `_ Historical files or files needed for binary extensions diff --git a/docs/packs/myapp/pyproject.toml b/docs/packs/myapp/pyproject.toml index 446907f2..681b307b 100644 --- a/docs/packs/myapp/pyproject.toml +++ b/docs/packs/myapp/pyproject.toml @@ -16,6 +16,5 @@ classifiers = [ "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", ] -dependencies = [ ] - +dependencies = [] scripts.myapp = "myapp:main" diff --git a/docs/packs/mypack/pyproject.toml b/docs/packs/mypack/pyproject.toml index 5f5723e4..eeb00be2 100644 --- a/docs/packs/mypack/pyproject.toml +++ b/docs/packs/mypack/pyproject.toml @@ -16,6 +16,5 @@ classifiers = [ "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", ] -dependencies = [ ] - +dependencies = [] scripts.mypack = "mypack:main" diff --git a/docs/packs/publish.rst b/docs/packs/publish.rst index c6820441..1ec9328f 100644 --- a/docs/packs/publish.rst +++ b/docs/packs/publish.rst @@ -160,9 +160,9 @@ Finally, you can publish your package on PyPI: GitHub Action ------------- -You can also create a GitHub action, which creates a package and uploads it to -PyPI at every time a release is created. Such a -:file:`.github/workflows/pypi.yml` file could look like this: +You can also create a `GitHub Action `_, +which creates a package and uploads it to PyPI at every time a release is +created. Such a :file:`.github/workflows/pypi.yml` file could look like this: .. code-block:: yaml :caption: .github/workflows/pypi.yml @@ -183,16 +183,16 @@ PyPI at every time a release is created. Such a needs: [test] steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version-file: .python-version cache-dependency-path: '**/pyproject.toml' - name: Setup cached uv - uses: hynek/setup-cached-uv@v2 + uses: hynek/setup-cached-uv@4300ec2180bc77d705e626a34e381b81a4772c51 # v2.5.0 - name: Create venv run: | uv venv @@ -203,9 +203,9 @@ PyPI at every time a release is created. Such a - name: Retrieve and publish steps: - name: Retrieve release distributions - uses: actions/download-artifact@v4 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - name: Publish package distributions to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 with: username: __token__ password: ${{ secrets.PYPI_TOKEN }} @@ -218,26 +218,88 @@ Line 12 Line 31 Here :samp:`{mypack}` should be replaced by your package name. Line 36 - The GitHub action ``actions/download-artifact`` provides the built + The GitHub action `actions/download-artifact + `_ provides the built distribution packages. Lines 38–41 - The GitHub action ``pypa/gh-action-pypi-publish`` publishes the packages + The GitHub action `pypa/gh-action-pypi-publish + `_ publishes the packages with the upload token on :term:`PyPI`. .. seealso:: * `GitHub Actions `_ + * :doc:`cibuildwheel` + +.. _secure-release-workflow: + +Securing the release workflow +----------------------------- + +Continuous deployment systems used to publish Python packages are a popular +target for attacks. You can avoid many of these risks by following a few +security recommendations: + +Avoid insecure triggers + Workflows that can be triggered by an attacker, particularly those that rely + on inputs controlled by the attacker (such as :ref:`pull request + ` or :doc:`branch + ` titles), have been used in the + past to inject commands. In particular, the `pull_request_target + `_ + trigger in :ref:`github-actions` should be avoided. + + .. seealso:: + * `Keeping your GitHub Actions and workflows secure Part 2: Untrusted + input + `_ + * `Mitigating Attack Vectors in GitHub Workflows: Workflow triggers + `_ + * `Misuse of pull_request_target trigger + `_ + +Sanitise parameters and inputs + Any workflow parameter or input that can be expanded into an executable + command has the potential to be exploited in attacks. Sanitise values by + passing them to commands as environment variables to prevent + `template injection `_ + attacks. +Avoid mutable references + Fix your dependencies in workflows. + + * Prefer Git commit `SHA + `_ values over + :doc:`Git tags `, as tags are + mutable. + + .. tip:: + * Für die Aktualisierung der GitHub Actions und `Dependency Cooldowns + `_ + könnt ihr `pinact `_ + verwenden, also :abbr:`z. B. (zum Beispiel)`: + + .. code-block:: console + + $ pinact run -u --min-age 7 + + * Use a :ref:`uv_lock` file for PyPI dependencies used in workflows. + +Use verifiable deployments + With :ref:`trusted_publishers`, you can use verifiable GitHub environments + to build your Python packages. If you use GitHub Actions for continuous + delivery, you should use :ref:`zizmorcore` to detect and fix insecure + workflows. .. _trusted_publishers: Trusted Publishers ------------------- +~~~~~~~~~~~~~~~~~~ `Trusted Publishers `_ is a procedure for publishing packages on the :term:`PyPI`. It is based on OpenID Connect and requires neither a password nor a token. Only the following steps are required: -#. Add a *Trusted Publishers* on PyPI +#. Add a *Trusted Publisher* on PyPI Depending on whether you want to publish a new package or update an existing one, the process is slightly different: @@ -276,7 +338,7 @@ requires neither a password nor a token. Only the following steps are required: .. code-block:: diff :caption: .github/workflows/pypi.yml :lineno-start: 10 - :emphasize-lines: 3, 4-5 + :emphasize-lines: 3-5 package-and-deploy: runs-on: ubuntu-latest @@ -292,24 +354,19 @@ requires neither a password nor a token. Only the following steps are required: Lines 13–14 The ``write`` authorisation is required for *Trusted Publishing*. - Zeilen 42–44 + Zeilen 40–44 ``username`` and ``password`` are no longer required for the GitHub action ``pypa/gh-action-pypi-publish``. - .. code-block:: diff + .. code-block:: yaml :lineno-start: 40 :emphasize-lines: 3- - - name: Publish package distributions to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - - with: - - username: __token__ - - password: ${{ secrets.PYPI_TOKEN }} - -.. _digital-attestations: - -Digital Attestations --------------------- + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + with: + username: __token__ + password: ${{ secrets.PYPI_TOKEN }} Since 14 November 2024, :term:`PyPI` also supports :pep:`740` with `Digital Attestations `_. PyPI uses the @@ -337,7 +394,7 @@ are used for publishing: id-token: write steps: - name: Publish package distributions to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 .. note:: Support for the automatic creation of digital attestations and publishing @@ -346,3 +403,57 @@ are used for publishing: .. seealso:: `PyPI now supports digital attestations `_ + +.. _zizmorcore: + +zizmor +~~~~~~ + +`zizmor `_ can detect and resolve many security issues +in typical GitHub Actions CI/CD configurations. zizmor is designed to integrate +with GitHub Actions. A typical GitHub Action we use for zizmor looks like this: + +.. code-block:: yaml + :caption: .github/workflows/zizmor.yml + + # https://github.com/woodruffw/zizmor + name: Zizmor + + on: + push: + branches: ["main"] + pull_request: + branches: ["**"] + + concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + + permissions: {} + + jobs: + zizmor: + name: Run zizmor + runs-on: ubuntu-latest + permissions: + security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files. + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Run zizmor + uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2 + with: + persona: pedantic + +.. _add_2fa: + +2FA for all development accounts +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You should use two-factor authentication for all your accounts related to +development – not just for :term:`PyPI`. Remember your version control accounts +(`GitHub `_, `GitLab `_, +`Codeberg `_, `Forgejo `_) and +email. diff --git a/docs/packs/pyproject.toml b/docs/packs/pyproject.toml index 59e72d9d..b61c7212 100644 --- a/docs/packs/pyproject.toml +++ b/docs/packs/pyproject.toml @@ -1,9 +1,7 @@ [tool.cibuildwheel] -test-requires = "pytest" -test-command = "pytest {project}/tests" build-verbosity = 1 - +test-command = "pytest {project}/tests" +test-requires = "pytest" # support Universal2 for Apple Silicon: -[tool.cibuildwheel.macos] -archs = [ "auto", "universal2" ] -test-skip = [ "*universal2:arm64" ] +macos.archs = [ "auto", "universal2" ] +macos.test-skip = [ "*universal2:arm64" ] diff --git a/docs/packs/templating/advanced.rst b/docs/packs/templating/advanced.rst index 7fd8d51c..17c42de1 100644 --- a/docs/packs/templating/advanced.rst +++ b/docs/packs/templating/advanced.rst @@ -19,7 +19,6 @@ Variables, for example, can be validated in a pre-generate hook: import re import sys - MODULE_REGEX = r"^[_a-zA-Z][_a-zA-Z0-9]+$" module_name = "{{ cookiecutter.module_name }}" diff --git a/docs/packs/templating/cruft.rst b/docs/packs/templating/cruft.rst index f30c77f7..5cc0b244 100644 --- a/docs/packs/templating/cruft.rst +++ b/docs/packs/templating/cruft.rst @@ -19,7 +19,7 @@ Installation .. code-block:: console - $ python -m pip install cruft + $ uv add --group dev cruft Create a new project -------------------- diff --git a/docs/packs/templating/install.rst b/docs/packs/templating/install.rst index 5a3966da..629e40e0 100644 --- a/docs/packs/templating/install.rst +++ b/docs/packs/templating/install.rst @@ -44,4 +44,4 @@ Installation .. code-block:: console - $ python -m pip install --user cookiecutter + $ uv init --package cookiecutter_env diff --git a/docs/save-data/psycopg.rst b/docs/save-data/psycopg.rst index e03d01fe..5c0e3f99 100644 --- a/docs/save-data/psycopg.rst +++ b/docs/save-data/psycopg.rst @@ -5,12 +5,14 @@ The psycopg module .. code-block:: console - $ python3 -m pip install psycopg - Collecting psycopg - Downloading psycopg-3.0.1-py3-none-any.whl (140 kB) - |████████████████████████████████| 140 kB 3.4 MB/s - Installing collected packages: psycopg - Successfully installed psycopg-3.0.1 + $ uv add psycopg + Resolved 3 packages in 4ms + Built psycopg-env @ file:///Users/veit/sandbox/psycopg_env + Prepared 1 package in 7ms + Uninstalled 1 package in 0.96ms + Installed 2 packages in 5ms + + psycopg==3.3.4 + ~ psycopg-env==0.1.0 (from file:///Users/veit/cusy/trn/python-basics-tutorial) #. Import the psycopg module diff --git a/docs/test/bdd.rst b/docs/test/bdd.rst new file mode 100644 index 00000000..79dc195a --- /dev/null +++ b/docs/test/bdd.rst @@ -0,0 +1,61 @@ +Behavior-Driven Development +=========================== + +Dan North began talking about Behaviour-Driven Development (BDD) in 2006 to +clear up misunderstandings about :doc:`tdd`: + + *“Programmers wanted to know where to start, what to test and what not to + test, how much to test in one go, what to call their tests, and how to + understand why a test fails.”* + +– `Introducing BDD `_ by Dan North + +He realised that it was clearer if he told them to test behaviours rather than +functions. More specifically: + +* Think of changes to the system as changes in behaviour. Deciding which test or + series of tests to write next is easier if you think in terms of behaviours. + What is the next most important behaviour? Write tests for that. +* Name your test functions after the expected behaviour, even if this makes the + test names sound almost like sentences. This makes it clear what is wrong if + the test fails. It also makes it clear which aspects you should be checking in + the test. If a test can fail for a reason that does not match the name of the + test, it should be a different test. +* Formulating or reformulating requirements as Given-When-Then (GWT) makes it + easier to write tests. + +Given-When-Then and Arrange-Act-Assert actually mean the same thing, but GWT +fits better with the behaviour-oriented mindset. Furthermore, :term:`acceptance +tests ` can be created very easily from requirements written as +Given-When-Then. + +BDD thus facilitates agile collaboration between development, quality assurance +and the product owner on a software project. In `Selling BDD to the Business +`_, Dan North +explores this process in more detail: + + *“BDD is a second-generation, outside–in, pull-based, multiple-stakeholder, + multiple-scale, high-automation, agile methodology. It describes a cycle of + interactions with well-defined outputs, resulting in the delivery of + working, tested software that matters.”* + +Gherkin +------- + +The structured acceptance criteria, which were written in plain language rather +than code, were further formalised using the Gherkin description language so +that they could be parsed automatically. + +.. code-block:: gherkin + + Scenario: Add a task to the database + Given an empty database + When a task with a summary is added + Then the number of tasks should be 1 + and the queried task from the db should correspond to the added object. + +Each scenario is intended to serve as an example illustrating a specific aspect +of the application’s behaviour. + +In Python, both `behave `_ and +`pytest-bdd `_ can parse Gherkin. diff --git a/docs/test/behave/example.rst b/docs/test/behave/example.rst deleted file mode 100644 index 063cf8de..00000000 --- a/docs/test/behave/example.rst +++ /dev/null @@ -1,61 +0,0 @@ -Example -======= - -#. After installing behave, we can create a file called :file:`install.feature` - in the :file:`features` directory with the following content: - - .. code-block:: gherkin - - Feature: showing off behave - - Scenario: run a simple test - Given we have behave installed - When we implement a test - Then behave will test it for us! - - .. seealso:: - `Features `_ - -#. Next, we create the file :file:`install.py` in the directory - :file:`features/steps`: - - .. code-block:: python - - from behave import * - - - @given("we have behave installed") - def step_impl(context): - pass - - - @when("we implement a test") - def step_impl(context): - assert True is not False - - - @then("behave will test it for us") - def step_impl(context): - assert context.failed is False - - .. seealso:: - `Python Step Implementations - `_ - -#. Call ``behave`` - - .. code-block:: console - - $ behave - USING RUNNER: behave.runner:Runner - Feature: showing off behave # features/install.feature:1 - - Scenario: run a simple test # features/install.feature:3 - Given we have behave installed # features/steps/install.py:3 0.000s - When we implement a test # features/steps/install.py:7 0.000s - Then behave will test it for us # features/steps/install.py:11 0.000s - - 1 feature passed, 0 failed, 0 skipped - 1 scenario passed, 0 failed, 0 skipped - 3 steps passed, 0 failed, 0 skipped - Took 0min 0.000s diff --git a/docs/test/behave/index.rst b/docs/test/behave/index.rst deleted file mode 100644 index 1906d2f6..00000000 --- a/docs/test/behave/index.rst +++ /dev/null @@ -1,59 +0,0 @@ -behave -====== - -`behave `_ implements behaviour-driven -development (:abbr:`BDD (Behavior-Driven Development)`). BDD is an agile -software development technique that promotes collaboration between development, -quality assurance and non-technical or business staff on a software project. The -term was originally coined in 2003 by `Daniel Terhorst-North -`_ in response to :term:`test-driven -development ` and encompasses :term:`acceptance testing -` or customer-test-driven development practices as found in -:term:`extreme programming `. In `Selling BDD to the -Business `_, he -gave the following definition: - - *‚BDD is a second-generation, outside–in, pull-based, multiple-stakeholder, - multiple-scale, high-automation, agile methodology. It describes a cycle of - interactions with well-defined outputs, resulting in the delivery of - working, tested software that matters.‘* - -Gherkin -------- - -Description language based on natural written language for the textual -specification of software requirements. Only certain keywords are predefined. - -.. code-block:: gherkin - - Scenario: Add an item to the database - Given an empty database - When an item with a summary is added - Then the number of items should be 1 - and the queried item from the db should correspond to the added object. - -Each scenario is an example intended to illustrate a specific aspect of the -application’s behaviour. - -Installation ------------- - -You can install behave in your :ref:`virtual environments ` with: - -.. tab:: Linux/macOS - - .. code-block:: console - - $ python -m pip install behave - -.. tab:: Windows - - .. code-block:: ps1con - - C:> python -m pip install behave - -.. toctree:: - :titlesonly: - :hidden: - - example diff --git a/docs/test/hypothesis.rst b/docs/test/hypothesis.rst index a8e3354e..291d374e 100644 --- a/docs/test/hypothesis.rst +++ b/docs/test/hypothesis.rst @@ -1,67 +1,102 @@ Hypothesis ========== -`Hypothesis `_ is a library for `property -testing `_ that -allows you to write :term:`parameterised ` tests from a source of -examples. It then generates simple and understandable examples that can be used -to make your tests fail and find bugs with little effort. +`Property testing +`_ is a testing +method that does not check whether specific inputs lead to specific outputs, but +instead generates random inputs, runs the programme with all of these inputs, +and then verifies the validity of this property. -#. Install Hypothesis: +In Python, you can use `Hypothesis `_ to +generate such inputs :term:`parametrically `, enabling you to quickly +find errors in your tests. - .. tab:: Linux/macOS +.. seealso:: + The :doc:`Jupyter Tutorial ` + describes how Hypothesis can also be used in Jupyter Notebooks. - .. code-block:: console +.. note:: + We also use Hypothesis in agent-based software development: - $ python -m pip install hypothesis + .. code-block:: md + :caption: AGENTS.md - .. tab:: Windows + - Use the `hypothesis` library for property-based testing when you have complex input spaces or need to test edge cases. - .. code-block:: ps1con + .. seealso:: + * :ref:`agentic-software-engineering:testing` - C:> python -m pip install hypothesis +Installation +------------ - Alternatively, Hypothesis can also be installed with extensions, for example: +.. tab:: Linux/macOS - .. tab:: Linux/macOS + .. code-block:: console - .. code-block:: console + $ uv add --group tests hypothesis - $ python -m pip install hypothesis[numpy,pandas] +.. tab:: Windows - .. tab:: Windows + .. code-block:: ps1con + + C:> uv add --group tests hypothesis + +Alternatively, Hypothesis can also be installed using extensions, for example: - .. code-block:: ps1con +.. tab:: Linux/macOS - C:> python -m pip install hypothesis[numpy,pandas] + .. code-block:: console -#. Write a test: + $ uv add --group tests "hypothesis[numpy, pandas]" - #. Imports: +.. tab:: Windows - .. literalinclude:: test_hypothesis.py - :language: python - :lines: 1-3 - :lineno-start: 1 + .. code-block:: ps1con - #. Test: + C:> uv add --group tests "hypothesis[numpy, pandas]" - .. literalinclude:: test_hypothesis.py - :language: python - :lines: 6- - :lineno-start: 6 +.. seealso: + * `First-party extensions + `_ -#. Perform test: +Example using ``strategies`` and ``given`` +------------------------------------------ + +#. First, we import sample data for `floats + `_ + and `lists + `_ + from `hypothesis.strategies + `_. To + be able to apply this sample data to our test function, we also import + `hypothesis.given + `_: + + .. literalinclude:: test_hypothesis.py + :language: python + :lines: 1-3 + :lineno-start: 1 + +#. For our test, we will now use ``hypothesis.given`` as a :doc:`dekorator + <../functions/decorators>` to convert the test function into a parameterised + one, which is then executed with a wide range of suitable data: + + .. literalinclude:: test_hypothesis.py + :language: python + :lines: 6- + :lineno-start: 6 + +#. Finally, we run the test: .. tab:: Linux/macOS .. code-block:: pytest - $ python -m pytest test_hypothesis.py + $ uv run pytest docs/test/test_hypothesis.py ============================= test session starts ============================== - platform darwin -- Python 3.13.0, pytest-8.3.3, pluggy-1.5.0 - rootdir: /Users/veit/cusy/trn/python-basics/docs/test - plugins: hypothesis-6.114.1 + platform darwin -- Python 3.14.0, pytest-9.0.3, pluggy-1.6.0 + rootdir: /Users/veit/cusy/trn/python-basics-tutorial-de + plugins: hypothesis-6.152.1 collected 1 item test_hypothesis.py F [100%] @@ -82,7 +117,7 @@ to make your tests fail and find bugs with little effort. mean = sum(ls) / len(ls) > assert min(ls) <= mean <= max(ls) E assert inf <= 1.7976931348623157e+308 - E + where 1.7976931348623157e+308 = max([9.9792015476736e+291, 1.7976931348623157e+308]) + E + where 1.7976931348623157e+308 = max([9.9792015476736e+291, 1.7976931348623157e+308]) test_hypothesis.py:8: AssertionError ---------------------------------- Hypothesis ---------------------------------- @@ -97,11 +132,11 @@ to make your tests fail and find bugs with little effort. .. code-block:: pytest - C:> python -m pytest test_hypothesis.py + C:> uv run pytest docs/test/test_hypothesis.py ============================= test session starts ============================== - platform win32 -- Python 3.13.0, pytest-8.3.3, pluggy-1.5.0 - rootdir: C:\Users\veit\python-basics\docs\test - plugins: plugins: hypothesis-6.114.1 + platform darwin -- Python 3.14.0, pytest-9.0.3, pluggy-1.6.0 + rootdir: C:\Users\veit\python-basics-tutorial-de + plugins: hypothesis-6.152.1 collected 1 item test_hypothesis.py F [100%] @@ -133,6 +168,108 @@ to make your tests fail and find bugs with little effort. FAILED test_hypothesis.py::test_mean - assert inf <= 1.7976931348623157e+308 ============================== 1 failed in 0.44s =============================== -.. seealso:: - `Hypothesis for the Scientific Stack - `_ + In the list ``[9.9792015476736e+291, 1.7976931348623157e+308]``, the + calculation of the mean gives ``inf``, and ``inf`` is not less than the + larger of the two numbers. + +Example with regular expressions +-------------------------------- + +#. In the following example, we attempt to extract the ``username`` and + ``domain`` with a :doc:`regular expression + <../types/strings/built-in-modules/regex>` from an email address using: + + .. literalinclude:: test_emails.py + :lines: 1, 5-9 + +#. Now let’s write a test called :func:`test_parse_email` to check our function. + We’ll use the `emails + `_ + strategy from Hypothesis as our input values. As a result, we expect, for + example, that for :samp:`veit@cusy.io`, the ``username`` is veit and the + ``domain`` is cusy.io. + +#. In our test, we assume, on the one hand, that two entries are always returned + and that the second entry contains a full stop (``.``): + + .. literalinclude:: test_emails.py + :lines: 3-4, 10- + +#. Now let’s run the test: + + .. code-block:: pytest + :emphasize-lines: 26 + + $ uv run pytest docs/test/test_emails.py + ============================= test session starts ============================== + platform darwin -- Python 3.14.0, pytest-9.0.3, pluggy-1.6.0 + rootdir: /Users/veit/cusy/trn/python-basics-tutorial-de + configfile: pyproject.toml + plugins: hypothesis-6.152.1 + collected 1 item + + docs/test/test_emails.py F [100%] + + =================================== FAILURES =================================== + _______________________________ test_parse_email _______________________________ + + Exception Group Traceback (most recent call last): + | File "/Users/veit/cusy/trn/python-basics-tutorial-de/docs/test/test_emails.py", line 12, in test_parse_email + | def test_parse_email(email): + | ^^^ + | File "/Users/veit/cusy/trn/python-basics-tutorial-de/.venv/lib/python3.14/site-packages/hypothesis/core.py", line 2264, in wrapped_test + | raise the_error_hypothesis_found + | ExceptionGroup: Hypothesis found 2 distinct failures. (2 sub-exceptions) + +-+---------------- 1 ---------------- + | Traceback (most recent call last): + | File "/Users/veit/cusy/trn/python-basics-tutorial-de/docs/test/test_emails.py", line 16, in test_parse_email + | assert '.' in result[1] + | AssertionError: assert '.' in '0' + | Falsifying example: test_parse_email( + | email='0/0@A.AC', + | ) + +---------------- 2 ---------------- + | Traceback (most recent call last): + | File "/Users/veit/cusy/trn/python-basics-tutorial-de/docs/test/test_emails.py", line 13, in test_parse_email + | result = parse_email(email) + | File "/Users/veit/cusy/trn/python-basics-tutorial-de/docs/test/test_emails.py", line 8, in parse_email + | result = re.match(r"(?P\w+).(?P[\w\.]+)", email).groups() + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | AttributeError: 'NoneType' object has no attribute 'groups' + | Falsifying example: test_parse_email( + | email='/@A.AC', + | ) + +------------------------------------ + =========================== short test summary info ============================ + FAILED docs/test/test_emails.py::test_parse_email - ExceptionGroup: Hypothesis found 2 distinct failures. (2 sub-exceptions) + ============================== 1 failed in 0.14s =============================== + + The email address provided by Hypothesis, ``0/0@A.ac``, shows that our + regular expression in the :func:`parse_email` method is not yet sufficient. + We will therefore adjust our regular expression and then run the test again: + + .. literalinclude:: test_emails_2.py + :diff: test_emails.py + + .. code-block:: pytest + + $ uv run pytest docs/test/test_emails_2.py + ============================= test session starts ============================== + platform darwin -- Python 3.14.0, pytest-9.0.3, pluggy-1.6.0 + rootdir: /Users/veit/cusy/trn/python-basics-tutorial-de + configfile: pyproject.toml + plugins: hypothesis-6.152.1 + collected 1 item + + docs/test/test_emails_2.py . [100%] + + ============================== 1 passed in 0.29s =============================== + +Third-party extensions +---------------------- + +There are a number of open-source libraries that extend :doc:`index` +capabilities. Some of these are listed on `Third-party extensions +`_; you can find +more on :term:`PyPI` by searching for `keywords +`_ or using the `framework classifier +`_. diff --git a/docs/test/index.rst b/docs/test/index.rst index 09f93416..feb9317e 100644 --- a/docs/test/index.rst +++ b/docs/test/index.rst @@ -11,17 +11,14 @@ Basically, a distinction is made between static and dynamic test procedures. `cusy seminar: Efficient testing with Python `_ -.. seealso:: - * `Python Testing and Continuous Integration - `_ - .. toctree:: :titlesonly: :hidden: unittest pytest/index - behave/index + tox mock hypothesis - tox + tdd + bdd diff --git a/docs/test/mock.rst b/docs/test/mock.rst index f90b5dc4..7b90b5f5 100644 --- a/docs/test/mock.rst +++ b/docs/test/mock.rst @@ -1,59 +1,58 @@ Mock ==== -In this chapter, we will test the :abbr:`CLI (Command Line Interface)`. For this, -we will use the :doc:`mock ` package, which has -been delivered as part of the Python standard library under the name -``unittest.mock`` since Python 3.3. For older versions of Python, you can install -it with : - -.. tab:: Linux/macOS - - .. code-block:: console - - $ . .venv/bin/activate - $ python -m pip install mock - -.. tab:: Windows - - .. code-block:: ps1con - - C:> Scripts\activate.bat - C:> python -m pip install mock - -:term:`Mock` objects are sometimes also referred to as test doubles, -:term:`fakes ` or :term:`stubs `. With pytest’s own monkeypatch -fixture and mock, you should have all the functions you need. +In this chapter, we will test the :abbr:`CLI (Command line interface)`. To do +this, we will use the :doc:`mock ` library, which +has been included as part of the standard Python library since Python 3.3 under +the name ``unittest.mock``. + +Objects that are not real can be :term:`dummies `, :term:`fakes `, +:term:`stubs `, :term:`mocks ` or :term:`spies `. They are all +known as test doubles. However, with pytest’s own :ref:`monkeypatch-fixture` and +:doc:`unittest.mock `, you should have all the +functions you need. + +The three core functionalities of :doc:`unittest.mock +` are: + +:class:`Mock ` + The Mock class can be used to simulate any object. +:class:`MagickMock ` + A subclass of Mock that contains all magic methods, for example ``__str__``, + ``__len__`` and so on. +:func:`patch `-Methode + An object is searched for within a specific module and replaced with another + object. Example ------- -Firstly, we wanted to start with a simple example and check whether the working +First, we wanted to start with a simple example and check whether the working days from Monday to Friday are determined correctly. -#. We import ``datetime.datetime`` and ``Mock``: +#. First, we import ``datetime.datetime`` and ``Mock``: .. literalinclude:: test_mock.py :language: python :lines: 1-2 :lineno-start: 1 -#. Then we define two test days: +#. Let’s then define two test days: .. literalinclude:: test_mock.py :language: python :lines: 5-6 :lineno-start: 5 -#. Now we define a method for checking the working days, whereby the Python - datetime library treats Mondays as ``0`` and Sundays as ``6``: +#. Now let’s define a method to check for working days, bearing in mind that + Python’s datetime library treats Mondays as ``0`` and Sundays as ``6``: .. literalinclude:: test_mock.py :language: python :lines: 9-11 :lineno-start: 9 -#. Then we mock datetime: +#. Then we'll mock datetime: .. literalinclude:: test_mock.py :language: python @@ -64,246 +63,246 @@ days from Monday to Friday are determined correctly. .. literalinclude:: test_mock.py :language: python - :lines: 17-19 + :lines: 17-21 :lineno-start: 17 .. literalinclude:: test_mock.py :language: python - :lines: 21-23 - :lineno-start: 21 + :lines: 24- + :lineno-start: 24 Testing with Typer ------------------ -For testing the Items CLI, we will also look at how the ``CliRunner`` provided by -`Typer `_ helps with testing. Typer provides a test -interface that allows us to call our application without having to rely on -:func:`python3:subprocess.run` as in the short :ref:`capsys-fixture` example. -This is good because we cannot simulate what is running in a separate process. So -in :file:`tests/cli/conftest.py` we can just pass our application -``items.cli.app`` and a list of strings representing the command to the -:func:`invoke` function of our ``runner``: more precisely, we use -:func:`shlex.split(command_string)` to convert the commands, for example -:samp:`list -o "veit"` into :samp:`["list", "-o", "veit"]` and can then -intercept and return the output. +When testing the Tasks CLI, we will also look at how the ``CliRunner`` provided +by `Typer `_ helps with testing. Typer offers a +testing interface that allows us to invoke our application without having to +use :func:`python3:subprocess.run`, as in the brief :ref:`capsys-fixture` +example. This is useful because we cannot simulate what is running in a separate +process. So, in :file:`tests/cli/conftest.py`, we can simply pass our +application ``cusy.tasks.cli.app`` and a list of strings representing the +command to the :func:`invoke` function of our runner: more specifically, we use +:func:`shlex.split(command_string)` to split the commands, for example +:samp:`list -o "veit"` into :samp:`["list", "-o", "veit"]`, and can then capture +and return the output. .. code-block:: python :emphasize-lines: 4, 8, 16-17 - import shlex + import shlex - import pytest - from typer.testing import CliRunner + import pytest + from typer.testing import CliRunner - import items + from cusy import tasks - runner = CliRunner() + runner = CliRunner() - @pytest.fixture() - def items_cli(db_path, monkeypatch, items_db): - monkeypatch.setenv("ITEMS_DB_DIR", db_path.as_posix()) + @pytest.fixture() + def tasks_cli(db_path, monkeypatch, tasks_db): + monkeypatch.setenv("ITEMS_DB_DIR", db_path.as_posix()) - def run_cli(command_string): - command_list = shlex.split(command_string) - result = runner.invoke(items.cli.app, command_list) - output = result.stdout.rstrip() - return output + def run_cli(command_string): + command_list = shlex.split(command_string) + result = runner.invoke(tasks.cli.app, command_list) + output = result.stdout.rstrip() + return output - return run_cli + return run_cli We can then simply use this fixture to test the version in :file:`tests/cli/test_version.py`, for example: .. code-block:: python - import items + from cusy import tasks - def test_version(items_cli): - assert items_cli("version") == items.__version__ + def test_version(tasks_cli): + assert tasks_cli("version") == tasks.__version__ .. seealso:: `Typer Learn Testing `_ -Mocking of attributes ---------------------- +Mocking attributes +------------------ -Let’s take a look at how we can use mocking to ensure that, for example, -three-digit version numbers of :func:`items.__version__` are also output -correctly via the CLI. For this we will use :func:`mock.patch.object` as a -context manager: +Let’s look at how we can use mocking to ensure that, for example, three-digit +version numbers from :func:`tasks.__version__` are also output correctly via the +CLI. To do this, we’ll use :func:`mock.patch.object` as a context manager: .. code-block:: python :emphasize-lines: 1, 7 - from unittest import mock + from unittest import mock - import items + from cusy import tasks - def test_mock_version(items_cli): - with mock.patch.object(items, "__version__", "100.0.0"): - assert items_cli("version") == items.__version__ + def test_mock_version(tasks_cli): + with mock.patch.object(tasks, "__version__", "100.0.0"): + assert tasks_cli("version") == tasks.__version__ -In our test code, we import ``items``. The resulting items object is what we -will patch. The call to :func:`mock.patch.object`, which is used as a -:doc:`context manager <../control-flow/with>` within a ``with`` block, returns a -mock object that is cleaned up after the ``with`` block: +In our test code, we import ``tasks``. The resulting ``tasks`` object is what we +will be patching. The call to :func:`mock.patch.object`, used as a context +manager within a ``with`` block, returns a mock object that is cleaned up after +the ``with`` block: -#. In this case, the ``__version__`` attribute of ``items`` is replaced with +#. In this case, the ``__version__`` attribute of ``tasks`` is replaced with ``"100.0.0"`` for the duration of the ``with`` block. -#. We then use :func:`items_cli` to call our CLI application with the - ``"version"`` command. However, when the :func:`version` method is called, - the ``__version__`` attribute is not the original string, but the string we - replaced with :func:`mock.patch.object`. +#. We then use :func:`tasks_cli` to run our CLI application with the ``version`` + command. However, when the :func:`version` method is called, the + ``__version__`` attribute is not the original string, but the string we + replaced it with using :func:`mock.patch.object`. Mocking classes and methods --------------------------- -In :file:`src/items/cli.py` we have defined :func:`config` as follows: +In :file:`src/cusy/tasks/cli.py`, we have defined :func:`config` as follows: .. code-block:: python - def config(): - """List the path to the Items db.""" - with items_db() as db: - print(db.path()) + def config(): + """List the path to the Tasks db.""" + with tasks_db() as db: + print(db.path()) -:func:`items_db` is a :doc:`context manager <../control-flow/with>` that returns -an ``items.ItemsDB`` object. The returned object is then used as a ``db`` to -call :func:`db.path`. So we should mock two things here: ``items.ItemsDB`` and -one of its methods, :func:`path`. Let’s start with the class: +:func:`tasks_db` is a :doc:`context manager <../control-flow/with>` that returns +a ``tasks.TasksDB`` object. The returned object is then used as ``db`` to call +:func:`db.path`. So here we need to mock two things: ``tasks.TasksDB`` and one +of its methods, :func:`path`. Let’s start with the class: .. code-block:: python - from unittest import mock + from unittest import mock - import items + from cusy import tasks - def test_mock_itemsdb(items_cli): - with mock.patch.object(items, "ItemsDB") as MockItemsDB: - mock_db_path = MockItemsDB.return_value.path.return_value = "/foo/" - assert items_cli("config") == str(mock_db_path) + def test_mock_tasksdb(tasks_cli): + with mock.patch.object(tasks, "TasksDB") as MockTasksDB: + mock_db_path = MockTasksDB.return_value.path.return_value = "/foo/" + assert tasks_cli("config") == str(mock_db_path) -Let's make sure that it really works: +Let’s make sure it really works: .. code-block:: pytest - $ pytest -v -s tests/cli/test_config.py::test_mock_itemsdb - ============================= test session starts ============================== - ... - configfile: pyproject.toml - plugins: cov-4.1.0, Faker-19.11.0 - collected 1 item + $ uv run pytest -v -s tests/cli/test_config.py::test_mock_tasksdb + ============================= test session starts ============================== + ... + configfile: pyproject.toml + plugins: cov-4.1.0, Faker-19.11.0 + collected 1 item - tests/cli/test_config.py::test_mock_itemsdb PASSED + tests/cli/test_config.py::test_mock_tasksdb PASSED - ============================== 1 passed in 0.04s =============================== + ============================== 1 passed in 0.04s =============================== -Great, now we just have to move the mock for the database to a fixture, because -we will need it in many test methods: +Great, now we just need to move the database mock into a fixture, as we’ll need +it in lots of test methods: .. code-block:: python - @pytest.fixture() - def mock_itemsdb(): - with mock.patch.object(items="ItemsDB") as MockItemsDB: - yield MockItemsDB.return_value + @pytest.fixture() + def mock_tasksdb(): + with mock.patch.object(tasks, "TasksDB") as MockTasksDB: + yield MockTasksDB.return_value -This fixture mocks the ``ItemsDB`` object and returns the ``return_value`` so -that tests can use it to replace things like ``path``: +This fixture mocks the ``TasksDB`` object and returns the ``return_value``, so +that tests can use it to substitute values for things like ``path``: .. code-block:: python - def test_mock_itemsdb(items_cli, mock_itemsdb): - mock_itemsdb.path.return_value = "/foo/" - result = runner.invoke(app, ["config"]) - assert result.stdout.rstrip() == "/foo/" + def test_mock_tasksdb(tasks_cli, mock_tasksdb): + mock_tasksdb.path.return_value = "/foo/" + result = runner.invoke(app, ["config"]) + assert result.stdout.rstrip() == "/foo/" Alternatively, the :func:`@mock.patch` decorator can also be used to mock classes or objects. In the following examples, the output of ``os.listdir`` is -mocked. This does not require ``db_path`` to be present in the file system: +mocked. For this, ``db_path`` does not need to exist on the file system: .. code-block:: python - import os - from unittest import mock + import os + from unittest import mock - @mock.patch("os.listdir", mock.MagicMock(return_value="db_path")) - def test_listdir(): - assert "db_path" == os.listdir() + @mock.patch("os.listdir", mock.MagicMock(return_value="db_path")) + def test_listdir(): + assert "db_path" == os.listdir() -Another alternative is to define the return value separately: +Another option is to define the return value separately: .. code-block:: python - @mock.patch("os.listdir") - def test_listdir(mock_listdir): - mock_listdir.return_value = "db_path" - assert "db_path" == os.listdir() + @mock.patch("os.listdir") + def test_listdir(mock_listdir): + mock_listdir.return_value = "db_path" + assert "db_path" == os.listdir() Synchronising mocks with ``autospec`` ------------------------------------- -Mock objects are usually intended as objects that are used instead of the real -implementation. By default, however, they will accept any access. For example, if -the real object allows :func:`start(index)`, our mock objects should also allow -:func:`start(index)`. However, there is a problem with this. Mock objects are -too flexible by default: they would also accept :func:`stort` or other -misspelled, renamed or deleted methods or :term:`parameters `. Over -time, this can lead to so-called mock drift if the interface you are modelling -changes, but your mock in your test code does not. This form of mock drift can -be solved by adding ``autospec=True`` to the mock during creation: +Mock objects are generally intended to be used in place of the real +implementation. By default, however, they will accept any access. For example, +if the real object allows :func:`.start(index)`, our mock objects should also +allow :func:`.start(index)`. However, there is a problem here. By default, mock +objects are too flexible: they would also accept :func:`stort` or other +misspelled, renamed or deleted methods or parameters. Over time, this can lead +to what is known as *‘mock drift’*, where the interface you are mocking changes, +but your mock in your test code does not. This form of mock drift can be +resolved by adding ``autospec=True`` to the mock during creation: .. code-block:: python :emphasize-lines: 3 - @pytest.fixture() - def mock_itemsdb(): - with mock.patch.object(items, "ItemsDB") as MockItemsDB: - yield MockItemsDB.return_value + @pytest.fixture() + def mock_tasksdb(): + with mock.patch.object(tasks, "TasksDB", autospec=True) as MockTasksDB: + yield MockTasksDB.return_value -Usually, this protection is always built in with ``autospec``. The only exception -I know of is if the class or object being mocked has dynamic methods or if -attributes are added at runtime. +This protection is usually always included when using ``autospec``. The only +exception I am aware of is when the class or object being mocked has dynamic +methods, or when attributes are added at runtime. .. seealso:: The Python documentation has a large section on ``autospec``: :ref:`python3:auto-speccing`. -Check call with :func:`assert_called_with` ------------------------------------------- +Checking a call with :func:`assert_called_with` +----------------------------------------------- So far, we have used the return values of a mocking method to ensure that our application code handles the return values correctly. But sometimes there is no -useful return value, for example with :samp:`items add some tasks -o veit`. In -these cases, we can ask the mock object if it was called correctly. After calling -:func:`items_cli("add some tasks -o veit")`, the API is not used to check -whether the item has entered the database, but a mock is used to ensure that the -CLI has called the API method correctly. Finally, the implementation of the -:func:`add` function calls :func:`db.add_item` with an ``Item`` object: +useful return value, for example in :samp:`tasks add some tasks -o veit`. In +these cases, we can ask the mock object whether it was called correctly. After +calling :func:`tasks_cli("add some tasks -o veit")`, we do not use the API to +check whether the task has been added to the database, but instead use a mock to +ensure that the CLI has correctly called the right API method. The +implementation of the :func:`add` command ultimately calls :func:`db.add_task` +with a ``Task`` object: .. _test_add_with_owner: .. code-block:: python :emphasize-lines: 4 - def test_add_with_owner(mock_itemsdb, items_cli): - items_cli("add some task -o veit") - expected = items.Item("some task", owner="veit", state="todo") - mock_itemsdb.add_item.assert_called_with(expected) + def test_add_with_owner(mock_tasksdb, tasks_cli): + tasks_cli("add some task -o veit") + expected = tasks.Task("some task", owner="veit", state="todo") + mock_tasksdb.add_task.assert_called_with(expected) -If :func:`add_item` is not called or is called with the wrong type or the wrong +If :func:`add_task` is not called, or is called with the wrong type or the wrong object content, the test fails. For example, if we capitalise the string -``"Veit"`` in ``expected``, but not in the CLI call, we get the following output: +``"Veit"`` in expected but not in the CLI call, we get the following output: .. code-block:: pytest :emphasize-lines: 10-13, 16 - $ pytest -s tests/cli/test_add.py::test_add_with_owner + $ uv run pytest -s tests/cli/test_add.py::test_add_with_owner ============================= test session starts ============================== ... configfile: pyproject.toml @@ -314,107 +313,116 @@ object content, the test fails. For example, if we capitalise the string ... > raise AssertionError(_error_message()) from cause E AssertionError: expected call not found. - E Expected: add_item(Item(summary='some task', owner='Veit', state='todo', id=None)) - E Actual: add_item(Item(summary='some task', owner='veit', state='todo', id=None)) + E Expected: add_task(Task(summary='some task', owner='Veit', state='todo', id=None)) + E Actual: add_task(Task(summary='some task', owner='veit', state='todo', id=None)) ... =========================== short test summary info ============================ FAILED tests/cli/test_add.py::test_add_with_owner - AssertionError: expected call not found. ============================== 1 failed in 0.08s =============================== .. seealso:: - There is a whole range of variants of :func:`assert_called`. A complete list - and description can be found in `unittest.mock.Mock.assert_called + There are a whole range of variants of :func:`assert_called`. You can find a + complete list and description in `unittest.mock.Mock.assert_called `_. - If the only way to test is to ensure the correct call, the various assert_called*() methods fulfil their purpose. - - Wenn die einzige Möglichkeit zum Testen darin besteht, den korrekten Aufruf - sicherzustellen, erfüllen die verschiedenen :func:`assert_called*`-Methoden - ihren Zweck. + If the only way to test is to ensure the correct call has been made, the + various :func:`assert_called*` methods serve their purpose. -Create error conditions ------------------------ +Creating error conditions +------------------------- -Let’s now check if the Items CLI handles error conditions correctly. For example, -here is the implementation of the delete command: +Let’s now check whether the Tasks CLI handles error conditions correctly. Here, +for example, is the implementation of the delete command: .. code-block:: python - @app.command() - def delete(item_id: int): - """Remove item in db with given id.""" - with items_db() as db: - try: - db.delete_item(item_id) - except items.InvalidItemId: - print(f"Error: Invalid item id {item_id}") - -To test how the CLI handles an error condition, we can pretend that -:func:`delete_item` generates an exception by assigning the exception to the -`side_effect + @app.command() + def delete(task_id: int): + """Remove task in db with given id.""" + with tasks_db() as db: + try: + db.delete_task(task_id) + except tasks.InvalidTaskId: + print(f"Error: Invalid task id {task_id}") + +To test how the CLI handles an error condition, we can simulate +:func:`delete_task` throwing an exception by assigning the exception to the mock +object’s `side_effect `_ -attribute of the mock object, like this: +attribute, something like this: .. code-block:: python - def test_delete_invalid(mock_itemsdb, items_cli): - mock_itemsdb.delete_item.side_effect = items.api.InvalidItemId - out = items_cli("delete 42") - assert "Error: Invalid item id 42" in out + def test_delete_invalid(mock_tasksdb, tasks_cli): + mock_tasksdb.delete_task.side_effect = tasks.api.InvalidTaskId + out = tasks_cli("delete 42") + assert "Error: Invalid task id 42" in out -That’s all we need to test the CLI: mocking return values, checking calls to mock -functions and mocking exceptions. However, there is a whole range of other -mocking techniques that we have not covered. So be sure to read -:doc:`python3:library/unittest.mock` if you want to use mocking extensively. +That’s all we need to test the CLI: mocking return values, checking calls to +mock functions, and mocking exceptions. However, there are a whole host of other +mocking techniques that we haven’t covered. So be sure to read up on +:doc:`python3:library/unittest.mock` — the mock object library — if you want to +make extensive use of mocking. -Limitations of mocking +Limitations of Mocking ---------------------- -One of the biggest problems with using mocks is that we are no longer testing the -behaviour in a test, but the implementation. However, this is not only -time-consuming but also dangerous: a valid refactoring, for example changing a -variable name, can cause tests to fail if that particular variable has been -mocked. However, we only want our tests to fail when there are breaks in -behaviour, not just when there are code changes. +One of the biggest problems with using mocks is that, in a test, we are no +longer testing the behaviour but the implementation. This is not only +time-consuming but also dangerous: a valid refactoring – such as changing a +variable name – can cause tests to fail if that particular variable has been +mocked. However, we want our tests to fail only when there are breaks in +behaviour, not simply due to code changes. -However, sometimes mocking is the easiest way to create exceptions or error -conditions and make sure your code handles them correctly. There are also cases -where testing behaviour is unreasonable, such as when accessing a payment API or -sending emails. In these cases, a good option is to test whether your code calls -a specific API method at the right time and with the right :term:`parameters -`. +Sometimes, though, mocking is the easiest way to generate exceptions or error +conditions and ensure that your code handles them correctly. There are also +cases where testing behaviour is impractical, such as when accessing a payment +API or sending emails. In these cases, it is a good option to test whether your +code calls a specific API method at the right time and with the correct +parameters. .. seealso:: - * Hynek Schlawack: `“Don’t Mock What You Don’t Own” - `_ + * `“Don’t Mock What You Don’t Own” + `_ by Hynek Schlawack + +.. note:: + Even in agent-based software development, we try to avoid mocking as much as + possible: + + .. code-block:: md + :caption: AGENTS.md + + - Prefer testing real code where possible. Use mocks and `monkeypatch` when absolute necessary. Try to avoid mocking as much as possible. + + .. seealso:: + * :ref:`agentic-software-engineering:testing` -Avoid mocking with tests on multiple levels -------------------------------------------- +Avoiding mocks with multi-level testing +--------------------------------------- -We can also test the Items CLI without mocks by also using the API. We will not -test the API, but only use it to check the behaviour of actions that are executed -via the CLI. We can also test the :ref:`test_add_with_owner -` example as follows: +We can also test the Tasks CLI without using mocks by utilising the API. In +doing so, we will not be testing the API itself, but simply using it to verify +the behaviour of actions executed via the CLI. We can also test the +:ref:`test_add_with_owner` example as follows: .. code-block:: python - def test_add_with_owner(items_db, items_cli): - items_cli("add some task -o veit") - expected = items.Item("some task", owner="veit", state="todo") - all = items_db.list_items() + def test_add_with_owner(tasks_db, tasks_cli): + tasks_cli("add some task -o veit") + expected = tasks.Task("some task", owner="veit", state="todo") + all = tasks_db.list_tasks() assert len(all) == 1 assert all[0] == expected -Mocking tests the implementation of the command line interface and ensures that -an API call is made with certain :term:`parameters `. The -mixed-layer approach tests the behaviour to ensure that the result meets our -expectations. This approach is much less of a change detector and has a greater -chance of remaining valid during a refactoring. Interestingly, the tests are -also about twice as fast: +Mocking tests the implementation of the command-line interface and ensures that +an API call is made with specific parameters. With the mixed-layer approach, the +behaviour is tested to ensure that the result meets our expectations. This +approach is much less of a change detector and is more likely to remain valid +during a refactoring. Interestingly, the tests are also about twice as fast: .. code-block:: pytest - $ pytest -s tests/cli/test_add.py::test_add_with_owner + $ uv run pytest -s tests/cli/test_add.py::test_add_with_owner ============================= test session starts ============================== ... configfile: pyproject.toml @@ -425,45 +433,45 @@ also about twice as fast: ============================== 1 passed in 0.03s =============================== -We could also avoid mocking in another way. We could test the behaviour -completely via the CLI. This might require parsing the output of the items list -to check the correct database content. - -In the API, :func:`add_item` returns an index and provides a -:func:`get_item(index)` method to help with testing. Both methods are not -available in the CLI, but could be. We could perhaps add the ``items get index`` -or ``items info index`` commands so we can retrieve an item instead of having to -use ``items list`` for everything. ``list`` also already supports filtering. -Maybe filtering by index would work instead of adding a new command. And we could -add an output to ``items add`` that says something like *Item added at index 3*. -These changes would fall into the *Design for Testability* category. They also -don’t seem to be deep interface interventions and perhaps should be considered in -future versions. +We could also avoid mocking in another way. We could test the behaviour entirely +via the CLI. To do this, we might need to parse the output of the tasks list to +verify that the database contents are correct. + +In the API, :func:`add_task` returns an index and provides a +:func:`get_task(index)` method, which helps with testing. Neither of these +methods is available in the CLI, but they could be. We could perhaps add the +commands tasks get index or tasks info index so that we can retrieve a task +instead of having to use tasks list for everything. list already supports +filtering. Perhaps filtering by index would work, rather than adding a new +command. And we could add output to ``tasks add`` that says something like +*‘Task added at index 3’*. These changes would fall under the category of +*Design for Testability*. They do not appear to involve any major changes to the +interface and might be worth considering in future versions. Plugins to support mocking -------------------------- -So far we have focussed on the direct use of :doc:`mock +So far, we have focused on the direct use of :doc:`mock `. However, there are many plugins that help with mocking, such as `pytest-mock `_, which provides a ``mocker`` fixture. One advantage is that the fixture cleans up after -itself, so you don’t need to use a ``with`` block like we did in our examples. +itself, so you don’t need to use a ``with`` block, as we did in our examples. -There are also some special mocking libraries: +There are also some specialised mocking libraries: -- The following are suitable for mocking database accesses: +- For mocking database access, the following are suitable - `pytest-postgresql `_ - `pytest-mongo `_ - `pytest-mysql `_ - `pytest-dynamodb `_. -- You can use `pytest-httpserver `_ - to test HTTP servers. -- You can use `responses `_ or `betamax - `_ to mock `requests - `_. -- Other tools for different requirements are: +- To test HTTP servers, you can use `pytest-httpserver + `_. +- To mock `requests `_, you can use + `responses `_ or `betamax + `_. +- Other tools for various requirements include - `pytest-rabbitmq `_ - `pytest-solr `_ diff --git a/docs/test/pytest/builtin-fixtures.rst b/docs/test/pytest/builtin-fixtures.rst index 78fc7cef..23b6ca6a 100644 --- a/docs/test/pytest/builtin-fixtures.rst +++ b/docs/test/pytest/builtin-fixtures.rst @@ -314,6 +314,18 @@ The ``monkeypatch`` fixture offers the following functions: it is set, the value of the environment variable is changed to :samp:`{VALUE} + prepend + {OLD_VALUE}` +.. note:: + In agent-based software development, we try to avoid monkey patching as much + as possible: + + .. code-block:: md + :caption: AGENTS.md + + - Prefer testing real code where possible. Use mocks and `monkeypatch` when absolute necessary. Try to avoid mocking as much as possible. + + .. seealso:: + * :ref:`agentic-software-engineering:testing` + RMonkey patching of environment variables ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/test/pytest/ci.yaml b/docs/test/pytest/ci.yaml index e9052764..6bb1e3d2 100644 --- a/docs/test/pytest/ci.yaml +++ b/docs/test/pytest/ci.yaml @@ -3,12 +3,36 @@ name: CI on: push: branches: [main] + tags: ["*"] pull_request: - branches: [main] + workflow_dispatch: + +env: + PIP_DISABLE_PIP_VERSION_CHECK: "1" + PIP_NO_PYTHON_VERSION_WARNING: "1" + +permissions: {} jobs: + build-package: + name: Build & verify package + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: false + - uses: hynek/build-and-inspect-python-package@d44ca7d91762de7a7d5436ddae667c6da6d1c3df # v2.18.0 + id: baipp + + outputs: + # Used to define the matrix for tests below. The value is based on + # packaging metadata (trove classifiers). + supported-python-versions: ${{ steps.baipp.outputs.supported_python_classifiers_json_array }} + tests: - name: Python ${{ matrix.python-version }} on ${{ matrix.os }} + name: Tests on Python ${{ matrix.python-version }} and ${{ matrix.os }} runs-on: ${{ matrix.os }} strategy: @@ -18,32 +42,35 @@ jobs: - ubuntu-latest - macos-latest - windows-latest - python-version: - - "3.8" - - "3.9" - - "3.10" - - "3.11" - - "3.12" + # Created by the build-and-inspect-python-package action above. + python-version: ${{ fromJson(needs.build-package.outputs.supported-python-versions) }} steps: - - name: Check out the repo - uses: actions/checkout@v4 - + - name: Download pre-built packages + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: Packages + path: dist + - run: | + tar xf dist/*.tar.gz --strip-components=1 + rm -rf src - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ matrix.python-version }} - - - name: Install dependencies - run: | - python -m pip install tox tox-gh-actions + allow-prereleases: true + - name: Set up uv + uses: hynek/setup-cached-uv@4300ec2180bc77d705e626a34e381b81a4772c51 # v2.5.0 - name: Run tox for ${{ matrix.python-version }} - run: | - python -m tox + run: > + uvx --with=tox-uv + tox run + --installpkg dist/*.whl + -f py$(echo $PYTHON | tr -d .) - name: Upload coverage data - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: coverage-data path: .coverage.* @@ -55,43 +82,45 @@ jobs: needs: tests runs-on: ubuntu-latest steps: - - name: Check out the repo - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 + - name: Download pre-built packages + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - python-version: 3.12 - - - name: Install dependencies - run: | - python -m pip install --upgrade coverage[toml] + name: Packages + path: dist + - run: tar xf dist/*.tar.gz --strip-components=1 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version-file: .python-version + - uses: hynek/setup-cached-uv@4300ec2180bc77d705e626a34e381b81a4772c51 # v2.5.0 - name: Download coverage data - uses: actions/download-artifact@v4 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: coverage-data + pattern: coverage-data-* + merge-multiple: true - - name: Combine coverage and fail it it’s under 100 % + - name: Combine coverage and fail if it's <100%. run: | - python -m coverage combine - python -m coverage html --skip-covered --skip-empty + uv tool install coverage + + coverage combine + coverage html --skip-covered --skip-empty # Report and write to summary. - python -m coverage report | sed 's/^/ /' >> $GITHUB_STEP_SUMMARY + coverage report --format=markdown >> $GITHUB_STEP_SUMMARY # Report again and fail if under 100%. - python -Im coverage report --fail-under=100 + coverage report --fail-under=100 - - name: Upload HTML report if check failed - uses: actions/upload-artifact@v4 + - name: Upload HTML report if check failed. + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: html-report path: htmlcov if: ${{ failure() }} - name: Create badge - uses: schneegans/dynamic-badges-action@v1.7.0 + uses: schneegans/dynamic-badges-action@0e50b8bad39e7e1afd3e4e9c2b7dd145fad07501 # v1.8.0 with: auth: ${{ secrets.GIST_TOKEN }} gistID: YOUR_GIST_ID diff --git a/docs/test/pytest/command-line-options.rst b/docs/test/pytest/command-line-options.rst index 7175a653..fe573756 100644 --- a/docs/test/pytest/command-line-options.rst +++ b/docs/test/pytest/command-line-options.rst @@ -14,12 +14,12 @@ achieve this using the following pattern: .. code-block:: python :caption: test_example.py - def test_db(items_db, db_path, cmdopt): + def test_db(tasks_db, db_path, cmdopt): if cmdopt == "json": print("Save as JSON file") elif cmdopt == "sqlite": print("Save in a SQLite database") - assert items_db.path() == db_path + assert tasks_db.path() == db_path For this to work, the command line option must be added and ``cmdopt`` must be provided via a fixture function: @@ -47,7 +47,7 @@ You can then call up your tests, for example, with: .. code-block:: console - $ pytest --sqlite + $ uv run pytest --sqlite In addition, you can add a simple validation of the input by listing the options: @@ -69,7 +69,7 @@ This is how we receive feedback on an incorrect argument: .. code-block:: console - $ pytest --postgresql + $ uv run pytest --postgresql ERROR: usage: pytest [options] [file_or_dir] [file_or_dir] [...] pytest: error: argument --cmdopt: invalid choice: 'postgresql' (choose from json, sqlite) @@ -141,14 +141,14 @@ Below, we add a :file:`conftest.py` file with a command line option ) - def pytest_collection_modifyitems(config, items): + def pytest_collection_modifytasks(config, tasks): if config.getoption("--runslow"): # If --runslow is specified on the CLI, slow tests are not skipped. return skip_slow = pytest.mark.skip(reason="need --runslow option to run") - for item in items: - if "slow" in item.keywords: - item.add_marker(skip_slow) + for task in tasks: + if "slow" in task.keywords: + task.add_marker(skip_slow) If we now write a test with the ``@pytest.mark.slow`` decorator, a skipped ‘slow’ test will be displayed when pytest is called: @@ -188,7 +188,7 @@ Additional information can be easily provided in a ``pytest -v`` run: platform darwin -- Python 3.14.0b4, pytest-8.4.1, pluggy-1.6.0 cachedir: .pytest_cache Is GIL enabled? False - rootdir: /Users/veit/sandbox/items + rootdir: /Users/veit/prj/cusy.tasks configfile: pyproject.toml plugins: anyio-4.9.0, Faker-37.4.0, cov-6.2.1 ... diff --git a/docs/test/pytest/config.rst b/docs/test/pytest/config.rst index 58dfb6ae..1f6b038a 100644 --- a/docs/test/pytest/config.rst +++ b/docs/test/pytest/config.rst @@ -37,7 +37,7 @@ structure: .. code-block:: console :emphasize-lines: 3, 7, 8 - items + cusy.tasks ├── … ├── pyproject.toml ├── src @@ -47,7 +47,7 @@ structure: ├── conftest.py └── test_….py -In the case of the ``items`` project that we have used for testing so far, there +In the case of the ``tasks`` project that we have used for testing so far, there is a :file:`pyproject.toml` file and a :file:`tests` directory at the top level. We will refer to this structure when we talk about the various files in the rest of this section. @@ -74,7 +74,7 @@ projects; however, it can also be used to define project settings. "exception: Only run expected exceptions", "finish: Only run finish tests", "smoke: Small subset of all tests", - "num_items: Number of items to be pre-filled for the items_db fixture", + "num_tasks: Number of tasks to be pre-filled for the tasks_db fixture", ] ``[tool.pytest]`` marks the start of the pytest section. This is followed by the @@ -215,11 +215,11 @@ be sure that pytest finds the correct configuration file: .. code-block:: pytest :emphasize-lines: 5, 6 - $ cd items - $ pytest + $ cd cusy.tasks + $ uv run pytest ============================= test session starts ============================== ... - rootdir: /Users/veit/cusy/prj/items + rootdir: /Users/veit/cusy/prj/cusy.tasks configfile: pyproject.toml testpaths: tests plugins: Faker-19.11.0 @@ -253,11 +253,10 @@ have :file:`__init__.py` files in each test subdirectory, you can use the same test filename in multiple directories, for example: .. code-block:: console - :emphasize-lines: 8, 11 + :emphasize-lines: 7, 10 - items + cusy.tasks ├── … - ├── pytest.ini ├── src │   └── … └── tests @@ -276,10 +275,10 @@ whereby a :file:`test_add.py` is located in both directories: .. code-block:: pytest - $ pytest + $ uv run pytest ============================= test session starts ============================== ... - rootdir: /Users/veit/cusy/prj/items + rootdir: /Users/veit/cusy/prj/cusy.tasks configfile: pyproject.toml testpaths: tests plugins: Faker-19.11.0 diff --git a/docs/test/pytest/coverage.rst b/docs/test/pytest/coverage.rst index 09d9c138..4836727a 100644 --- a/docs/test/pytest/coverage.rst +++ b/docs/test/pytest/coverage.rst @@ -1,15 +1,16 @@ Coverage ======== -We have created an initial list of test cases. The tests in the :file:`tests/api` -directory test Items via the API. But how do we know whether these tests -comprehensively test our code? This is where code coverage comes into play. +We have created an initial list of test cases. The tests in the +:file:`tests/api` directory test tasks via the API. But how do we know whether +these tests comprehensively test our code? This is where code coverage comes +into play. Tools that measure code coverage observe your code while a test suite is running -and record which lines are passed and which are not. This measure – known as line -coverage – is calculated by dividing the total number of executed lines by the -total number of lines of code. Code coverage tools can also tell you whether all -paths in control statements are traversed, a measurement known as branch +and record which lines are passed and which are not. This measure – known as +line coverage – is calculated by dividing the total number of executed lines by +the total number of lines of code. Code coverage tools can also tell you whether +all paths in control statements are traversed, a measurement known as branch coverage. However, code coverage cannot tell you if your test suite is good; it can only @@ -32,13 +33,13 @@ You can create a report for the test coverage with Coverage.py. .. code-block:: console - $ python -m pip install coverage pytest-cov + $ uv add --group tests pytest-cov .. tab:: Windows .. code-block:: ps1con - C:> python -m pip install coverage pytest-cov + C:> uv add --group tests pytest-cov .. note:: If you want to determine the test coverage for Python 2 and Python<3.6, you @@ -46,21 +47,19 @@ You can create a report for the test coverage with Coverage.py. To run tests with Coverage.py, you need to add the ``--cov`` option and specify either a path to the code you want to measure or the installed package you are -testing. In our case, the Items project is an installed package, so we will test -it with ``--cov=items``. +testing. In our case, the cusy.tasks project is an installed package, so we will +test it with ``--cov=cusy.tasks``. The normal pytest output is followed by the coverage report, as shown here: .. code-block:: pytest - $ cd /PATH/TO/items - $ python3 -m venv .venv - $ . .venv/bin/activate - $ python -m pip install ".[dev]" - $ pytest --cov=items + $ cd /PATH/TO/cusy.tasks + $ uv sync --group tests + $ uv run pytest --cov=cusy.tasks ============================= test session starts ============================== ... - rootdir: /Users/veit/cusy/prj/items + rootdir: /Users/veit/cusy/prj/cusy.tasks configfile: pyproject.toml testpaths: tests plugins: cov-4.1.0, Faker-19.11.0 @@ -80,10 +79,10 @@ The normal pytest output is followed by the coverage report, as shown here: ---------- coverage: platform darwin, python 3.11.5-final-0 ---------- Name Stmts Miss Cover ------------------------------------------- - src/items/__init__.py 3 0 100% - src/items/api.py 70 1 99% - src/items/cli.py 38 9 76% - src/items/db.py 23 0 100% + src/cusy/tasks/__init__.py 3 0 100% + src/cusy/tasks/api.py 70 1 99% + src/cusy/tasks/cli.py 38 9 76% + src/cusy/tasks/db.py 23 0 100% ------------------------------------------- TOTAL 134 10 93% @@ -91,18 +90,18 @@ The normal pytest output is followed by the coverage report, as shown here: ============================== 35 passed in 0.11s ============================== The previous output was generated by coverage’s reporting functions, although we -did not call coverage directly. ``pytest --cov=items`` instructed the +did not call coverage directly. ``pytest --cov=cusy.tasks`` instructed the ``pytest-cov`` plugin to -* set ``coverage`` to ``items`` with ``--source`` while running pytest with the - tests +* set ``coverage`` to ``cusy.tasks`` with ``--source`` while running pytest with + the tests * execute ``coverage`` report for the line coverage report Without ``pytest-cov``, the commands would look like this: .. code-block:: console - $ coverage run --source=items -m pytest + $ coverage run --source=cusy.tasks -m pytest $ coverage report The files :file:`__init__.py` and :file:`db.py` have a coverage of 100%, which @@ -121,10 +120,10 @@ coverage. We can find out what was missed by re-running the tests and adding the .. code-block:: pytest - pytest --cov=items --cov-report=term-missing + pytest --cov=cusy.tasks --cov-report=term-missing ============================= test session starts ============================== ... - rootdir: /Users/veit/cusy/prj/items + rootdir: /Users/veit/cusy/prj/cusy.tasks configfile: pyproject.toml testpaths: tests plugins: cov-4.1.0, Faker-19.11.0 @@ -144,10 +143,10 @@ coverage. We can find out what was missed by re-running the tests and adding the ---------- coverage: platform darwin, python 3.11.5-final-0 ---------- Name Stmts Miss Cover Missing ----------------------------------------------------- - src/items/__init__.py 3 0 100% - src/items/api.py 68 1 99% 52 - src/items/cli.py 38 9 76% 18-19, 25, 39-43, 51 - src/items/db.py 23 0 100% + src/cusy/tasks/__init__.py 3 0 100% + src/cusy/tasks/api.py 68 1 99% 52 + src/cusy/tasks/cli.py 38 9 76% 18-19, 25, 39-43, 51 + src/cusy/tasks/db.py 23 0 100% ----------------------------------------------------- TOTAL 132 10 92% @@ -165,7 +164,7 @@ report. `_: Conditional coverage based on any rules you define * `Coverage.py regex pragmas - `_ + `_ Generate HTML reports ~~~~~~~~~~~~~~~~~~~~~ @@ -176,11 +175,9 @@ by executing coverage html after a previous coverage run: .. code-block:: console - $ cd /PATH/TO/items - $ python3 -m venv .venv - $ . .venv/bin/activate - $ python -m pip install ".[dev]" - $ pytest --cov=items --cov-report=html + $ cd /PATH/TO/cusy.tasks + $ uv sync --group tests + $ uv run pytest --cov=cusy.tasks --cov-report=html Both commands will prompt Coverage.py to create an HTML report in the :file:`htmlcov/` directory. Open :file:`htmlcov/index.html` with a browser and @@ -189,11 +186,11 @@ you should see the following: .. image:: coverage.png :alt: Coverage report: 92% -If you click on the :file:`src/items/api.py:` file, a report for this file is -displayed: +If you click on the :file:`src/cusy/tasks/api.py` file, a report for this file +is displayed: .. image:: api.png - :alt: Coverage for src/items/api.py: 99% + :alt: Coverage for src/cusy/tasks/api.py: 99% The upper part of the report shows the percentage of rows covered (99%), the total number of statements (68) and how many statements were executed (67), @@ -203,7 +200,7 @@ rows that were not executed: .. image:: missing.png :alt: raise MissingSummary -It looks like the function :func:`add_item` has an exception ``MissingSummary``, +It looks like the function :func:`add_task` has an exception ``MissingSummary``, which is not tested yet. Exclude code from test coverage @@ -211,10 +208,10 @@ Exclude code from test coverage In the HTML reports you will find a column with the specification *0 excluded*. This refers to a function of Coverage.py that allows us to exclude some lines -from the check. We do not exclude anything in items. However, it is not uncommon -for some lines of code to be excluded from the test coverage calculation, for -example modules that are to be both imported and executed directly may contain a -block that looks something like this: +from the check. We do not exclude anything in cusy.tasks. However, it is not +uncommon for some lines of code to be excluded from the test coverage +calculation, for example modules that are to be both imported and executed +directly may contain a block that looks something like this: .. code-block:: python @@ -386,8 +383,8 @@ them later in other jobs: .. literalinclude:: ci.yaml :language: yaml - :lines: 45-51 - :lineno-start: 45 + :lines: 72-78 + :lineno-start: 72 ``include-hidden-files`` has become necessary with `actions/upload-artifact v4.4.0 @@ -402,8 +399,8 @@ results: .. literalinclude:: ci.yaml :language: yaml - :lines: 53-91 - :lineno-start: 53 + :lines: 80-120 + :lineno-start: 80 ``needs: tests`` ensures that all tests are performed. If your job that runs the tests has a @@ -436,14 +433,14 @@ our :download:`ci.yaml` as follows: .. literalinclude:: ci.yaml :language: yaml - :lines: 93- - :lineno-start: 93 + :lines: 122- + :lineno-start: 122 -Line 97 - ``GIST_TOKEN`` is a personal GitHub access token. -Line 98 - You should replace ``YOUR_GIST_ID`` with your own Gist ID. If you don’t have - a Gist ID yet, you can create one with: +``GIST_TOKEN`` + is a personal GitHub access token. +``YOUR_GIST_ID`` + You should replace this with your own Gist ID. If you don’t have a Gist ID + yet, you can create one with: #. Call up https://gist.github.com and create a new gist, which you can name :file:`test.json`, for example. The ID of the gist is the long @@ -459,7 +456,7 @@ Line 98 :samp:`{GIST_SECRET}` in :menuselection:`YOUR_REPO --> Settings --> Secrets --> Dependabot`. -Lines 102-104 +``minColorRange``, ``maxColorRange``, ``valColorRange`` The badge is automatically coloured: * ≤ 50 % in red diff --git a/docs/test/pytest/debug.rst b/docs/test/pytest/debug.rst index 587bec1e..111c9c21 100644 --- a/docs/test/pytest/debug.rst +++ b/docs/test/pytest/debug.rst @@ -126,13 +126,13 @@ Combining pdb and tox In order to combine pdb with tox, we need to make sure that we can pass arguments through tox to pytest. This is done with the ``{posargs}`` function of tox, which was described in :ref:`posargs`. We have already set up this function -in our :file:`tox.ini` for Items: +in our :file:`tox.ini` for cusy.tasks: .. code-block:: ini :emphasize-lines: 11 [tox] - envlist = py39, py310, py311, py312, py313 + envlist = py310, py311, py312, py313, py314 isolated_build = True skip_missing_interpreters = True @@ -141,22 +141,22 @@ in our :file:`tox.ini` for Items: pytest faker pytest-cov - commands = pytest --cov=items --cov-fail-under=99 {posargs} + commands = pytest --cov=cusy.tasks --cov-fail-under=99 {posargs} [gh-actions] python = - 3.9: py39 3.10: py310 3.11: py311 3.12: py312 3.13: py313 + 3.14: py314 We want to run the Python 3.13 environment and start the debugger on a failed -test with ``tox -e py313 -- --pdb --no-cov``. This will take us to the pdb, +test with ``tox -e py314 -- --pdb --no-cov``. This will take us to the pdb, right at the assertion that failed. Once we have found and fixed the error, we can run the tox environment again -with this one test error: ``tox -e py313 -- --lf --tb=no --no-cov``. +with this one test error: ``tox -e py314 -- --lf --tb=no --no-cov``. Overview of the most common pytest debugger options --------------------------------------------------- diff --git a/docs/test/pytest/examples.rst b/docs/test/pytest/examples.rst index af5916c3..3d06f22b 100644 --- a/docs/test/pytest/examples.rst +++ b/docs/test/pytest/examples.rst @@ -20,7 +20,7 @@ Execute pytest .. code-block:: pytest $ cd docs/test/pytest - $ pytest test_one.py + $ uv run pytest docs/test/pytest/test_one.py ============================= test session starts ============================== … collected 1 item @@ -37,7 +37,7 @@ corresponds to 100% of the tests. If you need more information, you can use .. code-block:: pytest - $ pytest -v test_one.py + $ uv run pytest -v test_one.py ============================= test session starts ============================== … collected 1 item @@ -76,7 +76,7 @@ says we get the full diff with ``-v``. Let’s do that: .. code-block:: pytest - $ pytest -v test_two.py + $ uv run pytest -v test_two.py ============================= test session starts ============================== … collected 1 item @@ -113,7 +113,7 @@ run: .. code-block:: pytest - $ pytest --tb=no + $ uv run pytest --tb=no ============================= test session starts ============================== … diff --git a/docs/test/pytest/fixtures.rst b/docs/test/pytest/fixtures.rst index 184cd104..8891cf7e 100644 --- a/docs/test/pytest/fixtures.rst +++ b/docs/test/pytest/fixtures.rst @@ -16,9 +16,9 @@ use ``scope`` to run fixtures once across many tests and learn how tests can use multiple fixtures. You will also learn how to track code execution through fixtures and test code. -But before you familiarise yourself with fixtures and use them to test Items, -let’s take a look at a small example fixture and learn how fixtures and test -functions are connected. +But before you familiarise yourself with fixtures and use them to test +cusy.tasks, let’s take a look at a small example fixture and learn how fixtures +and test functions are connected. .. seealso:: * `pytest fixtures `_ @@ -71,32 +71,32 @@ somewhere in a fixture. Using fixtures for setup and teardown ------------------------------------- -Fixtures will be a great help when testing the Items application. The Items -application consists of an API that does most of the work and logic, a lean -:abbr:`CLI (Command Line Interface)` and a database. Handling the database is an -area where fixtures will be of great help: +Fixtures will be a great help when testing the cusy.tasks application. The +cusy.tasks application consists of an API that does most of the work and logic, +a lean :abbr:`CLI (Command Line Interface)` and a database. Handling the +database is an area where fixtures will be of great help: .. code-block:: python - from pathlib import Path - from tempfile import TemporaryDirectory + from pathlib import Path + from tempfile import TemporaryDirectory - import items + from cusy import tasks - def test_empty(): - with TemporaryDirectory() as db_dir: - db_path = Path(db_dir) - db = items.ItemsDB(db_path) - count = db.count() - db.close() - assert count == 0 + def test_empty(): + with TemporaryDirectory() as db_dir: + db_path = Path(db_dir) + db = tasks.TasksDB(db_path) + count = db.count() + db.close() + assert count == 0 To be able to call :func:`count`, we need a database object, which we obtain by -calling :func:`items.ItemsDB(db_path)`. The :func:`items.ItemsDB` function -returns an ``ItemsDB`` object. The :term:`parameter` ``db_path`` must be a -``pathlib.Path`` object that points to the database directory. For testing, a -temporary directory that we obtain with :func:`tempfile.TemporaryDirectory` +calling :func:`cusy.tasks.TasksDB(db_path)`. The :func:`cusy.tasks.TasksDB` +function returns an ``TasksDB`` object. The :term:`parameter` ``db_path`` must +be a ``pathlib.Path`` object that points to the database directory. For testing, +a temporary directory that we obtain with :func:`tempfile.TemporaryDirectory` works. However, this test function contains some problems: The code to set up the @@ -111,20 +111,20 @@ closed. These problems can be solved with pytest fixture: @pytest.fixture() - def items_db(): + def tasks_db(): with TemporaryDirectory() as db_dir: db_path = Path(db_dir) - db = items.ItemsDB(db_path) + db = tasks.TasksDB(db_path) yield db db.close() - def test_empty(items_db): - assert items_db.count() == 0 + def test_empty(tasks_db): + assert tasks_db.count() == 0 The test function itself is now much easier to read, as we have outsourced the -entire database initialisation to a fixture called ``items_db``. The -``items_db`` fixture prepares the test by providing the database and then +entire database initialisation to a fixture called ``tasks_db``. The +``tasks_db`` fixture prepares the test by providing the database and then outputting the database object. Only then is the test executed. And only after the test has run is the database closed again. @@ -145,14 +145,14 @@ We can also use fixtures in several tests, for example in .. code-block:: python - def test_count(items_db): - items_db.add_item(items.Item("something")) - items_db.add_item(items.Item("something else")) - assert items_db.count() == 2 + def test_count(tasks_db): + tasks_db.add_task(tasks.Task("something")) + tasks_db.add_task(tasks.Task("something else")) + assert tasks_db.count() == 2 -:func:`test_count` uses the same ``items_db`` fixture. This time we take the -empty database and add two items before checking the count. We can now use -``items_db`` for any test that requires a configured database. The individual +:func:`test_count` uses the same ``tasks_db`` fixture. This time we take the +empty database and add two tasks before checking the count. We can now use +``tasks_db`` for any test that requires a configured database. The individual tests, such as :func:`test_empty` and :func:`test_count`, can be kept smaller and focus on what we really want to test, rather than setup and teardown. @@ -166,23 +166,23 @@ including the setup and teardown phases of the fixtures: .. code-block:: pytest - $ pytest --setup-show tests/test_count.py + $ uv run pytest --setup-show tests/test_count.py ============================= test session starts ============================== … collected 2 items tests/test_count.py - SETUP F items_db - tests/test_count.py::test_empty (fixtures used: items_db). - TEARDOWN F items_db - SETUP F items_db - tests/test_count.py::test_count (fixtures used: items_db). - TEARDOWN F items_db + SETUP F tasks_db + tests/test_count.py::test_empty (fixtures used: tasks_db). + TEARDOWN F tasks_db + SETUP F tasks_db + tests/test_count.py::test_count (fixtures used: tasks_db). + TEARDOWN F tasks_db ============================== 2 passed in 0.01s =============================== We can see that our test is running, surrounded by the ``SETUP`` and -``TEARDOWN`` parts of the ``items_db`` fixture. The ``F`` in front of the +``TEARDOWN`` parts of the ``tasks_db`` fixture. The ``F`` in front of the fixture name indicates that the fixture is using the function scope, meaning that the fixture is called before each test function it uses, and then dismantled afterwards. Next, let’s take a look at the functional scope. @@ -204,30 +204,30 @@ by adding ``scope="module"`` to the fixture decorator: .. code-block:: python @pytest.fixture(scope="module") - def items_db(): + def tasks_db(): with TemporaryDirectory() as db_dir: db_path = Path(db_dir) - db = items.ItemsDB(db_path) + db = tasks.TasksDB(db_path) yield db db.close() .. code-block:: pytest - $ pytest --setup-show tests/test_count.py + $ uv run pytest --setup-show tests/test_count.py ============================= test session starts ============================== … collected 2 items tests/test_count.py - SETUP M items_db - tests/test_count.py::test_empty (fixtures used: items_db). - tests/test_count.py::test_count (fixtures used: items_db). - TEARDOWN M items_db + SETUP M tasks_db + tests/test_count.py::test_empty (fixtures used: tasks_db). + tests/test_count.py::test_count (fixtures used: tasks_db). + TEARDOWN M tasks_db ============================== 2 passed in 0.01s =============================== We have saved this setup time for the second test function. By changing the -module scope, any test in this module that uses the ``items_db`` fixture can use +module scope, any test in this module that uses the ``tasks_db`` fixture can use the same instance of it without incurring additional setup and teardown time. However, the fixture :term:`parameter` ``scope`` allows for more than just @@ -272,7 +272,7 @@ multiple test files, you must use a :file:`conftest.py` file either in the same directory as the test file that uses it or in a parent directory. The :file:`conftest.py` file is optional. It is considered a local plugin by pytest and can contain hook functions and fixtures. Let’s start by moving the -``items_db`` fixture from :file:`test_count.py` to a :file:`conftest.py` file in +``tasks_db`` fixture from :file:`test_count.py` to a :file:`conftest.py` file in the same directory: .. code-block:: python @@ -282,15 +282,15 @@ the same directory: import pytest - import items + from cusy import tasks @pytest.fixture(scope="session") - def items_db(): - """ItemsDB object connected to a temporary database""" + def tasks_db(): + """TasksDB object connected to a temporary database""" with TemporaryDirectory() as db_dir: db_path = Path(db_dir) - db = items.ItemsDB(db_path) + db = tasks.TasksDB(db_path) yield db db.close() @@ -320,7 +320,7 @@ display where the fixtures are defined: .. code-block:: pytest - pytest --fixtures + $ uv run pytest --fixtures ============================= test session starts ============================== … collected 10 items @@ -337,8 +337,8 @@ display where the fixtures are defined: --------------------- fixtures defined from tests.conftest --------------------- - items_db [session scope] -- conftest.py:10 - ItemsDB object connected to a temporary database + tasks_db [session scope] -- conftest.py:10 + TasksDB object connected to a temporary database ------------------ fixtures defined from tests.test_fixtures ------------------- @@ -372,15 +372,15 @@ test and where the fixtures are defined: .. code-block:: pytest - pytest --fixtures-per-test test_count.py::test_empty + $ uv run pytest --fixtures-per-test test_count.py::test_empty ============================= test session starts ============================== … collected 1 item ------------------------- fixtures used by test_empty -------------------------- ------------------------------ (test_count.py:5) ------------------------------- - items_db -- conftest.py:10 - ItemsDB object connected to a temporary database + tasks_db -- conftest.py:10 + TasksDB object connected to a temporary database ============================ no tests ran in 0.00s ============================= @@ -396,7 +396,7 @@ add a third test: .. code-block:: pytest - $ pytest test_count.py::test_count2 + $ uv run pytest test_count.py::test_count2 ============================= test session starts ============================== … collected 1 item @@ -410,7 +410,7 @@ It works when executed individually, but not when executed after .. code-block:: pytest - $ pytest test_count.py + $ uv run pytest test_count.py ============================= test session starts ============================== … collected 3 items @@ -420,51 +420,51 @@ It works when executed individually, but not when executed after =================================== FAILURES =================================== _________________________________ test_count2 __________________________________ - items_db = + tasks_db = - def test_count2(items_db): - items_db.add_item(items.Item("something different")) - > assert items_db.count() == 1 + def test_count2(tasks_db): + tasks_db.add_task(tasks.Task("something different")) + > assert tasks_db.count() == 1 E assert 3 == 1 - E + where 3 = >() - E + where > = .count + E + where 3 = >() + E + where > = .count test_count.py:15: AssertionError =========================== short test summary info ============================ FAILED test_count.py::test_count2 - assert 3 == 1 ========================= 1 failed, 2 passed in 0.03s ========================== -There are three items in the database because the previous test already added -two items before ``test_count2`` was executed. However, tests should not rely on +There are three tasks in the database because the previous test already added +two tasks before ``test_count2`` was executed. However, tests should not rely on the order of execution. ``test_count2`` only succeeds if it is executed alone, but fails if it is executed after ``test_count``. If we still want to try to work with an open database but start all tests with -zero items in the database, we can do this by adding another fixture in +zero tasks in the database, we can do this by adding another fixture in :file:`conftest.py`: .. code-block:: python @pytest.fixture(scope="session") def db(): - """ItemsDB object connected to a temporary database""" + """TasksDB object connected to a temporary database""" with TemporaryDirectory() as db_dir: db_path = Path(db_dir) - db_ = items.ItemsDB(db_path) + db_ = tasks.TasksDB(db_path) yield db_ db_.close() @pytest.fixture(scope="function") - def items_db(db): - """ItemsDB object that's empty""" + def tasks_db(db): + """TasksDB object that's empty""" db.delete_all() return db -I have renamed the old ``items_db`` to ``db`` and moved it to the session area. +I have renamed the old ``tasks_db`` to ``db`` and moved it to the session area. -The ``items_db`` fixture has ``db`` in its :term:`parameter` list, which means -that it depends on the ``db`` fixture. In addition, ``items_db`` is +The ``tasks_db`` fixture has ``db`` in its :term:`parameter` list, which means +that it depends on the ``db`` fixture. In addition, ``tasks_db`` is ``function``-orientated, which is a narrower scope than ``db``. If fixtures depend on other fixtures, they can only use fixtures that have the same or a larger scope. @@ -473,28 +473,28 @@ Let’s see if it works: .. code-block:: pytest - $ pytest --setup-show test_count.py + $ uv run pytest --setup-show test_count.py ============================= test session starts ============================== … collected 3 items test_count.py SETUP S db - SETUP F items_db (fixtures used: db) - test_count.py::test_empty (fixtures used: db, items_db). - TEARDOWN F items_db - SETUP F items_db (fixtures used: db) - test_count.py::test_count (fixtures used: db, items_db). - TEARDOWN F items_db - SETUP F items_db (fixtures used: db) - test_count.py::test_count2 (fixtures used: db, items_db). - TEARDOWN F items_db + SETUP F tasks_db (fixtures used: db) + test_count.py::test_empty (fixtures used: db, tasks_db). + TEARDOWN F tasks_db + SETUP F tasks_db (fixtures used: db) + test_count.py::test_count (fixtures used: db, tasks_db). + TEARDOWN F tasks_db + SETUP F tasks_db (fixtures used: db) + test_count.py::test_count2 (fixtures used: db, tasks_db). + TEARDOWN F tasks_db TEARDOWN S db ============================== 3 passed in 0.00s =============================== We see that the setup for ``db`` is done first and has the scope of the session -(from the ``S``). The setup for ``items_db`` happens next and before each test +(from the ``S``). The setup for ``tasks_db`` happens next and before each test function call and has the scope of the function (from the ``F``). In addition, all three tests are passed. @@ -505,44 +505,44 @@ Using multiple fixtures per test or fixture ------------------------------------------- Another way to use multiple fixtures is to use more than one in a function or -fixture. For example, we can put some pre-planned items together to test them in +fixture. For example, we can put some pre-planned tasks together to test them in one fixture: .. code-block:: python @pytest.fixture(scope="session") - def items_list(): - """List of different Item objects""" + def tasks_list(): + """List of different Task objects""" return [ - items.Item("Add Python 3.12 static type improvements", "veit", "todo"), - items.Item("Add tips for efficient testing", "veit", "wip"), - items.Item("Update cibuildwheel section", "veit", "done"), - items.Item("Add backend examples", "veit", "done"), + tasks.Task("Add Python 3.12 static type improvements", "veit", "todo"), + tasks.Task("Add tips for efficient testing", "veit", "wip"), + tasks.Task("Update cibuildwheel section", "veit", "done"), + tasks.Task("Add backend examples", "veit", "done"), ] -Then we can use both ``empty_db`` and ``items_list`` in ``test_add.py``: +Then we can use both ``empty_db`` and ``tasks_list`` in ``test_add.py``: .. code-block:: python - def test_add_list(items_db, items_list): - expected_count = len(items_list) - for i in items_list: - items_db.add_item(i) - assert items_db.count() == expected_count + def test_add_list(tasks_db, tasks_list): + expected_count = len(tasks_list) + for i in tasks_list: + tasks_db.add_task(i) + assert tasks_db.count() == expected_count And fixtures can also use several other fixtures: .. code-block:: python @pytest.fixture(scope="function") - def populated_db(items_db, items_list): - """ItemsDB object populated with 'items_list'""" - for i in items_list: - items_db.add_item(i) - return items_db + def populated_db(tasks_db, tasks_list): + """TasksDB object populated with 'tasks_list'""" + for i in tasks_list: + tasks_db.add_task(i) + return tasks_db The fixture ``populated_db`` must be in the function area, as it uses -``items_db``, which is already in the ``function`` area. If you try to place +``tasks_db``, which is already in the ``function`` area. If you try to place ``populated_db`` in the ``module`` area or a larger area, pytest will issue an error. Don't forget that if you don’t specify a range, you will get fixtures in the ``function`` area. Tests that require a populated database can now simply do @@ -563,8 +563,8 @@ Set fixture scope dynamically ----------------------------- Let’s assume we have set up the fixtures as they are now, with ``db`` in the -``session`` scope and ``items_db`` in the ``function`` scope. However, there is -now a risk that the ``items_db`` fixture is empty because it calls +``session`` scope and ``tasks_db`` in the ``function`` scope. However, there is +now a risk that the ``tasks_db`` fixture is empty because it calls :func:`delete_all`. We therefore want to create a way of setting up the database completely for each test function by dynamically defining the scope of the ``db`` fixture at runtime. To do this, we first change the scope of ``db`` in @@ -574,10 +574,10 @@ the :file:`conftest.py` file: @pytest.fixture(scope=db_scope) def db(): - """ItemsDB object connected to a temporary database""" + """TasksDB object connected to a temporary database""" with TemporaryDirectory() as db_dir: db_path = Path(db_dir) - db_ = items.ItemsDB(db_path) + db_ = tasks.TasksDB(db_path) yield db_ db_.close() @@ -611,22 +611,22 @@ After all this, the default behaviour is the same as before, with ``db`` in the .. code-block:: pytest - $ pytest --setup-show test_count.py + $ uv run pytest --setup-show test_count.py ============================= test session starts ============================== … collected 3 items test_count.py SETUP S db - SETUP F items_db (fixtures used: db) - test_count.py::test_empty (fixtures used: db, items_db). - TEARDOWN F items_db - SETUP F items_db (fixtures used: db) - test_count.py::test_count (fixtures used: db, items_db). - TEARDOWN F items_db - SETUP F items_db (fixtures used: db) - test_count.py::test_count2 (fixtures used: db, items_db). - TEARDOWN F items_db + SETUP F tasks_db (fixtures used: db) + test_count.py::test_empty (fixtures used: db, tasks_db). + TEARDOWN F tasks_db + SETUP F tasks_db (fixtures used: db) + test_count.py::test_count (fixtures used: db, tasks_db). + TEARDOWN F tasks_db + SETUP F tasks_db (fixtures used: db) + test_count.py::test_count2 (fixtures used: db, tasks_db). + TEARDOWN F tasks_db TEARDOWN S db ============================== 3 passed in 0.00s =============================== @@ -636,26 +636,26 @@ scope: .. code-block:: pytest - $ pytest --fdb --setup-show test_count.py + $ uv run pytest --fdb --setup-show test_count.py ============================= test session starts ============================== … collected 3 items test_count.py SETUP F db - SETUP F items_db (fixtures used: db) - test_count.py::test_empty (fixtures used: db, items_db). - TEARDOWN F items_db + SETUP F tasks_db (fixtures used: db) + test_count.py::test_empty (fixtures used: db, tasks_db). + TEARDOWN F tasks_db TEARDOWN F db SETUP F db - SETUP F items_db (fixtures used: db) - test_count.py::test_count (fixtures used: db, items_db). - TEARDOWN F items_db + SETUP F tasks_db (fixtures used: db) + test_count.py::test_count (fixtures used: db, tasks_db). + TEARDOWN F tasks_db TEARDOWN F db SETUP F db - SETUP F items_db (fixtures used: db) - test_count.py::test_count2 (fixtures used: db, items_db). - TEARDOWN F items_db + SETUP F tasks_db (fixtures used: db) + test_count.py::test_count2 (fixtures used: db, tasks_db). + TEARDOWN F tasks_db TEARDOWN F db ============================== 3 passed in 0.00s =============================== @@ -688,7 +688,7 @@ for example: .. code-block:: pytest - pytest --setup-show test_count.py + $ uv run pytest --setup-show test_count.py ============================= test session starts ============================== … collected 3 items @@ -696,15 +696,15 @@ for example: test_count.py SETUP S setup_test_env SETUP S db - SETUP F items_db (fixtures used: db) - test_count.py::test_empty (fixtures used: db, items_db, setup_test_env). - TEARDOWN F items_db - SETUP F items_db (fixtures used: db) - test_count.py::test_count (fixtures used: db, items_db, setup_test_env). - TEARDOWN F items_db - SETUP F items_db (fixtures used: db) - test_count.py::test_count2 (fixtures used: db, items_db, setup_test_env). - TEARDOWN F items_db + SETUP F tasks_db (fixtures used: db) + test_count.py::test_empty (fixtures used: db, tasks_db, setup_test_env). + TEARDOWN F tasks_db + SETUP F tasks_db (fixtures used: db) + test_count.py::test_count (fixtures used: db, tasks_db, setup_test_env). + TEARDOWN F tasks_db + SETUP F tasks_db (fixtures used: db) + test_count.py::test_count2 (fixtures used: db, tasks_db, setup_test_env). + TEARDOWN F tasks_db TEARDOWN S db TEARDOWN S setup_test_env @@ -727,7 +727,7 @@ parameter to ``@pytest.fixture()``: import pytest - from items import cli + from cusy.tasks import cli @pytest.fixture(scope="session", name="db") @@ -737,7 +737,7 @@ parameter to ``@pytest.fixture()``: def test_empty(db): - assert items_db.count() == 0 + assert tasks_db.count() == 0 One case in which renaming can be useful is if the most obvious fixture name already exists as a variable or function name. diff --git a/docs/test/pytest/functions.rst b/docs/test/pytest/functions.rst index 78cc2456..b76c22ca 100644 --- a/docs/test/pytest/functions.rst +++ b/docs/test/pytest/functions.rst @@ -39,29 +39,29 @@ at a failed ``assert`` test: .. code-block:: python def test_equality_fails(): - i1 = Item("do something", "veit") - i2 = Item("do something else", "veit") + i1 = Task("do something", "veit") + i2 = Task("do something else", "veit") assert i1 == i2 This test fails, but the traceback information is interesting: .. code-block:: pytest - $ pytest tests/test_item_fails.py + $ uv run pytest tests/test_task_fails.py ============================= test session starts ============================== … collected 1 item - tests/test_item_fails.py F [100%] + tests/test_task_fails.py F [100%] =================================== FAILURES =================================== _____________________________ test_equality_fails ______________________________ def test_equality_fails(): - i1 = Item("do something", "veit") - i2 = Item("do something else", "veit.schiele") + i1 = Task("do something", "veit") + i2 = Task("do something else", "veit.schiele") > assert i1 == i2 - E AssertionError: assert Item(summary=...odo', id=None) == Item(summary=...odo', id=None) + E AssertionError: assert Task(summary=...odo', id=None) == Task(summary=...odo', id=None) E E Omitting 1 identical items, use -vv to show E Differing attributes: @@ -72,9 +72,9 @@ This test fails, but the traceback information is interesting: E E ...Full output truncated (8 lines hidden), use '-vv' to show - tests/test_item_fails.py:7: AssertionError + tests/test_task_fails.py:7: AssertionError =========================== short test summary info ============================ - FAILED tests/test_item_fails.py::test_equality_fails - AssertionError: assert Item(summary=...odo', id=None) == Item(summary=...od... + FAILED tests/test_task_fails.py::test_equality_fails - AssertionError: assert Task(summary=...odo', id=None) == Task(summary=...od... ============================== 1 failed in 0.03s =============================== That’s a lot of information: @@ -89,21 +89,21 @@ with the ``-vv`` option as suggested in the error message: .. code-block:: pytest - $ pytest -vv tests/test_item_fails.py + $ pytest -vv tests/test_task_fails.py ============================= test session starts ============================== … collected 1 item - tests/test_item_fails.py::test_equality_fails FAILED [100%] + tests/test_task_fails.py::test_equality_fails FAILED [100%] =================================== FAILURES =================================== _____________________________ test_equality_fails ______________________________ def test_equality_fails(): - i1 = Item("do something", "veit") - i2 = Item("do something else", "veit.schiele") + i1 = Task("do something", "veit") + i2 = Task("do something else", "veit.schiele") > assert i1 == i2 - E AssertionError: assert Item(summary='do something', owner='veit', state='todo', id=None) == Item(summary='do something else', owner='veit.schiele', state='todo', id=None) + E AssertionError: assert Task(summary='do something', owner='veit', state='todo', id=None) == Task(summary='do something else', owner='veit.schiele', state='todo', id=None) E E Matching attributes: E ['state'] @@ -121,9 +121,9 @@ with the ``-vv`` option as suggested in the error message: E - veit.schiele E + veit - tests/test_item_fails.py:7: AssertionError + tests/test_task_fails.py:7: AssertionError =========================== short test summary info ============================ - FAILED tests/test_item_fails.py::test_equality_fails - AssertionError: assert Item(summary='do something', owner='veit', state='to... + FAILED tests/test_task_fails.py::test_equality_fails - AssertionError: assert Task(summary='do something', owner='veit', state='to... ============================== 1 failed in 0.03s =============================== pytest has listed exactly which attributes match and which do not. The exact @@ -131,7 +131,7 @@ deviations were also highlighted. For comparison, we can see what Python displays for ``assert`` errors. To be able to call the test directly from Python, we need to add a block at the end of -:file:`tests/test_item_fails.py`: +:file:`tests/test_task_fails.py`: .. code-block:: python @@ -142,11 +142,11 @@ If we now run the test with Python, we get the following result: .. code-block:: pycon - python tests/test_item_fails.py + $ uv run python tests/test_task_fails.py Traceback (most recent call last): - File "tests/test_item_fails.py", line 11, in + File "tests/test_task_fails.py", line 11, in test_equality_fails() - File "tests/test_item_fails.py", line 7, in test_equality_fails + File "tests/test_task_fails.py", line 7, in test_equality_fails assert i1 == i2 ^^^^^^^^ AssertionError @@ -176,35 +176,35 @@ test: .. code-block:: python def test_with_fail(): - i1 = Item("do something", "veit") - i2 = Item("do something else", "veit.schiele") + i1 = Task("do something", "veit") + i2 = Task("do something else", "veit.schiele") if i1 != i2: - pytest.fail("The items are not identical!") + pytest.fail("The tasks are not identical!") The output is as follows: .. code-block:: pytest - pytest tests/test_item_fails.py + $ uv run pytest tests/test_task_fails.py ============================= test session starts ============================== … collected 1 item - tests/test_item_fails.py F [100%] + tests/test_task_fails.py F [100%] =================================== FAILURES =================================== ________________________________ test_with_fail ________________________________ def test_with_fail(): - i1 = Item("do something", "veit") - i2 = Item("do something else", "veit.schiele") + i1 = Task("do something", "veit") + i2 = Task("do something else", "veit.schiele") if i1 != i2: - > pytest.fail("The items are not identical!") - E Failed: The items are not identical! + > pytest.fail("The tasks are not identical!") + E Failed: The tasks are not identical! - tests/test_item_fails.py:10: Failed + tests/test_task_fails.py:10: Failed =========================== short test summary info ============================ - FAILED tests/test_item_fails.py::test_with_fail - Failed: The items are not identical! + FAILED tests/test_task_fails.py::test_with_fail - Failed: The tasks are not identical! ============================== 1 failed in 0.03s =============================== When calling ``pytest.fail()`` or throwing an exception, we do not get the @@ -215,7 +215,7 @@ Writing ``assertion`` helper functions -------------------------------------- An ``assertion`` helper function is used to package a complicated ``assertion`` -check. For example, the ``Item`` data class is set up so that two items with +check. For example, the ``Task`` data class is set up so that two tasks with different IDs still report equality. If we want a stricter check, we could write a helper function called ``assert_ident`` as follows: @@ -223,10 +223,10 @@ write a helper function called ``assert_ident`` as follows: import pytest - from items import Item + from cusy.tasks import Task - def assert_ident(i1: Item, i2: Item): + def assert_ident(i1: Task, i2: Task): __tracebackhide__ = True assert i1 == i2 if i1.id != i2.id: @@ -234,14 +234,14 @@ write a helper function called ``assert_ident`` as follows: def test_ident(): - i1 = Item("something to do", id=42) - i2 = Item("something to do", id=42) + i1 = Task("something to do", id=42) + i2 = Task("something to do", id=42) assert_ident(i1, i2) def test_ident_fail(): - i1 = Item("something to do", id=42) - i2 = Item("something to do", id=43) + i1 = Task("something to do", id=42) + i2 = Task("something to do", id=43) assert_ident(i1, i2) @@ -254,7 +254,7 @@ helpful message. Let’s take a look at what this looks like after execution: .. code-block:: pytest - $ pytest tests/test_helper.py + $ uv run pytest tests/test_helper.py ============================= test session starts ============================== … collected 2 items @@ -265,8 +265,8 @@ helpful message. Let’s take a look at what this looks like after execution: _______________________________ test_ident_fail ________________________________ def test_ident_fail(): - i1 = Item("something to do", id=42) - i2 = Item("something to do", id=43) + i1 = Task("something to do", id=42) + i2 = Task("something to do", id=43) > assert_ident(i1, i2) E Failed: The IDs do not match: 42 != 43 @@ -281,15 +281,15 @@ Testing for expected exceptions We have looked at how any exception can cause a test to fail. But what if part of the code we are testing should raise an exception? For this we use ``pytest.raises()`` to test for expected exceptions. An example of this would be -the Items API, which has an ``ItemsDB`` class that requires a path argument. +the Tasks API, which has an ``TasksDB`` class that requires a path argument. .. code-block:: python - from items.api import ItemsDB + from tasks.api import TasksDB def test_db_exists(): - ItemsDB() + TasksDB() .. code-block:: pytest @@ -303,29 +303,29 @@ the Items API, which has an ``ItemsDB`` class that requires a path argument. =================================== FAILURES =================================== ________________________________ test_db_exists ________________________________ tests/test_db.py:5: in test_db_exists - ItemsDB() - E TypeError: ItemsDB.__init__() missing 1 required positional argument: 'db_path' + TasksDB() + E TypeError: TasksDB.__init__() missing 1 required positional argument: 'db_path' =========================== short test summary info ============================ - FAILED tests/test_db.py::test_db_exists - TypeError: ItemsDB.__init__() missing 1 required positional argument: 'db_p... + FAILED tests/test_db.py::test_db_exists - TypeError: TasksDB.__init__() missing 1 required positional argument: 'db_p... ============================== 1 failed in 0.03s =============================== Here I have used the shorter traceback format ``--tb=short`` because we don’t need to see the full traceback to find out which exception was thrown. The exception ``TypeError`` seems to make sense because the error occurs when -trying to initialise the custom ``ItemsDB`` type. We can write a test to ensure +trying to initialise the custom ``TasksDB`` type. We can write a test to ensure that this exception is thrown, something like this: .. code-block:: python import pytest - from items.api import ItemsDB + from cusy.tasks.api import TasksDB def test_db_exists(): with pytest.raises(TypeError): - ItemsDB() + TasksDB() The instruction with ``pytest.raises(TypeError):`` states that the next code block should throw a ``TypeError`` exception. If no exception or another @@ -340,7 +340,7 @@ as additional :term:`parameters `: def test_db_exists(): match_regex = "missing 1 .* positional argument" with pytest.raises(TypeError, match=match_regex): - ItemsDB() + TasksDB() or @@ -348,6 +348,6 @@ or def test_db_exists(): with pytest.raises(TypeError) as exc_info: - ItemsDB() + TasksDB() expected = "missing 1 required positional argument" assert expected in str(exc_info.value) diff --git a/docs/test/pytest/index.rst b/docs/test/pytest/index.rst index 65cf541d..291f7171 100644 --- a/docs/test/pytest/index.rst +++ b/docs/test/pytest/index.rst @@ -24,16 +24,34 @@ module that simplifies testing even further. * pytest has an extensive ecosystem with over 800 :doc:`plugins` for advanced testing requirements; unittest is more limited in its extensibility. +.. note:: + We also use pytest for agent-based software development: + + .. code-block:: md + :caption: AGENTS.md + + # Testing + - Use `pytest` for testing your code. + - Collect pytest fixtures in a `conftest.py` file to avoid duplication. + - When a test fails, run the last failed test first using `uv run pytest --last-failed`. + + .. seealso:: + * :ref:`agentic-software-engineering:testing` + +.. seealso:: + The :doc:`Jupyter tutorial ` + explains how pytest can also be used in Jupyter Notebooks. + Installation ------------ -You can install pytest in `virtual environments ` with: +You can install pytest using :ref:`uv`: .. tab:: Linux/macOS .. code-block:: console - $ python -m pip install pytest + $ uv add --group tests pytest Collecting pytest ... Successfully installed attrs-21.2.0 iniconfig-1.1.1 pluggy-1.0.0 py-1.10.0 pytest-6.2.5 toml-0.10.2 @@ -42,7 +60,7 @@ You can install pytest in `virtual environments ` with: .. code-block:: ps1con - C:> python -m pip install pytest + C:> uv add --group tests pytest Collecting pytest ... Successfully installed attrs-21.2.0 iniconfig-1.1.1 pluggy-1.0.0 py-1.10.0 pytest-6.2.5 toml-0.10.2 diff --git a/docs/test/pytest/markers.rst b/docs/test/pytest/markers.rst index 26b814a1..7a4c1d73 100644 --- a/docs/test/pytest/markers.rst +++ b/docs/test/pytest/markers.rst @@ -47,31 +47,31 @@ Skipping tests with ``@pytest.mark.skip`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The ``skip`` marker allows us to skip a test. Let’s say we want to add the -ability to sort in a future version of the ``Items`` application and want the -``Item`` class to support comparisons. We write a test for comparing ``Item`` +ability to sort in a future version of the ``Tasks`` application and want the +``Task`` class to support comparisons. We write a test for comparing ``Task`` objects with ``<`` as follows: .. code-block:: python - from items import Item + from cusy.tasks import Task def test_less_than(): - i1 = Item("Update pytest section") - i2 = Item("Update cibuildwheel section") + i1 = Task("Update pytest section") + i2 = Task("Update cibuildwheel section") assert i1 < i2 def test_equality(): - i1 = Item("Update pytest section") - i2 = Item("Update pytest section") + i1 = Task("Update pytest section") + i2 = Task("Update pytest section") assert i1 == i2 And it fails: .. code-block:: pytest - pytest --tb=short tests/test_compare.py + $ uv run pytest --tb=short tests/test_compare.py ============================= test session starts ============================== ... collected 2 items @@ -82,9 +82,9 @@ And it fails: ________________________________ test_less_than ________________________________ tests/test_compare.py:7: in test_less_than assert i1 < i2 - E TypeError: '<' not supported between instances of 'Item' and 'Item' + E TypeError: '<' not supported between instances of 'Task' and 'Task' =========================== short test summary info ============================ - FAILED tests/test_compare.py::test_less_than - TypeError: '<' not supported between instances of 'Item' and 'Item' + FAILED tests/test_compare.py::test_less_than - TypeError: '<' not supported between instances of 'Task' and 'Task' ========================= 1 failed, 1 passed in 0.03s ========================== The error is simply due to the fact that we have not yet implemented this @@ -96,13 +96,13 @@ omit it: import pytest - from items import Item + from cusy.tasks import Task - @pytest.mark.skip(reason="Items do not yet allow a < comparison") + @pytest.mark.skip(reason="Tasks do not yet allow a < comparison") def test_less_than(): - i1 = Item("Update pytest section") - i2 = Item("Update cibuildwheel section") + i1 = Task("Update pytest section") + i2 = Task("Update cibuildwheel section") assert i1 < i2 The marker ``@pytest.mark.skip()`` instructs pytest to skip the test. Specifying @@ -112,7 +112,7 @@ skipped tests, they are displayed as ``s``: .. code-block:: :emphasize-lines: 6 - $ pytest --tb=short tests/test_compare.py + $ uv run pytest --tb=short tests/test_compare.py ============================= test session starts ============================== ... collected 2 items @@ -126,16 +126,16 @@ skipped tests, they are displayed as ``s``: .. code-block:: :emphasize-lines: 1, 10 - $ pytest -v -ra tests/test_compare.py + $ uv run pytest -v -ra tests/test_compare.py ============================= test session starts ============================== ... collected 2 items - tests/test_compare.py::test_less_than SKIPPED (Items do not yet allo...) [ 50%] + tests/test_compare.py::test_less_than SKIPPED (Tasks do not yet allo...) [ 50%] tests/test_compare.py::test_equality PASSED [100%] =========================== short test summary info ============================ - SKIPPED [1] tests/test_compare.py:6: Items do not yet allow a < comparison + SKIPPED [1] tests/test_compare.py:6: Tasks do not yet allow a < comparison ========================= 1 passed, 1 skipped in 0.00s ========================= Since we have instructed pytest with ``-r`` to output a short summary of our @@ -151,9 +151,9 @@ certain tests failed. Conditional skipping of tests with ``@pytest.mark.skipif`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Suppose we know that we will not support sorting in versions 0.1.x of the Items +Suppose we know that we will not support sorting in versions 0.1.x of the Tasks app, but we will support it in version 0.2.x. Then we can instruct pytest to -skip the test for all versions of items lower than 0.2.x as follows: +skip the test for all versions of cusy.tasks lower than 0.2.x as follows: .. code-block:: python :emphasize-lines: 2, 4, 8-11 @@ -161,17 +161,17 @@ skip the test for all versions of items lower than 0.2.x as follows: import pytest from packaging.version import parse - import items - from items import Item + from cusy import tasks + from cusy.tasks import Task @pytest.mark.skipif( - parse(items.__version__).minor < 2, + parse(tasks.__version__).minor < 2, reason="The comparison with < is not yet supported in version 0.1.x.", ) def test_less_than(): - i1 = Item("Update pytest section") - i2 = Item("Update cibuildwheel section") + i1 = Task("Update pytest section") + i2 = Task("Update cibuildwheel section") assert i1 < i2 With the ``skipif`` marker, you can enter as many conditions as you like, and if @@ -181,7 +181,7 @@ number 2. In this example, `packaging `_ is used as an additional package. If you want to try out the example, install it first with -``python -m pip install packaging``. +``uv add --group dev packaging``. .. tip:: ``skipif`` is also ideal if tests need to be written differently for @@ -216,31 +216,31 @@ Let’s take a look at an example: import pytest from packaging.version import parse - import items - from items import Item + import cusy.tasks + from cusy.tasks import Task @pytest.mark.xfail( - parse(items.__version__).minor < 2, + parse(tasks.__version__).minor < 2, reason="The comparison with < is not yet supported in version 0.1.x.", ) def test_less_than(): - i1 = Item("Update pytest section") - i2 = Item("Update cibuildwheel section") + i1 = Task("Update pytest section") + i2 = Task("Update cibuildwheel section") assert i1 < i2 @pytest.mark.xfail(reason="Feature #17: not implemented yet") def test_xpass(): - i1 = Item("Update pytest section") - i2 = Item("Update pytest section") + i1 = Task("Update pytest section") + i2 = Task("Update pytest section") assert i1 == i2 @pytest.mark.xfail(reason="Feature #17: not implemented yet", strict=True) def test_strict_xfail(): - i1 = Item("Update pytest section") - i2 = Item("Update pytest section") + i1 = Task("Update pytest section") + i2 = Task("Update pytest section") assert i1 == i2 We have three tests here: one that we know will fail, and two that we know will @@ -251,7 +251,7 @@ this is what the result looks like: .. code-block:: - pytest -v -ra tests/test_xfail.py + $ uv run pytest -v -ra tests/test_xfail.py ============================= test session starts ============================== ... collected 3 items @@ -334,24 +334,24 @@ any of the main systems. In addition, we will label some of our tests with import pytest - from items import InvalidItemId, Item + from cusy.tasks import InvalidTaskId, Task @pytest.mark.smoke - def test_start(items_db): + def test_start(tasks_db): """ Change state from ‘todo’ to ‘in progress’ """ - i = items_db.add_item(Item("Update pytest section", state="todo")) - items_db.start(i) - s = items_db.get_item(i) + i = tasks_db.add_task(Task("Update pytest section", state="todo")) + tasks_db.start(i) + s = tasks_db.get_task(i) assert s.state == "in progress" Now we should be able to select only this test by using the ``-m smoke`` option: .. code-block:: pytest - $ pytest -v -m smoke tests/test_start.py + $ uv run pytest -v -m smoke tests/test_start.py ============================= test session starts ============================== ... collected 2 items / 1 deselected / 1 selected @@ -360,7 +360,7 @@ Now we should be able to select only this test by using the ``-m smoke`` option: =============================== warnings summary =============================== tests/test_start.py:6 - /Users/veit/items/tests/test_start.py:6: PytestUnknownMarkWarning: Unknown pytest.mark.smoke - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html + /Users/veit/cusy.tasks/tests/test_start.py:6: PytestUnknownMarkWarning: Unknown pytest.mark.smoke - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html @pytest.mark.smoke -- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html @@ -383,7 +383,7 @@ Now pytest no longer warns us of an unknown marker: .. code-block:: :emphasize-lines: 4 - $ pytest -v -m smoke tests/test_start.py + $ uv run pytest -v -m smoke tests/test_start.py ============================= test session starts ============================== ... configfile: pyproject.toml @@ -412,21 +412,21 @@ Let's do the same with the ``exception`` marker for ``test_start_non_existent``. :emphasize-lines: 1 @pytest.mark.exception - def test_start_non_existent(items_db): + def test_start_non_existent(tasks_db): """ - Shouldn’t start a non-existent item. + Shouldn’t start a non-existent task. """ # any_number will be invalid, db is empty any_number = 44 - with pytest.raises(InvalidItemId): - items_db.start(any_number) + with pytest.raises(InvalidTaskId): + tasks_db.start(any_number) #. Finally, we run the test with ``-m exception``: .. code-block:: pytest - $ pytest -v -m exception tests/test_start.py + $ uv run pytest -v -m exception tests/test_start.py ============================= test session starts ============================== ... configfile: pyproject.toml @@ -451,7 +451,7 @@ We can even set multiple markers on a single test. First, we set in import pytest - from items import Item + from cusy.tasks import Task pytestmark = pytest.mark.finish @@ -468,24 +468,24 @@ and use markers at class level: @pytest.mark.smoke class TestFinish: - def test_finish_from_todo(self, items_db): - i = items_db.add_item(Item("Update pytest section", state="todo")) - items_db.finish(i) - s = items_db.get_item(i) + def test_finish_from_todo(self, tasks_db): + i = tasks_db.add_task(Task("Update pytest section", state="todo")) + tasks_db.finish(i) + s = tasks_db.get_task(i) assert s.state == "done" - def test_finish_from_in_prog(self, items_db): - i = items_db.add_item( - Item("Update pytest section", state="in progress") + def test_finish_from_in_prog(self, tasks_db): + i = tasks_db.add_task( + Task("Update pytest section", state="in progress") ) - items_db.finish(i) - s = items_db.get_item(i) + tasks_db.finish(i) + s = tasks_db.get_task(i) assert s.state == "done" - def test_finish_from_done(self, items_db): - i = items_db.add_item(Item("Update pytest section", state="done")) - items_db.finish(i) - s = items_db.get_item(i) + def test_finish_from_done(self, tasks_db): + i = tasks_db.add_task(Task("Update pytest section", state="done")) + tasks_db.finish(i) + s = tasks_db.get_task(i) assert s.state == "done" The test class :class:`TestFinish` is labelled with ``@pytest.mark.smoke``. If @@ -506,10 +506,10 @@ test: "done", ], ) - def test_finish(items_db, start_state): - i = items_db.add_item(Item("Update pytest section", state=states)) - items_db.finish(i) - s = items_db.get_item(i) + def test_finish(tasks_db, start_state): + i = tasks_db.add_task(Task("Update pytest section", state=states)) + tasks_db.finish(i) + s = tasks_db.get_task(i) assert s.state == "done" The :func:`test_finish` function is not directly marked, but only one of its @@ -537,12 +537,12 @@ However, you can also mark fixtures in the same way: return request.param - def test_finish(items_db, start_state_fixture): - i = items_db.add_item( - Item("Update pytest section", state=start_state_fixture) + def test_finish(tasks_db, start_state_fixture): + i = tasks_db.add_task( + Task("Update pytest section", state=start_state_fixture) ) - items_db.finish(i) - s = items_db.get_item(i) + tasks_db.finish(i) + s = tasks_db.get_task(i) assert s.state == "done" If you want to add more than one marker to a function, you can simply stack @@ -552,15 +552,15 @@ them. For example, :func:`test_finish_non_existent` is marked with both .. code-block:: python :emphasize-lines: 4-5 - from items import InvalidItemId, Item + from cusy.tasks import InvalidTaskId, Task @pytest.mark.smoke @pytest.mark.exception - def test_finish_non_existent(items_db): + def test_finish_non_existent(tasks_db): i = 44 # any_number will be invalid, db is empty - with pytest.raises(InvalidItemId): - items_db.finish(i) + with pytest.raises(InvalidTaskId): + tasks_db.finish(i) We have added a number of markers to :file:`test_finish.py` in various ways. We use the markers to select the tests to be executed instead of a test file: @@ -568,7 +568,7 @@ use the markers to select the tests to be executed instead of a test file: .. code-block:: pytest $ cd tests - $ tests % pytest -v -m exception + $ $ uv run pytest -v -m exception ============================= test session starts ============================== ... configfile: pyproject.toml @@ -588,7 +588,7 @@ we can only select the ``finish`` tests that deal with ``exception``: .. code-block:: pytest - $ pytest -v -m "finish and exception" + $ uv run pytest -v -m "finish and exception" ============================= test session starts ============================== ... configfile: pyproject.toml @@ -602,7 +602,7 @@ We can also use all logical operations together: .. code-block:: pytest - $ pytest -v -m "(exception or smoke) and (not finish)" + $ uv run pytest -v -m "(exception or smoke) and (not finish)" ============================= test session starts ============================== ... configfile: pyproject.toml @@ -619,7 +619,7 @@ class: .. code-block:: console - $ pytest -v -m smoke -k "not TestFinish" + $ uv run pytest -v -m smoke -k "not TestFinish" ============================= test session starts ============================== ... configfile: pyproject.toml @@ -657,7 +657,7 @@ advantages: :emphasize-lines: 3-4 [tool.pytest] - … + ... addopts = [ "--strict-markers", ] @@ -670,77 +670,77 @@ Combining markers with fixtures Markers can be used in conjunction with fixtures, plugins and hook functions. The built-in markers require :term:`parameters `, while the custom markers we have used so far do not require parameters. Let’s create a new marker -called ``num_items`` that we can pass to the ``items_db`` fixture. The -``items_db`` fixture currently cleans up the database for each test that wants +called ``num_tasks`` that we can pass to the ``tasks_db`` fixture. The +``tasks_db`` fixture currently cleans up the database for each test that wants to use it: .. code-block:: python @pytest.fixture(scope="function") - def items_db(session_items_db): - db = session_items_db + def tasks_db(session_tasks_db): + db = session_tasks_db db.delete_all() return db -For example, if we want to have four items in the database when our test starts, +For example, if we want to have four tasks in the database when our test starts, we can simply write a different but similar fixture: .. code-block:: python @pytest.fixture(scope="session") - def items_list(): - """List of different Item objects""" + def tasks_list(): + """List of different Task objects""" return [ - items.Item("Add Python 3.12 static type improvements", "veit", "todo"), - items.Item("Add tips for efficient testing", "veit", "wip"), - items.Item("Update cibuildwheel section", "veit", "done"), - items.Item("Add backend examples", "veit", "done"), + tasks.Task("Add Python 3.12 static type improvements", "veit", "todo"), + tasks.Task("Add tips for efficient testing", "veit", "wip"), + tasks.Task("Update cibuildwheel section", "veit", "done"), + tasks.Task("Add backend examples", "veit", "done"), ] @pytest.fixture(scope="function") - def populated_db(items_db, items_list): - """ItemsDB object populated with 'items_list'""" - for i in items_list: - items_db.add_item(i) - return items_db + def populated_db(tasks_db, tasks_list): + """TasksDB object populated with 'tasks_list'""" + for i in tasks_list: + tasks_db.add_task(i) + return tasks_db We could then use the original fixture for tests, which provides an empty database, and the new fixture for tests, which contains a database with four -items: +tasks: .. code-block:: python - def test_zero_item(items_db): - assert items_db.count() == 0 + def test_zero_task(tasks_db): + assert tasks_db.count() == 0 - def test_four_items(populated_db): + def test_four_tasks(populated_db): assert populated_db.count() == 4 -We now have the option of testing either zero or four items in the database. But -what if we want to have no, four or 13 items? Then we don’t want to write a new -fixture each time. Markers allow us to tell a test how many items we want to +We now have the option of testing either zero or four tasks in the database. But +what if we want to have no, four or 13 tasks? Then we don’t want to write a new +fixture each time. Markers allow us to tell a test how many tasks we want to have. This requires three steps: -#. First, we define three different tests in :file:`test_items.py` with our - marker ``@pytest.mark.num_items``: +#. First, we define three different tests in :file:`test_tasks.py` with our + marker ``@pytest.mark.num_tasks``: .. code-block:: python - @pytest.mark.num_items - def test_zero_item(items_db): - assert items_db.count() == 0 + @pytest.mark.num_tasks + def test_zero_task(tasks_db): + assert tasks_db.count() == 0 - @pytest.mark.num_items(4) - def test_four_items(items_db): - assert items_db.count() == 4 + @pytest.mark.num_tasks(4) + def test_four_tasks(tasks_db): + assert tasks_db.count() == 4 - @pytest.mark.num_items(13) - def test_thirteen_items(items_db): - assert items_db.count() == 13 + @pytest.mark.num_tasks(13) + def test_thirteen_tasks(tasks_db): + assert tasks_db.count() == 13 #. We must then declare this marker in the :file:`pyproject.toml` file: @@ -750,13 +750,13 @@ have. This requires three steps: [tool.pytest] markers = [ "…", - "num_items: Number of items to be pre-filled for the items_db fixture", + "num_tasks: Number of tasks to be pre-filled for the tasks_db fixture", ] -#. Now we modify the ``items_db`` fixture in the :file:`conftest.py` file to be - able to use the marker. To avoid having to hard-code the item information, we +#. Now we modify the ``tasks_db`` fixture in the :file:`conftest.py` file to be + able to use the marker. To avoid having to hard-code the task information, we will use the Python package `Faker `_, which - we can install with ``python -m pip install faker``: + we can install with ``uv add --group tests faker``: .. code-block:: python :linenos: @@ -769,30 +769,30 @@ have. This requires three steps: import faker import pytest - import items + from cusy import tasks ... @pytest.fixture(scope="function") - def items_db(session_items_db, request, faker): - db = session_items_db + def tasks_db(session_tasks_db, request, faker): + db = session_tasks_db db.delete_all() - # Support for random selection "@pytest.mark.num_items({NUMBER})`. + # Support for random selection "@pytest.mark.num_tasks({NUMBER})`. faker.seed_instance(99) - m = request.node.get_closest_marker("num_items") + m = request.node.get_closest_marker("num_tasks") if m and len(m.args) > 0: - num_items = m.args[0] - for _ in range(num_items): - db.add_item( - Item(summary=faker.sentence(), owner=faker.first_name()) + num_tasks = m.args[0] + for _ in range(num_tasks): + db.add_task( + Task(summary=faker.sentence(), owner=faker.first_name()) ) return db There are a lot of changes here that we want to go through now. Line 13 - We have added ``request`` and ``faker`` to the list of ``items_db`` + We have added ``request`` and ``faker`` to the list of ``tasks_db`` :term:`parameters `. Line 18 This sets the randomness of faker so that we get the same data every time. @@ -800,17 +800,17 @@ have. This requires three steps: invent data ourselves. Line 19 Here we use ``request``, more precisely ``request.node`` for the pytest - representation of a test. ``get_closest_marker('num_items')`` returns a - marker object if the test is marked with ``num_items``, otherwise it returns + representation of a test. ``get_closest_marker('num_tasks')`` returns a + marker object if the test is marked with ``num_tasks``, otherwise it returns ``None``. The :func:`get_closest_marker` function returns the marker closest t545o the test, which is usually what we want. Line 20 - The expression is true if the test is marked with ``num_items`` and an + The expression is true if the test is marked with ``num_tasks`` and an argument is given. The additional ``len`` check is there so that if someone - accidentally just uses ``pytest.mark.num_items`` without specifying the - number of items, this part is skipped. + accidentally just uses ``pytest.mark.num_tasks`` without specifying the + number of tasks, this part is skipped. Line 22–24 - Once we know how many items we need to create, we let Faker create some data + Once we know how many tasks we need to create, we let Faker create some data for us. Faker provides the Faker fixture. * For the ``summary`` field, the :func:`faker.sentence` method works. @@ -827,53 +827,53 @@ Let’s run the tests now to make sure everything is working properly: .. code-block:: pytest - $ pytest -v -s test_items.py + $ uv run pytest -v -s test_tasks.py ============================= test session starts ============================== ... configfile: pyproject.toml plugins: Faker-19.10.0 collected 3 items - test_items.py::test_zero_item PASSED - test_items.py::test_four_items PASSED - test_items.py::test_thirteen_items PASSED + test_tasks.py::test_zero_task PASSED + test_tasks.py::test_four_tasks PASSED + test_tasks.py::test_thirteen_tasks PASSED ============================== 3 passed in 0.09s =============================== .. note:: - You can add a ``print`` statement to :func:`test_four_items` to get an + You can add a ``print`` statement to :func:`test_four_tasks` to get an impression of what the data from Faker looks like: .. code-block:: python :emphasize-lines: 4- - @pytest.mark.num_items(4) - def test_four_items(items_db): - assert items_db.count() == 4 + @pytest.mark.num_tasks(4) + def test_four_tasks(tasks_db): + assert tasks_db.count() == 4 print() - for i in items_db.list_items(): + for i in tasks_db.list_tasks(): print(i) - You can then call the tests in :file:`test_items.py` again: + You can then call the tests in :file:`test_tasks.py` again: .. code-block:: pytest :emphasize-lines: 10-13 - $ pytest -v -s test_items.py + $ uv run pytest -v -s test_tasks.py ============================= test session starts ============================== ... configfile: pyproject.toml plugins: Faker-19.10.0 - collected 3 items - - test_items.py::test_zero_item PASSED - test_items.py::test_four_items - Item(summary='Herself outside discover card beautiful rock.', owner='Alyssa', state='todo', id=1) - Item(summary='Bed perhaps current reveal open society small.', owner='Lynn', state='todo', id=2) - Item(summary='Charge produce sure full water.', owner='Allison', state='todo', id=3) - Item(summary='Light I especially account.', owner='James', state='todo', id=4) + collected 3 tasks + + test_tasks.py::test_zero_task PASSED + test_tasks.py::test_four_tasks + Task(summary='Herself outside discover card beautiful rock.', owner='Alyssa', state='todo', id=1) + Task(summary='Bed perhaps current reveal open society small.', owner='Lynn', state='todo', id=2) + Task(summary='Charge produce sure full water.', owner='Allison', state='todo', id=3) + Task(summary='Light I especially account.', owner='James', state='todo', id=4) PASSED - test_items.py::test_thirteen_items PASSED + test_tasks.py::test_thirteen_tasks PASSED ============================== 3 passed in 0.09s =============================== @@ -896,9 +896,9 @@ the following: ALL = {"win32", "darwin", "linux"} - def pytest_setup(item): + def pytest_setup(task): supported_platforms = ALL.intersection( - mark.name for mark in item.iter_markers() + mark.name for mark in task.iter_markers() ) pf = sys.platform if supported_platforms and pf not in supported_platforms: @@ -1023,21 +1023,21 @@ List markers We’ve already covered a lot of markers: the built-in markers ``skip``, ``skipif`` and ``xfail``, our own markers ``smoke``, ``exception``, ``finish`` -and ``num_items`` and there are also a few more built-in markers. And when we +and ``num_tasks`` and there are also a few more built-in markers. And when we start using :doc:`plugins`, more markers may be added. To list all available markers with descriptions and :term:`parameters `, you can run ``pytest --markers``: .. code-block:: pytest - $ pytest --markers + $ uv run pytest --markers @pytest.mark.exception: Only run expected exceptions @pytest.mark.finish: Only run finish tests @pytest.mark.smoke: Small subset of all tests - @pytest.mark.num_items: Number of items to be pre-filled for the items_db fixture + @pytest.mark.num_tasks: Number of tasks to be pre-filled for the tasks_db fixture @pytest.mark.filterwarnings(warning): add a warning filter to the given test. see https://docs.pytest.org/en/stable/how-to/capture-warnings.html#pytest-mark-filterwarnings ... diff --git a/docs/test/pytest/params.rst b/docs/test/pytest/params.rst index d9217d68..26f1bfc5 100644 --- a/docs/test/pytest/params.rst +++ b/docs/test/pytest/params.rst @@ -23,53 +23,53 @@ a set of values is rarely sufficient to fully test the functions. Parameterised testing is a way to send multiple data sets through the same test and have pytest report if any of the data sets fail. To understand the problem that :term:`parameterised ` tests are trying to solve, let’s write some -tests for the ``finish()`` API method from :file:`src/items/api.py`: +tests for the ``finish()`` API method from :file:`src/cusy/tasks/api.py`: .. code-block:: python - def finish(self, item_id: int): - """Set an item state to done.""" - self.update_item(item_id, Item(state="done")) + def finish(self, task_id: int): + """Set a task state to done.""" + self.update_task(task_id, Task(state="done")) The states used in the application are *todo*, *in progress* and *done*, and ``finish()`` sets the state of a card to *done*. To test this, we could -#. create an Item object and add it to the database so we have a card to work +#. create an Task object and add it to the database so we have a card to work with #. call ``finish()`` #. ensure that the final state is *done*. -One variable is the start state of the item. It could be "todo", "in progress" +One variable is the start state of the task. It could be "todo", "in progress" or even already "done". Let’s test all three: .. code-block:: python - from items import Item + from cusy.tasks import Task - def test_finish_from_in_prog(items_db): - index = items_db.add_item( - Item("Update pytest section", state="in progress") + def test_finish_from_in_prog(tasks_db): + index = tasks_db.add_task( + Task("Update pytest section", state="in progress") ) - items_db.finish(index) - item = items_db.get_item(index) - assert item.state == "done" + tasks_db.finish(index) + task = tasks_db.get_task(index) + assert task.state == "done" - def test_finish_from_done(items_db): - index = items_db.add_item( - Item("Update cibuildwheel section", state="done") + def test_finish_from_done(tasks_db): + index = tasks_db.add_task( + Task("Update cibuildwheel section", state="done") ) - items_db.finish(index) - item = items_db.get_item(index) - assert item.state == "done" + tasks_db.finish(index) + task = tasks_db.get_task(index) + assert task.state == "done" - def test_finish_from_todo(items_db): - index = items_db.add_item(Item("Update mock tests", state="todo")) - items_db.finish(index) - item = items_db.get_item(index) - assert item.state == "done" + def test_finish_from_todo(tasks_db): + index = tasks_db.add_task(Task("Update mock tests", state="todo")) + tasks_db.finish(index) + task = tasks_db.get_task(index) + assert task.state == "done" Let’s let it go: @@ -92,19 +92,19 @@ functions into a single function, like this: .. code-block:: python - from items import Item + from cusy.tasks import Task - def test_finish(items_db): + def test_finish(tasks_db): for i in [ - Item("Update pytest section", state="done"), - Item("Update cibuildwheel section", state="in progress"), - Item("Update mock tests", state="todo"), + Task("Update pytest section", state="done"), + Task("Update cibuildwheel section", state="in progress"), + Task("Update mock tests", state="todo"), ]: - index = items_db.add_item(i) - items_db.finish(index) - item = items_db.get_item(index) - assert item.state == "done" + index = tasks_db.add_task(i) + tasks_db.finish(index) + task = tasks_db.get_task(index) + assert task.state == "done" Now we run :file:`tests/test_finish.py` again: @@ -140,7 +140,7 @@ arguments to be passed to the test, like this: import pytest - from items import Item + from cusy.tasks import Task @pytest.mark.parametrize( @@ -151,14 +151,14 @@ arguments to be passed to the test, like this: ("Update mock tests", "todo"), ], ) - def test_finish(items_db, start_summary, start_state): - initial_item = Item(summary=start_summary, state=start_state) - index = items_db.add_item(initial_item) - items_db.finish(index) - item = items_db.get_item(index) - assert item.state == "done" - -The ``test_finish()`` function now has its original ``items_db`` fixture as a + def test_finish(tasks_db, start_summary, start_state): + initial_task = Task(summary=start_summary, state=start_state) + index = tasks_db.add_task(initial_task) + tasks_db.finish(index) + task = tasks_db.get_task(index) + assert task.state == "done" + +The ``test_finish()`` function now has its original ``tasks_db`` fixture as a :term:`parameter`, but also two new parameters: ``start_summary`` and ``start_state``. These directly match the first argument of ``@pytest.mark.parametrize()``. @@ -196,7 +196,7 @@ complex. Let’s change the :term:`parameterisation ` in import pytest - from items import Item + from cusy.tasks import Task @pytest.mark.parametrize( @@ -207,12 +207,12 @@ complex. Let’s change the :term:`parameterisation ` in "todo", ], ) - def test_finish(items_db, start_state): - i = Item("Update pytest section", state=start_state) - index = items_db.add_item(i) - items_db.finish(index) - item = items_db.get_item(index) - assert item.state == "done" + def test_finish(tasks_db, start_state): + i = Task("Update pytest section", state=start_state) + index = tasks_db.add_task(i) + tasks_db.finish(index) + task = tasks_db.get_task(index) + assert task.state == "done" When we run the tests now, they focus on the change that is important to us: @@ -248,7 +248,7 @@ also different: import pytest - from items import Item + from cusy.tasks import Task @pytest.fixture(params=["done", "in progress", "todo"]) @@ -256,12 +256,12 @@ also different: return request.param - def test_finish(items_db, start_state): - i = Item("Update pytest section", state=start_state) - index = items_db.add_item(i) - items_db.finish(index) - item = items_db.get_item(index) - assert item.state == "done" + def test_finish(tasks_db, start_state): + i = Task("Update pytest section", state=start_state) + index = tasks_db.add_task(i) + tasks_db.finish(index) + task = tasks_db.get_task(index) + assert task.state == "done" This means that pytest calls ``start_state()`` three times, once for each of the values in ``params``. Each value of ``params`` is stored in ``request.param`` so @@ -312,7 +312,7 @@ looks like this: .. code-block:: python - from items import Item + from cusy.tasks import Task def pytest_generate_tests(metafunc): @@ -320,12 +320,12 @@ looks like this: metafunc.parametrize("start_state", ["done", "in progress", "todo"]) - def test_finish(items_db, start_state): - i = Item("Update pytest section", state=start_state) - index = items_db.add_item(i) - items_db.finish(index) - item = items_db.get_item(index) - assert item.state == "done" + def test_finish(tasks_db, start_state): + i = Task("Update pytest section", state=start_state) + index = tasks_db.add_task(i) + tasks_db.finish(index) + task = tasks_db.get_task(index) + assert task.state == "done" The ``test_finish()`` function has not changed; we have only changed the way pytest enters the value for ``initial_state`` for each test call. @@ -381,9 +381,9 @@ of how you can achieve this. import pytest - def test_db_initialised(items_db): + def test_db_initialised(tasks_db): # An example test - if items_db.__class__.__name__ == "Sqlite": + if tasks_db.__class__.__name__ == "Sqlite": pytest.fail("Deliberately failing for demonstration purposes") We can now add a test configuration that generates two calls to the @@ -397,8 +397,8 @@ database object for the actual test calls: def pytest_generate_tests(metafunc): - if "items_db" in metafunc.fixturenames: - metafunc.parametrize("items_db", ["json", "sqlite"], indirect=True) + if "tasks_db" in metafunc.fixturenames: + metafunc.parametrize("tasks_db", ["json", "sqlite"], indirect=True) class Json: @@ -410,7 +410,7 @@ database object for the actual test calls: @pytest.fixture - def items_db(request): + def tasks_db(request): if request.param == "json": return Json() elif request.param == "sqlite": @@ -426,12 +426,12 @@ First, let’s take a look at what it looks like at the time of setup: $ uv run pytest tests/test_backends.py --collect-only ============================= test session starts ============================== platform darwin -- Python 3.14.0b4, pytest-8.4.1, pluggy-1.6.0 - rootdir: /Users/veit/sandbox/items + rootdir: /Users/veit/prj/cusy.tasks configfile: pyproject.toml plugins: anyio-4.9.0, Faker-37.4.0, cov-6.2.1 collected 2 items - + @@ -448,7 +448,7 @@ First, let’s take a look at what it looks like at the time of setup: db = - def test_db_initialised(items_db): + def test_db_initialised(tasks_db): # An example test if db.__class__.__name__ == "Sqlite": > pytest.fail("Deliberately failing for demo purposes") diff --git a/docs/test/pytest/plugins.rst b/docs/test/pytest/plugins.rst index 8a722998..230ad528 100644 --- a/docs/test/pytest/plugins.rst +++ b/docs/test/pytest/plugins.rst @@ -287,7 +287,7 @@ plugins that further simplify testing: ~~~~~~~~~~~ We have already used `Faker `_ in -:ref:`marker_fixtures_combined` to create multiple item instances. There are +:ref:`marker_fixtures_combined` to create multiple Task instances. There are many cases in different areas where it is helpful to generate fake data. It is therefore not surprising that there are several plugins that fulfil this need: @@ -312,7 +312,7 @@ therefore not surprising that there are several plugins that fulfil this need: .. image:: https://raster.shields.io/github/stars/adamchainz/time-machine :alt: Stars - :target: https://github.com/pytest-dev/adamchainz/time-machine + :target: https://github.com//adamchainz/time-machine .. image:: https://raster.shields.io/github/contributors/adamchainz/time-machine :alt: Contributors @@ -465,7 +465,7 @@ therefore not surprising that there are several plugins that fulfil this need: .. image:: https://raster.shields.io/github/commit-activity/y/bloomberg/pytest-memray :alt: Commit activity - :target: https://github.com/bloomberg/pytest-memraygraphs/commit-activity + :target: https://github.com/bloomberg/pytest-memray/commit-activity .. image:: https://raster.shields.io/github/license/bloomberg/pytest-memray :alt: Lizenz @@ -487,6 +487,28 @@ therefore not surprising that there are several plugins that fulfil this need: :alt: License :target: https://github.com/pytest-dev/pytest-timeout?tab=MIT-1-ov-file#readme +`pytest-describe `_ + is a plugin for pytest that allows tests to be written in arbitrarily nested + ``describe`` blocks, similar to `RSpec `_ for Ruby and + `Jasmine `_ for JavaScript. + + .. image:: https://raster.shields.io/github/stars/pytest-dev/pytest-describe + :alt: Stars + :target: https://github.com/pytest-dev/pytest-describe/stargazers + + .. image:: https://raster.shields.io/github/contributors/pytest-dev/pytest-describe + :alt: Contributors + :target: https://github.com/pytest-dev/pytest-describe/graphs/contributors + + .. image:: https://raster.shields.io/github/commit-activity/y/pytest-dev/pytest-describe + :alt: Commit activity + :target: https://github.com/pytest-dev/pytest-describe/graphs/commit-activity + + .. image:: https://raster.shields.io/github/license/pytest-dev/pytest-describe + :alt: Lizenz + :target: https://github.com/pytest-dev/pytest-describe?tab=MIT-1-ov-file#readme + + :doc:`pytest-grpc ` is a Pytest plugin for :doc:`Python4DataScience:data-processing/apis/grpc/index`. diff --git a/docs/test/pytest/testsuite.rst b/docs/test/pytest/testsuite.rst index 0a58d181..99d5671d 100644 --- a/docs/test/pytest/testsuite.rst +++ b/docs/test/pytest/testsuite.rst @@ -37,13 +37,13 @@ Let’s apply this structure to one of our first tests as an example: .. code-block:: python def test_equality_fail(): - # Given two item objects with known contents - i1 = Item("do something", "veit") - i2 = Item("do something else", "veit.schiele") - # WHEN the two item objects are not identical + # Given two task objects with known contents + i1 = Task("do something", "veit") + i2 = Task("do something else", "veit.schiele") + # WHEN the two task objects are not identical if i1 != i2: # THEN the result will be a string - pytest.fail("The items are not identical!") + pytest.fail("The tasks are not identical!") The structure helps you to organise the test functions and focus on testing **one** behaviour. The structure also helps you to think of other test cases. @@ -59,24 +59,24 @@ Up to now, we have written test functions within test modules in a file system directory. This structuring of the test code actually works quite well and is sufficient for many projects. However, pytest also allows us to group tests with classes. Let’s take some of the test functions that relate to the equality of -items and group them into a class: +tasks and group them into a class: .. code-block:: python class TestEquality: def test_equality(self): - i1 = Item("do something", "veit", "todo", 42) - i2 = Item("do something", "veit", "todo", 42) + i1 = Task("do something", "veit", "todo", 42) + i2 = Task("do something", "veit", "todo", 42) assert i1 == i2 def test_equality_with_diff_ids(self): - i1 = Item("do something", "veit", "todo", 42) - i2 = Item("do something", "veit", "todo", 43) + i1 = Task("do something", "veit", "todo", 42) + i2 = Task("do something", "veit", "todo", 43) assert i1 == i2 def test_inequality(self): - i1 = Item("do something", "veit", "todo", 42) - i2 = Item("do something else", "veit", "done", 42) + i1 = Task("do something", "veit", "todo", 42) + i2 = Task("do something else", "veit", "done", 42) assert i1 != i2 The code looks pretty much the same as before, with the exception that each @@ -85,7 +85,7 @@ methods together by specifying the class: .. code-block:: pytest - $ pytest -v tests/test_classes.py::TestEquality + $ uv run pytest -v tests/test_classes.py::TestEquality ============================= test session starts ============================== … collected 3 items @@ -100,7 +100,7 @@ However, we can still call a single method: .. code-block:: pytest - $ pytest -v tests/test_classes.py::TestEquality::test_equality + $ uv run pytest -v tests/test_classes.py::TestEquality::test_equality ============================= test session starts ============================== … collected 1 item @@ -165,7 +165,7 @@ prefixes, for example all tests of class ``TestEquality``. .. code-block:: pytest - $ pytest -v -k TestEquality + $ uv run pytest -v -k TestEquality ============================= test session starts ============================== … collected 7 items / 4 deselected / 3 selected @@ -180,7 +180,7 @@ or all tests with ``equality`` in the name: .. code-block:: pytest - pytest -v --tb=no -k equality + $ uv run pytest -v --tb=no -k equality ============================= test session starts ============================== … collected 7 items / 3 deselected / 4 selected @@ -188,10 +188,10 @@ or all tests with ``equality`` in the name: test_classes.py::TestEquality::test_equality PASSED [ 25%] test_classes.py::TestEquality::test_equality_with_diff_ids PASSED [ 50%] test_classes.py::TestEquality::test_inequality PASSED [ 75%] - test_item_fail.py::test_equality_fail FAILED [100%] + test_task_fail.py::test_equality_fail FAILED [100%] =========================== short test summary info ============================ - FAILED test_item_fail.py::test_equality_fail - Failed: The items are not identical! + FAILED test_task_fail.py::test_equality_fail - Failed: The tasks are not identical! ================== 1 failed, 3 passed, 3 deselected in 0.01s =================== Unfortunately, one of these is our error example. We can remove it by expanding @@ -199,7 +199,7 @@ the expression: .. code-block:: pytest - $ pytest -v --tb=no -k "equality and not equality_fail" + $ uv run pytest -v --tb=no -k "equality and not equality_fail" ============================= test session starts ============================== … collected 7 items / 4 deselected / 3 selected @@ -216,7 +216,7 @@ in the "TestEquality" class: .. code-block:: pytest - $ pytest -v --tb=no -k "(inequality or id) and not _fail" + $ uv run pytest -v --tb=no -k "(inequality or id) and not _fail" ============================= test session starts ============================== … collected 7 items / 4 deselected / 3 selected diff --git a/docs/test/tdd.rst b/docs/test/tdd.rst new file mode 100644 index 00000000..4e4ff0eb --- /dev/null +++ b/docs/test/tdd.rst @@ -0,0 +1,190 @@ +Test-Driven Development +======================= + +:term:`Test-Driven Development` (:term:`TDD`) is characterised by the fact that +tests for a function are written first, before the function is implemented. More +specifically, only as much code should be implemented as is necessary to pass +the tests. + +Repeat this *‘test first, then implement’* process until the function meets your +current requirements. + +This idea was introduced in the late 1990s by Kent Beck in his book +`‘Test-Driven Development: By Example’ +`_. +The three simple, repetitive steps have become known as *‘Red – Green – +Refactor’*: + +#. Write tests for the next function to be added. +#. Write the function code until the test passes. +#. Refactor both the new and the old code to improve its structure. + +TDD aims to ensure that the process implements a requirement more efficiently, +whilst also ensuring that it is thoroughly tested for the specific use case. No +time should be wasted implementing options and features simply in case they +might prove useful later on. You should get exactly what you need, when you need +it, and nothing more. + +However, these three steps were a significant simplification, and so, at the end +of 2023, Kent Beck attempted to clear up some of the misunderstandings with +`Canon TDD `_. As in the Agile +Manifesto, people and interactions are prioritised over processes and tools: + + “If you’re doing something different than the following workflow & it works + for you, congratulations! It’s not Canon TDD, but who cares? There’s no gold + star for following these steps exactly.” + +Only then does he outline the following five steps:: + +#. Test list + + All expected variants of the new behaviour are listed: *“This is the base + case, and what should happen in this or that exceptional case.”* The aim here + is to analyse the behaviour, not the software design or implementation. + + .. admonition:: Example: Calculating the mean + :collapsible: closed + + For a mean calculation, the initial test list might look like this: + + * The base case is that the mean is calculated from a sequence, a list or + an iterator. + * A number of the appropriate type should be returned, which may also be + an integer. + * If the set or sequence is empty, an error message should be displayed. + * If one or more elements are :doc:`../types/strings/index`, an attempt + should be made to convert them into numbers of the appropriate type. + * If the conversion of individual elements into numbers fails, an + appropriate error message should be displayed. + +#. Write a test + + You should write just one test, including *setup*, *invocation* and + *assertion*. Although design decisions will be made whilst writing this test, + they will primarily concern the interface, not the implementation itself. + + .. admonition:: Example: Calculating the mean + :collapsible: closed + + The basic test might look like this: + + .. code-block:: python + + @pytest.mark.xfail( + strict=True, raises=AssertionError, reason="Not implemented yet" + ) + def test_mean_base(): + ls = [1, 2, 3] + tp = tuple(ls) + st = set(ls) + assert mean(ls) == mean(tp) == mean(st) == 2 + + We have simply defined that the function should be called :func:`mean` and + that it can take a :doc:`../types/sequences-sets/lists`, a + :doc:`../types/sequences-sets/tuples` or a + :doc:`../types/sequences-sets/sets` as a parameter. + + By using the :doc:`decorator <../functions/decorators>` + :func:`@pytest.mark.xfail`, we expect this test to fail initially. + + Next, we’ll write a minimal version of :func:`mean` that should cause our + test to fail: + + .. code-block:: python + + def mean(se): + pass + + .. code-block:: pytest + + $ uv run pytest -v test_mean.py + ============================= test session starts ============================== + ... + + test_mean.py::test_mean_base XFAIL (Not implemented yet) [100%] + + ============================== 1 xfailed in 0.08s ============================== + + Writing the test **before** the implementation has the following advantages: + + * The implementation is faster, as we already have code in the test to call + the implementation. + * This ensures that the test actually fails. + + With legacy code, we write a test for an existing behaviour. To ensure that + the test can indeed fail, we then temporarily delete the implementation. + Finally, we retrieve the implementation from version control. + + * The test forces us to change our perspective and focus on the interface for + calling the code. + * The test indicates to us when the implementation step is complete. + + With legacy code, writing tests becomes more difficult. Although you can also + write a test for an existing behaviour here, passing the test does not tell + you whether it can also fail. That is why we temporarily remove the + implementation to ensure the test fails. + +#. Passing the test + + Modify the code so that the test (and all previous tests) passes, and if you + find that another test is required, add it to the test list. + + .. admonition:: Example: Calculating the mean + :collapsible: closed + + Now let’s modify our :func:`mean` function so that our test passes: + + .. code-block:: python + + def mean(se): + return sum(se) / len(se) + + We will now remove the ``pytest.mark.xfail`` decorator, as we expect the + test to pass now: + + .. code-block:: pytest + + $ uv run pytest -v test_mean.py + ============================= test session starts ============================== + ... + + test_mean.py::test_mean_base PASSED [100%] + + ============================== 1 passed in 0.15s =============================== + + .. warning:: + + the past, mistakes were often made at this stage: + + * Deleting assertions so that the test passes + * Copying values calculated by the function into the test function + * Not ‘wearing two hats’ and trying to refactor at this stage + +#. Optional: Refactoring + + Now is the time to make decisions regarding the implementation design. + + .. warning:: + + Mistakes are also frequently made during this step: + + * Refactoring that goes beyond this behaviour + * Premature abstraction: duplicates are merely indications and not a + mandatory requirement for refactoring + +#. Go back to 2 until the list is empty + + Test and implement until the desired behaviour is achieved. + +.. note:: + We also use test-driven development when we use coding agents to assist us + with software development: + + .. code-block:: md + :caption: AGENTS.md + + - Use Test Driven Development (TDD) for all code you write. Write tests before writing the implementation code. + - When you come across a bug or regression, think hard about writing a test and also how to create code that will prevent this from a happening again in the future. + + .. seealso:: + * :ref:`agentic-software-engineering:testing` diff --git a/docs/test/test_emails.py b/docs/test/test_emails.py new file mode 100644 index 00000000..76e27f04 --- /dev/null +++ b/docs/test/test_emails.py @@ -0,0 +1,17 @@ +import re + +from hypothesis import given +from hypothesis.strategies import emails + + +def parse_email(email): + result = re.match(r"(?P\w+).(?P[\w\.]+)", email).groups() + return result + + +@given(emails()) +def test_parse_email(email): + result = parse_email(email) + # print(result) + assert len(result) == 2 + assert "." in result[1] diff --git a/docs/test/test_emails_2.py b/docs/test/test_emails_2.py new file mode 100644 index 00000000..16abe7fc --- /dev/null +++ b/docs/test/test_emails_2.py @@ -0,0 +1,19 @@ +import re + +from hypothesis import given +from hypothesis.strategies import emails + + +def parse_email(email): + result = re.match( + r"(?P[\.\w\-\!~#$%&\|{}\+\/\^\`\=\*']+).(?P[\w\.\-]+)", email + ).groups() + return result + + +@given(emails()) +def test_parse_email(email): + result = parse_email(email) + # print(result) + assert len(result) == 2 + assert "." in result[1] diff --git a/docs/test/test_mock.py b/docs/test/test_mock.py index 0991368e..dd1909a2 100644 --- a/docs/test/test_mock.py +++ b/docs/test/test_mock.py @@ -13,11 +13,16 @@ def is_workingday(): datetime = Mock() -# Mock .today() to return Tuesday -datetime.today.return_value = monday -# Test Tuesday is a weekday -assert is_workingday() -# Mock .today() to return Saturday -datetime.today.return_value = saturday -# Test Saturday is not a weekday -assert not is_workingday() + +def test_workinngday(): + # Mock .today() to return Tuesday + datetime.today.return_value = monday + # Test Tuesday is a weekday + assert is_workingday() + + +def test_no_workingday(): + # Mock .today() to return Saturday + datetime.today.return_value = saturday + # Test Saturday is not a weekday + assert not is_workingday() diff --git a/docs/test/tox.rst b/docs/test/tox.rst index 4ed0149f..1b89c26a 100644 --- a/docs/test/tox.rst +++ b/docs/test/tox.rst @@ -5,14 +5,14 @@ tox to a :term:`CI` tool, but can be run both locally and in conjunction with other CI tools on a server. -In the following, we will set up tox for our Items application so that it helps +In the following, we will set up tox for our Tasks application so that it helps us with local testing. We will then set up testing using GitHub Actions. Introduction to tox ------------------- tox is a command line tool that allows you to run your complete test suite in -different environments. We will use tox to test the Items project in multiple +different environments. We will use tox to test the Tasks project in multiple Python versions, but tox is not limited to Python versions only. You can use it to test with different dependency configurations and different configurations for different operating systems. tox uses project information from the @@ -46,14 +46,14 @@ configuration in the :file:`pyproject.toml` file: .. code-block:: toml [tool.tox] - env_list = ["py313"] + env_list = ["py314"] [tool.tox.env_run_base] dependency_groups = [ "tests" ] commands = [[ "pytest"]] -In the ``[tool.tox]`` section, we defined ``env_list = ["py313"]``. This is a -shorthand that instructs tox to run our tests using Python version 3.13. We will +In the ``[tool.tox]`` section, we defined ``env_list = ["py314"]``. This is a +shorthand that instructs tox to run our tests using Python version 3.14. We will add more Python versions shortly, but using one version helps us better understand how tox works. @@ -70,30 +70,30 @@ Before you can run tox, you must ensure that you have installed tox-uv: .. code-block:: console - $ uv sync --group dev + $ uv sync --group tests .. tab:: Windows .. code-block:: ps1con - C:> uv sync --group dev + C:> uv sync --group tests To run tox, simply start tox: .. code-block:: pytest $ uv run tox - .pkg: _optional_hooks> python /Users/veit/cusy/prj/items/.venv/lib/python3.13/site-packages/pyproject_api/_backend.py True hatchling.build - .pkg: get_requires_for_build_sdist> python /Users/veit/cusy/prj/items/.venv/lib/python3.13/site-packages/pyproject_api/_backend.py True hatchling.build - .pkg: build_sdist> python /Users/veit/cusy/prj/items/.venv/lib/python3.13/site-packages/pyproject_api/_backend.py True hatchling.build - py313: install_package> .venv/bin/uv pip install --reinstall --no-deps items@/Users/veit/cusy/prj/items/.tox/.tmp/package/18/items-0.1.0.tar.gz - py313: commands[0]> python --version --version - Python 3.13.0 (main, Oct 7 2024, 23:47:22) [Clang 18.1.8 ] - py313: commands[1]> coverage run -m pytest + .pkg: _optional_hooks> python /Users/veit/cusy/prj/cusy.tasks/.venv/lib/python3.14/site-packages/pyproject_api/_backend.py True hatchling.build + .pkg: get_requires_for_build_sdist> python /Users/veit/cusy/prj/cusy.tasks/.venv/lib/python3.14/site-packages/pyproject_api/_backend.py True hatchling.build + .pkg: build_sdist> python /Users/veit/cusy/prj/cusy.tasks/.venv/lib/python3.14/site-packages/pyproject_api/_backend.py True hatchling.build + py314: install_package> .venv/bin/uv pip install --reinstall --no-deps cusy.tasks@/Users/veit/cusy/prj/cusy.tasks/.tox/.tmp/package/18/cusy.tasks-26.2.0.tar.gz + py314: commands[0]> python --version --version + Python 3.14.0 (main, Oct 7 2024, 23:47:22) [Clang 18.1.8 ] + py314: commands[1]> coverage run -m pytest ============================= test session starts ============================== - platform darwin -- Python 3.13.0, pytest-9.0.2, pluggy-1.6.0 - cachedir: .tox/py313/.pytest_cache - rootdir: /Users/veit/cusy/prj/items + platform darwin -- Python 3.14.0, pytest-9.0.2, pluggy-1.6.0 + cachedir: .tox/py314/.pytest_cache + rootdir: /Users/veit/cusy/prj/cusy.tasks configfile: pyproject.toml testpaths: tests plugins: Faker-40.1.0, cov-7.0.0 @@ -106,8 +106,8 @@ To run tox, simply start tox: tests/api/test_delete_all.py .. [ 18%] tests/api/test_exceptions.py .. [ 20%] tests/api/test_finish.py .... [ 25%] - tests/api/test_item.py ... [ 28%] - tests/api/test_item_id.py . [ 30%] + tests/api/test_task.py ... [ 28%] + tests/api/test_task_id.py . [ 30%] tests/api/test_list.py ......... [ 40%] tests/api/test_list_edge_cases.py ........ [ 50%] tests/api/test_start.py .... [ 55%] @@ -126,8 +126,8 @@ To run tox, simply start tox: tests/cli/test_version.py . [100%] ============================== 83 passed in 0.35s ============================== - .pkg: _exit> python /Users/veit/cusy/prj/items/.venv/lib/python3.13/site-packages/pyproject_api/_backend.py True hatchling.build - py313: OK (1.19=setup[0.45]+cmd[0.01,0.72] seconds) + .pkg: _exit> python /Users/veit/cusy/prj/cusy.tasks/.venv/lib/python3.14/site-packages/pyproject_api/_backend.py True hatchling.build + py314: OK (1.19=setup[0.45]+cmd[0.01,0.72] seconds) congratulations :) (1.23 seconds) Testing multiple Python versions @@ -157,7 +157,7 @@ although I will only highlight the differences in the following illustration: $ uv run tox ... - py310: install_package> .venv/bin/uv pip install --reinstall --no-deps items@/Users/veit/cusy/prj/items/.tox/.tmp/package/19/items-0.1.0.tar.gz + py310: install_package> .venv/bin/uv pip install --reinstall --no-deps cusy.tasks@/Users/veit/cusy/prj/cusy.tasks/.tox/.tmp/package/19/cusy.tasks-26.2.0.tar.gz py310: commands[0]> python --version --version Python 3.10.17 (main, Apr 9 2025, 03:47:39) [Clang 20.1.0 ] py310: commands[1]> coverage run -m pytest @@ -165,7 +165,7 @@ although I will only highlight the differences in the following illustration: ... ============================== 83 passed in 0.35s ============================== py310: OK ✔ in 1.3 seconds - py311: install_package> .venv/bin/uv pip install --reinstall --no-deps items@/Users/veit/cusy/prj/items/.tox/.tmp/package/20/items-0.1.0.tar.gz + py311: install_package> .venv/bin/uv pip install --reinstall --no-deps cusy.tasks@/Users/veit/cusy/prj/cusy.tasks/.tox/.tmp/package/20/cusy.tasks-26.2.0.tar.gz py311: commands[0]> python --version --version Python 3.11.11 (main, Feb 5 2025, 18:58:27) [Clang 19.1.6 ] py311: commands[1]> coverage run -m pytest @@ -173,7 +173,7 @@ although I will only highlight the differences in the following illustration: ... ============================== 83 passed in 0.36s ============================== py311: OK ✔ in 1.16 seconds - py312: install_package> .venv/bin/uv pip install --reinstall --no-deps items@/Users/veit/cusy/prj/items/.tox/.tmp/package/21/items-0.1.0.tar.gz + py312: install_package> .venv/bin/uv pip install --reinstall --no-deps cusy.tasks@/Users/veit/cusy/prj/cusy.tasks/.tox/.tmp/package/21/cusy.tasks-26.2.0.tar.gz py312: commands[0]> python --version --version Python 3.12.12 (main, Oct 14 2025, 21:38:21) [Clang 20.1.4 ] py312: commands[1]> coverage run -m pytest @@ -181,15 +181,15 @@ although I will only highlight the differences in the following illustration: ... ============================== 83 passed in 0.55s ============================== py312: OK ✔ in 1.79 seconds - py313: install_package> .venv/bin/uv pip install --reinstall --no-deps items@/Users/veit/cusy/prj/items/.tox/.tmp/package/22/items-0.1.0.tar.gz - py313: commands[0]> python --version --version - Python 3.13.0 (main, Oct 7 2024, 23:47:22) [Clang 18.1.8 ] - py313: commands[1]> coverage run -m pytest + py314: install_package> .venv/bin/uv pip install --reinstall --no-deps cusy.tasks@/Users/veit/cusy/prj/cusy.tasks/.tox/.tmp/package/22/cusy.tasks-26.2.0.tar.gz + py314: commands[0]> python --version --version + Python 3.14.0 (main, Oct 7 2024, 23:47:22) [Clang 18.1.8 ] + py314: commands[1]> coverage run -m pytest ============================= test session starts ============================== ... ============================== 83 passed in 0.35s ============================== - py313: OK ✔ in 1.07 seconds - py314: install_package> .venv/bin/uv pip install --reinstall --no-deps items@/Users/veit/cusy/prj/items/.tox/.tmp/package/23/items-0.1.0.tar.gz + py314: OK ✔ in 1.07 seconds + py314: install_package> .venv/bin/uv pip install --reinstall --no-deps cusy.tasks@/Users/veit/cusy/prj/cusy.tasks/.tox/.tmp/package/23/cusy.tasks-26.2.0.tar.gz py314: commands[0]> python --version --version Python 3.14.0 (main, Oct 14 2025, 21:10:22) [Clang 20.1.4 ] py314: commands[1]> coverage run -m pytest @@ -197,22 +197,22 @@ although I will only highlight the differences in the following illustration: ... ============================== 83 passed in 0.36s ============================== py314: OK ✔ in 1.28 seconds - py313t: install_package> .venv/bin/uv pip install --reinstall --no-deps items@/Users/veit/cusy/prj/items/.tox/.tmp/package/24/items-0.1.0.tar.gz - py313t: commands[0]> python --version --version - Python 3.13.0 experimental free-threading build (main, Oct 16 2024, 08:24:33) [Clang 18.1.8 ] - py313t: commands[1]> coverage run -m pytest + py314t: install_package> .venv/bin/uv pip install --reinstall --no-deps cusy.tasks@/Users/veit/cusy/prj/cusy.tasks/.tox/.tmp/package/24/cusy.tasks-26.2.0.tar.gz + py314t: commands[0]> python --version --version + Python 3.14.0 experimental free-threading build (main, Oct 16 2024, 08:24:33) [Clang 18.1.8 ] + py314t: commands[1]> coverage run -m pytest ============================= test session starts ============================== ... ============================== 83 passed in 0.49s ============================== - py313t: OK ✔ in 1.51 seconds - py314t: install_package> .venv/bin/uv pip install --reinstall --no-deps items@/Users/veit/cusy/prj/items/.tox/.tmp/package/25/items-0.1.0.tar.gz + py314t: OK ✔ in 1.51 seconds + py314t: install_package> .venv/bin/uv pip install --reinstall --no-deps cusy.tasks@/Users/veit/cusy/prj/cusy.tasks/.tox/.tmp/package/25/cusy.tasks-26.2.0.tar.gz py314t: commands[0]> python --version --version Python 3.14.0b4 free-threading build (main, Jul 8 2025, 21:06:49) [Clang 20.1.4 ] py314t: commands[1]> coverage run -m pytest ============================= test session starts ============================== ... ============================== 83 passed in 0.39s ============================== - .pkg: _exit> python /Users/veit/cusy/prj/items/.venv/lib/python3.13/site-packages/pyproject_api/_backend.py True hatchling.build + .pkg: _exit> python /Users/veit/cusy/prj/cusy.tasks/.venv/lib/python3.14/site-packages/pyproject_api/_backend.py True hatchling.build py310: OK (1.30=setup[0.54]+cmd[0.01,0.75] seconds) py311: OK (1.16=setup[0.38]+cmd[0.01,0.76] seconds) py312: OK (1.79=setup[0.42]+cmd[0.01,1.36] seconds) @@ -229,7 +229,7 @@ although I will only highlight the differences in the following illustration: .. code-block:: toml [tool.tox] - envlist = [py3{10,11,12,13,14,13t,14t}] + envlist = ["py3{10,11,12,13,14,13t,14t}"] Running Tox environments in parallel ------------------------------------ @@ -310,9 +310,9 @@ be considered identical: [tool.coverage.paths] source = ["src", ".tox/py*/**/site-packages"] -The items source code is initially located in :file:`src/items/` before tox -creates the virtual environments and installs items in the environment. It is -then located in :file:`.tox/py313/lib/python3.13/site-packages/items`, for +The Tasks source code is initially located in :file:`src/cusy/tasks/` before tox +creates the virtual environments and installs tasks in the environment. It is +then located in :file:`.tox/py314/lib/python3.14/site-packages/cusy.tasks`, for example. .. code-block:: console @@ -347,7 +347,7 @@ level in order to recognise any coverage failures. This is achieved with the Name Stmts Miss Branch BrPart Cover Missing -------------------------------------------------------------- - src/items/api.py 68 1 12 1 98% 88 + src/cusy/tasks/api.py 68 1 12 1 98% 88 -------------------------------------------------------------- TOTAL 428 1 32 1 99% @@ -391,13 +391,13 @@ keyword option. We also use ``--no-cov`` to disable coverage: .. code-block:: pytest :emphasize-lines: 1, 3 - $ uv run tox -e py313 -- -k test_version --no-cov + $ uv run tox -e py314 -- -k test_version --no-cov ... - py313: commands[0]> coverage run -m pytest -k test_version --no-cov + py314: commands[0]> coverage run -m pytest -k test_version --no-cov ============================= test session starts ============================== - platform darwin -- Python 3.13.0, pytest-8.3.3, pluggy-1.5.0 - cachedir: .tox/py313/.pytest_cache - rootdir: /Users/veit/cusy/prj/items + platform darwin -- Python 3.14.0, pytest-8.3.3, pluggy-1.5.0 + cachedir: .tox/py314/.pytest_cache + rootdir: /Users/veit/cusy/prj/cusy.tasks configfile: pyproject.toml testpaths: tests plugins: cov-5.0.0, anyio-4.6.0, Faker-30.3.0 @@ -407,8 +407,8 @@ keyword option. We also use ``--no-cov`` to disable coverage: tests/cli/test_version.py . [100%] ======================= 2 passed, 47 deselected in 0.07s ======================= - .pkg: _exit> python /Users/veit/cusy/prj/items/.venv/lib/python3.13/site-packages/pyproject_api/_backend.py True hatchling.build - py313: OK (1.49=setup[0.96]+cmd[0.53] seconds) + .pkg: _exit> python /Users/veit/cusy/prj/cusy.tasks/.venv/lib/python3.14/site-packages/pyproject_api/_backend.py True hatchling.build + py314: OK (1.49=setup[0.96]+cmd[0.53] seconds) congratulations :) (1.53 seconds) ``tox`` is not only ideal for the local automation of test processes, but also @@ -442,16 +442,16 @@ of environments are available for GitHub actions: if: always() steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: actions/setup-python@v6 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version-file: .python-version - - uses: hynek/setup-cached-uv@v2 + - uses: hynek/setup-cached-uv@4300ec2180bc77d705e626a34e381b81a4772c51 # v2.5.0 - name: Download coverage data - uses: actions/download-artifact@v7 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: coverage-data-* merge-multiple: true @@ -474,16 +474,16 @@ of environments are available for GitHub actions: ``steps`` is a list of steps. The name of each step can be arbitrary and is optional. - ``uses: actions/checkout@v4`` + ``uses: actions/checkout`` is a GitHub actions tool that checks out our repository so that the rest of the workflow can access it. - ``uses: actions/setup-python@v5`` + ``uses: actions/setup-python`` is a GitHub actions tool that configures Python and installs it in a build environment. ``with: python-version: ${{ matrix.python }}`` says that an environment should be created for each of the Python versions listed in ``matrix.python``. - ``uses: hynek/setup-cached-uv@v2`` + ``uses: hynek/setup-cached-uv`` uses :term:`uv` in GitHub Actions. .. seealso:: diff --git a/docs/types/numbers/index.rst b/docs/types/numbers/index.rst index 8627bad1..ceff24f0 100644 --- a/docs/types/numbers/index.rst +++ b/docs/types/numbers/index.rst @@ -158,14 +158,14 @@ The ``math`` module provides, among other things Rounding half to even --------------------- -Usually Python calculates floating point numbers according to the `IEEE 754 -`_ standard, rounding down numbers in -the middle half of the time and rounding up in the other half to avoid -statistical drift in longer calculations. :class:`Decimal -` and :data:`ROUND_HALF_UP -` from the decimal module are therefore needed -for `rounding half to even -`_: +Python typically performs floating-point calculations in base 2, whereby numbers +in the middle are rounded down in half the cases and rounded up in the other +half, in order to prevent statistical drift during lengthy calculations. +However, the :class:`Decimal ` class also allows +calculations to be performed in base 10. For commercial rounding, +:data:`ROUND_HALF_UP ` from the Decimal class +is also required for `rounding half up +`_: .. code-block:: pycon @@ -175,6 +175,9 @@ for `rounding half to even >>> rounded Decimal('3') +.. seealso:: + * `IEEE 754 `_ + Built-in modules for numbers ---------------------------- diff --git a/docs/types/sequences-sets/tuples.rst b/docs/types/sequences-sets/tuples.rst index f7b12339..5bdf1d27 100644 --- a/docs/types/sequences-sets/tuples.rst +++ b/docs/types/sequences-sets/tuples.rst @@ -102,7 +102,7 @@ the right-hand side of the assignment operator. Here is a simple example: .. code-block:: pycon - >>> (v, w, x, y, z) = (1, "2.", 3.0, ["4a", "4b"], (5.1, 5.2)) + >>> v, w, x, y, z = (1, "2.", 3.0, ["4a", "4b"], (5.1, 5.2)) >>> v 1 >>> w diff --git a/docs/types/strings/built-in-modules/re.rst b/docs/types/strings/built-in-modules/re.rst index b954e190..7f6edd35 100644 --- a/docs/types/strings/built-in-modules/re.rst +++ b/docs/types/strings/built-in-modules/re.rst @@ -138,3 +138,6 @@ Checks numbers between -3 and +3? * Which regular expression would you use to find hexadecimal values? + +* What regular expression would you use for a :samp:`pylock.{NAME.}toml` + filename? diff --git a/docs/types/strings/built-in-modules/string.rst b/docs/types/strings/built-in-modules/string.rst index d6a3b153..f25dba5a 100644 --- a/docs/types/strings/built-in-modules/string.rst +++ b/docs/types/strings/built-in-modules/string.rst @@ -299,12 +299,6 @@ Line 3 The table generated by :py:meth:`str.maketrans` is passed to :py:meth:`str.translate`. -.. toctree:: - :titlesonly: - :hidden: - - regex - Checks ------ @@ -312,7 +306,7 @@ Checks contains hyphens instead of spaces and can therefore be better used as a file name? -* If you want to check whether a line begins with ``.. note::``, which method +* If you wanted to check whether a filename starts with ``pylock``, which method would you use? Are there any other options? * Suppose you have a string with exclamation marks, quotation marks and line diff --git a/pyproject.toml b/pyproject.toml index 32e10f4d..160fbbb1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,10 +18,9 @@ classifiers = [ "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", ] -dependencies = [ ] - +dependencies = [] urls."Bug Tracker" = "https://github.com/veit/python-basics-tutorial/issues" -urls."Homepage" = "https://github.com/veit/python-basics-tutorial/" +urls.Homepage = "https://github.com/veit/python-basics-tutorial/" [dependency-groups] dev = [ @@ -37,7 +36,7 @@ docs = [ "matplotlib", # matplotlib is required for social cards "nbsphinx", "pygments-pytest", - "sphinx<8.2", # furo requires sphinx < 8.2 + "sphinx", "sphinx-copybutton", "sphinx-inline-tabs", "sphinx-lint", @@ -48,5 +47,8 @@ docs = [ "sphinxext-opengraph", ] +[tool.uv] +exclude-newer = "P3D" # "3 days" in RFC 3339 format + [tool.codespell] ignore-words-list = "allo, ist, Juli, symbl"