pytest-codeblock

Test your documentation code blocks.

PyPI Version Supported Python versions Build Status Documentation Status llms.txt - documentation for LLMs MIT Coverage

`pytest-codeblock`_ is a Pytest plugin that discovers Python code examples in your reStructuredText and Markdown documentation files and runs them as part of your test suite. This ensures your docs stay correct and up-to-date.

Features

  • reStructuredText and Markdown support: Automatically find and test code blocks in reStructuredText (.rst) and Markdown (.md) files. The only requirement here is that your code blocks shall have a name starting with test_.

  • Grouping by name: Split a single example across multiple code blocks; the plugin concatenates them into one test.

  • Pytest markers support: Add existing or custom pytest markers to the code blocks and hook into the tests life-cycle using conftest.py.

Prerequisites

  • Python 3.9+

  • pytest is the only required dependency

Documentation

Installation

Install with pip:

pip install pytest-codeblock

Or install with `uv`_:

uv pip install pytest-codeblock

Configuration

Filename: pyproject.toml

[tool.pytest.ini_options]
testpaths = [
    "**/*.rst",
    "**/*.md",
]

Usage

reStructruredText usage

Any code directive, such as .. code-block:: python, .. code:: python, or literal blocks with a preceding .. codeblock-name: <name>, will be collected and executed automatically, if your pytest configuration allows that.

code-block directive example

Note

Note that :name: value has a test_ prefix.

Filename: README.rst

.. code-block:: python
   :name: test_basic_example

   import math

   result = math.pow(3, 2)
   assert result == 9

literalinclude directive example

Note

Note that :name: value has a test_ prefix.

Filename: README.rst

.. literalinclude:: examples/python/basic_example.py
    :name: test_li_basic_example

See a dedicated reStructuredText docs for more.

Markdown usage

Any fenced code block with a recognized Python language tag (e.g., python, py) will be collected and executed automatically, if your pytest configuration allows that.

Note

Note that name value has a test_ prefix.

Filename: README.md

```python name=test_basic_example
import math

result = math.pow(3, 2)
assert result == 9
```

See a dedicated Markdown docs for more.

Tests

Run the tests with pytest:

pytest

Writing documentation

Keep the following hierarchy.

=====
title
=====

header
======

sub-header
----------

sub-sub-header
~~~~~~~~~~~~~~

sub-sub-sub-header
^^^^^^^^^^^^^^^^^^

sub-sub-sub-sub-header
++++++++++++++++++++++

sub-sub-sub-sub-sub-header
**************************

License

MIT

Support

For security issues contact me at the e-mail given in the Author section.

For overall issues, go to GitHub.

Author

Artur Barseghyan <artur.barseghyan@gmail.com>


reStructuredText

The following directives are supported:

  • .. code-block:: python

  • .. code:: python

  • .. codeblock-name: <name>

  • .. literalinclude::

Any code directive, such as .. code-block:: python, .. code:: python, .. literalinclude:: or literal blocks with a preceding .. codeblock-name: <name>, will be collected and executed automatically, if your pytest configuration allows that.

Usage examples

Standalone code blocks

code-block directive

Note

Note that :name: value has a test_ prefix.

Filename: README.rst

.. code-block:: python
   :name: test_basic_example

   import math

   result = math.pow(3, 2)
   assert result == 9

literalinclude directive

Filename: README.rst

.. literalinclude:: examples/python/basic_example.py
    :name: test_li_basic_example

codeblock-name directive

You can also use a literal block with a preceding name comment:

Filename: README.rst

.. codeblock-name: test_grouping_example_literal_block
This is a literal block::

   y = 5
   print(y * 2)

Grouping multiple code-block directives

It’s possible to split one logical test into multiple blocks. They will be tested under the first :name: specified. Note the .. continue:: directive.

Note

Note that continue directive of the test_grouping_example_part_2 and test_grouping_example_part_3 refers to the test_grouping_example.

Filename: README.rst

.. code-block:: python
   :name: test_grouping_example

   x = 1

Some intervening text.

.. continue: test_grouping_example
.. code-block:: python
   :name: test_grouping_example_part_2

   y = x + 1  # Uses x from the first snippet
   assert y == 2

Some intervening text.

.. continue: test_grouping_example
.. code-block:: python
   :name: test_grouping_example_part_3

   print(y)  # Uses y from the previous snippet

The above mentioned three snippets will run as a single test.


Adding pytest markers to code-block and literalinclude directives

It’s possible to add custom pytest markers to your code-block or literalinclude directives. That allows adding custom logic and mocking in your conftest.py.

In the example below, django_db marker is added to the code-block directive.

Note

Note the pytestmark directive django_db marker.

Filename: README.rst

.. pytestmark: django_db
.. code-block:: python
    :name: test_django

    from django.contrib.auth.models import User

    user = User.objects.first()

In the example below, django_db marker is added to the literalinclude directive.

Filename: README.rst

.. pytestmark: django_db
.. literalinclude:: examples/python/django_example.py
    :name: test_li_django_example

Customisation/hooks

Tests can be extended and fine-tuned using pytest’s standard hook system.

Below is an example workflow:

  1. Add custom pytest markers to the code-block or literalinclude (fakepy, aws, openai).

  2. Implement pytest hooks in conftest.py to react to those markers.

Add custom pytest markers

Add fakepy marker

The example code below will generate a PDF file with random text using fake.py library. Note, that a fakepy marker is added to the code-block.

In the `Implement pytest hooks`_ section, you will see what can be done with the markers.

Note

Note the pytestmark directive fakepy marker.

Filename: README.rst

.. pytestmark: fakepy
.. code-block:: python
    :name: test_create_pdf_file

    from fake import FAKER

    FAKER.pdf_file()

In the example code below, a fakepy marker is added to the literalinclude block.

Filename: README.rst

.. pytestmark: fakepy
.. literalinclude:: examples/python/create_pdf_file_example.py
    :name: test_li_create_pdf_file

Add aws marker

Sample boto3 code to create a bucket on AWS S3.

Note

Note the pytestmark directive aws marker.

Filename: README.rst

.. pytestmark: aws
.. code-block:: python
    :name: test_create_bucket

    import boto3

    s3 = boto3.client("s3", region_name="us-east-1")
    s3.create_bucket(Bucket="my-bucket")
    assert "my-bucket" in [b["Name"] for b in s3.list_buckets()["Buckets"]]

Add openai marker

Sample openai code to ask LLM to tell a joke. Note, that next to a custom openai marker, xfail marker is used, which allows underlying code to fail, without marking entire test suite as failed.

Note

Note the pytestmark directive xfail and openai markers.

Filename: README.rst

.. pytestmark: xfail
.. pytestmark: openai
.. code-block:: python
    :name: test_tell_me_a_joke

    from openai import OpenAI

    client = OpenAI()
    completion = client.chat.completions.create(
        model="gpt-4o",
        messages=[
            {"role": "developer", "content": "You are a famous comedian."},
            {"role": "user", "content": "Tell me a joke."},
        ],
    )

    assert isinstance(completion.choices[0].message.content, str)

Implement pytest hooks

In the example below:

  • moto is used to mock AWS S3 service for all tests marked as aws.

  • Environment variable OPENAI_BASE_URL is set to http://localhost:11434/v1 (assuming you have Ollama running) for all tests marked as openai.

  • FILE_REGISTRY.clean_up() is executed at the end of each test marked as fakepy.

Filename: conftest.py

import os
from contextlib import suppress

import pytest

from fake import FILE_REGISTRY
from moto import mock_aws
from pytest_codeblock.constants import CODEBLOCK_MARK

# Modify test item during collection
def pytest_collection_modifyitems(config, items):
    for item in items:
        if item.get_closest_marker(CODEBLOCK_MARK):
            # All `pytest-codeblock` tests are automatically assigned
            # a `codeblock` marker, which can be used for customisation.
            # In the example below we add an additional `documentation`
            # marker to `pytest-codeblock` tests.
            item.add_marker(pytest.mark.documentation)
        if item.get_closest_marker("aws"):
            # Apply `mock_aws` to all tests marked as `aws`
            item.obj = mock_aws(item.obj)


# Setup before test runs
def pytest_runtest_setup(item):
    if item.get_closest_marker("openai"):
        # Send all OpenAI requests to locally running Ollama for all
        # tests marked as `openai`. The tests would x-pass on environments
        # where Ollama is up and running (assuming, you have created an
        # alias for gpt-4o using one of the available models) and would
        # x-fail on environments, where Ollama isn't runnig.
        os.environ.setdefault("OPENAI_API_KEY", "ollama")
        os.environ.setdefault("OPENAI_BASE_URL", "http://localhost:11434/v1")


# Teardown after the test ends
def pytest_runtest_teardown(item, nextitem):
    # Run file clean up on all tests marked as `fakepy`
    if item.get_closest_marker("fakepy"):
        FILE_REGISTRY.clean_up()

Markdown

Usage examples

Any fenced code block with a recognized Python language tag (e.g., python, py) will be collected and executed automatically, if your pytest configuration allows that.

Standalone code blocks

Note

Note that name value has a test_ prefix.

Filename: README.md

```python name=test_basic_example
import math

result = math.pow(3, 2)
assert result == 9
```

Grouping multiple code blocks

It’s possible to split one logical test into multiple blocks by specifying the same name.

Note

Note that both snippts share the same name value (test_grouping_example).

Filename: README.md

```python name=test_grouping_example
x = 1
```

Some intervening text.

```python name=test_grouping_example
print(x + 1)  # Uses x from the first snippet
```

The above mentioned three snippets will run as a single test.


Adding pytest markers to code blocks

It’s possible to add custom pytest markers to your code blocks. That allows adding custom logic and mocking in your conftest.py.

In the example below, django_db marker is added to the code block.

Note

Note the pytestmark directive django_db marker.

Filename: README.md

<!-- pytestmark: django_db -->
```python name=test_django
from django.contrib.auth.models import User

user = User.objects.first()
```

Customisation/hooks

Tests can be extended and fine-tuned using pytest’s standard hook system.

Below is an example workflow:

  1. Add custom pytest markers to the code blocks (fakepy, aws, openai).

  2. Implement pytest hooks in conftest.py to react to those markers.

Add custom pytest markers

Add fakepy marker

The example code below will generate a PDF file with random text using fake.py library. Note, that a fakepy marker is added to the code block.

In the `Implement pytest hooks`_ section, you will see what can be done with the markers.

Note

Note the pytestmark directive fakepy marker.

Filename: README.md

<!-- pytestmark: fakepy -->
```python name=test_create_pdf_file
from fake import FAKER

FAKER.pdf_file()
```
Add aws marker

Sample boto3 code to create a bucket on AWS S3.

Note

Note the pytestmark directive aws marker.

Filename: README.md

<!-- pytestmark: aws -->
```python name=test_create_bucket
import boto3

s3 = boto3.client("s3", region_name="us-east-1")
s3.create_bucket(Bucket="my-bucket")
assert "my-bucket" in [b["Name"] for b in s3.list_buckets()["Buckets"]]
```
Add openai marker

Sample openai code to ask LLM to tell a joke. Note, that next to a custom openai marker, xfail marker is used, which allows underlying code to fail, without marking entire test suite as failed.

Note

Note the pytestmark directive xfail and openai markers.

Filename: README.md

<!-- pytestmark: xfail -->
<!-- pytestmark: openai -->
```python name=test_tell_me_a_joke
from openai import OpenAI

client = OpenAI()
completion = client.chat.completions.create(
    model="gpt-4o",
    messages=[
        {"role": "developer", "content": "You are a famous comedian."},
        {"role": "user", "content": "Tell me a joke."},
    ],
)

assert isinstance(completion.choices[0].message.content, str)
```

Implement pytest hooks

In the example below:

  • moto is used to mock AWS S3 service for all tests marked as aws.

  • Environment variable OPENAI_BASE_URL is set to http://localhost:11434/v1 (assuming you have Ollama running) for all tests marked as openai.

  • FILE_REGISTRY.clean_up() is executed at the end of each test marked as fakepy.

Filename: conftest.py

import os
from contextlib import suppress

import pytest

from fake import FILE_REGISTRY
from moto import mock_aws
from pytest_codeblock.constants import CODEBLOCK_MARK

# Modify test item during collection
def pytest_collection_modifyitems(config, items):
    for item in items:
        if item.get_closest_marker(CODEBLOCK_MARK):
            # All `pytest-codeblock` tests are automatically assigned
            # a `codeblock` marker, which can be used for customisation.
            # In the example below we add an additional `documentation`
            # marker to `pytest-codeblock` tests.
            item.add_marker(pytest.mark.documentation)
        if item.get_closest_marker("aws"):
            # Apply `mock_aws` to all tests marked as `aws`
            item.obj = mock_aws(item.obj)


# Setup before test runs
def pytest_runtest_setup(item):
    if item.get_closest_marker("openai"):
        # Send all OpenAI requests to locally running Ollama for all
        # tests marked as `openai`. The tests would x-pass on environments
        # where Ollama is up and running (assuming, you have created an
        # alias for gpt-4o using one of the available models) and would
        # x-fail on environments, where Ollama isn't runnig.
        os.environ.setdefault("OPENAI_API_KEY", "ollama")
        os.environ.setdefault("OPENAI_BASE_URL", "http://localhost:11434/v1")


# Teardown after the test ends
def pytest_runtest_teardown(item, nextitem):
    # Run file clean up on all tests marked as `fakepy`
    if item.get_closest_marker("fakepy"):
        FILE_REGISTRY.clean_up()

Security Policy

Reporting a Vulnerability

Do not report security issues on GitHub!

Please report security issues by emailing Artur Barseghyan <artur.barseghyan@gmail.com>.

Supported Versions

The two most recent pytest-codeblock minor release series receive security support. It is recommended to use the latest version.

┌─────────────────┬────────────────┐
│ Version         │ Supported      │
├─────────────────┼────────────────┤
│ 0.1.x           │ Yes            │
├─────────────────┼────────────────┤
│ < 0.1           │ No             │
└─────────────────┴────────────────┘

Note

For example, during the development cycle leading to the release of pytest-codeblock 0.17.x, support will be provided for pytest-codeblock 0.16.x.

Upon the release of pytest-codeblock 0.18.x, security support for pytest-codeblock 0.16.x will end.


Contributor guidelines

Developer prerequisites

pre-commit

Refer to pre-commit for installation instructions.

TL;DR:

curl -LsSf https://astral.sh/uv/install.sh | sh  # Install uv
uv tool install pre-commit  # Install pre-commit
pre-commit install  # Install pre-commit hooks

Installing pre-commit will ensure you adhere to the project code quality standards.

Code standards

ruff and doc8 will be automatically triggered by pre-commit.

ruff is configured to do the job of black and isort as well.

Still, if you want to run checks manually:

make doc8
make ruff

Requirements

Requirements are compiled using `uv`_.

make compile-requirements

Virtual environment

You are advised to work in virtual environment.

TL;DR:

python -m venv env
pip install -e .[all]

Documentation

Check the documentation.

Testing

Check testing.

If you introduce changes or fixes, make sure to test them locally using all supported environments. For that use tox.

tox

In any case, GitHub Actions will catch potential errors, but using tox speeds things up.

For a quick test of the package and all examples, use the following Makefile command:

make test-all

Pull requests

You can contribute to the project by making a pull request.

For example:

  • To fix documentation typos.

  • To improve documentation (for instance, to add new recipe or fix an existing recipe that doesn’t seem to work).

  • To introduce a new feature (for instance, add support for a non-supported file type).

Good to know:

  • This library is almost dependency free. Do not submit pull requests with external dependencies unless it’s really necessary.

General list to go through:

  • Does your change require documentation update?

  • Does your change require update to tests?

  • Does your change rely on third-party package or a cloud based service? If so, please consider turning it into a dedicated standalone package, since this library is dependency free (and will always stay so).

When fixing bugs (in addition to the general list):

  • Make sure to add regression tests.

When adding a new feature (in addition to the general list):

  • Make sure to update the documentation (check whether the installation and features require changes).

GitHub Actions

Only non-EOL versions of Python and software `pytest-codeblock`_ aims to integrate with are supported.

On GitHub Actions includes tests for more than 40 different variations of Python versions and integration packages. Future, non-stable versions of Python are being tested too, so that new features/incompatibilities could be seen and adopted early.

For the list of Python versions supported by GitHub, see GitHub Actions versions manifest.

Questions

Questions can be asked on GitHub discussions.

Issues

For reporting a bug or filing a feature request, use GitHub issues.

Do not report security issues on GitHub. Check the support section.


Release history and notes

Sequence based identifiers are used for versioning (schema follows below):

major.minor[.revision]
  • It is always safe to upgrade within the same minor version (for example, from 0.3 to 0.3.4).

  • Minor version changes might be backwards incompatible. Read the release notes carefully before upgrading (for example, when upgrading from 0.3.4 to 0.4).

  • All backwards incompatible changes are mentioned in this document.

0.2

2025-11-15

  • Handle deprecations for pytest 9.x. The fspath argument is replaced with path and the code is updated accordingly.

0.1.8

2025-05-11

  • Move everything to src directory.

  • Add Python tests.

0.1.7

2025-05-11

  • Minor fixes.

0.1.6

2025-05-10

  • Minor fixes.

0.1.5

2025-05-07

  • Improve error tracebacks.

0.1.4

2025-05-05

  • Fixes in .. literalinclude blocks.

0.1.3

2025-05-05

  • Add support for .. literalinclude blocks.

0.1.2

2025-05-03

  • Automatically add codeblock mark to documentation tests.

  • Add customisation section to documentation.

0.1.1

2025-04-30

  • Support Python 3.9.

0.1

2025-04-29

Note

In memory of the victims of the Armenian Genocide.

  • Initial beta release.


Package

Indices and tables


Project source-tree

Below is the layout of our project (to 10 levels), followed by the contents of each key file.

Project directory layout
pytest-codeblock/

├── docs
│   ├── _static
│   ├── _templates
│   ├── _implement_pytest_hooks.rst
│   ├── changelog.rst
│   ├── code_of_conduct.rst
│   ├── conf.py
│   ├── conf.py.distrib
│   ├── contributor_guidelines.rst
│   ├── documentation.rst
│   ├── index.rst
│   ├── index.rst.distrib
│   ├── llms.rst
│   ├── make.bat
│   ├── Makefile
│   ├── markdown.rst
│   ├── package.rst
│   ├── requirements.txt
│   ├── restructured_text.rst
│   ├── security.rst
│   └── source_tree.rst
├── examples
│   ├── md_example
│      ├── customisation.md
│      └── README.md
│   ├── python
│      ├── __init__.py
│      ├── basic_example.py
│      ├── create_bucket_example.py
│      ├── create_pdf_file_example.py
│      ├── django_example.py
│      └── tell_me_a_joke_example.py
│   └── rst_example
│       ├── __pycache__
│       ├── __init__.py
│       ├── customisation.rst
│       ├── django_settings.py
│       └── README.rst
├── scripts
│   └── generate_project_source_tree.py
├── src
│   └── pytest_codeblock
│       ├── __pycache__
│       ├── tests
│          ├── __pycache__
│          ├── __init__.py
│          ├── test_pytest_codeblock.py
│          └── tests.rst
│       ├── __init__.py
│       ├── collector.py
│       ├── constants.py
│       ├── md.py
│       ├── rst.py

docs/_implement_pytest_hooks.rst

docs/_implement_pytest_hooks.rst
In the example below:

- `moto`_ is used to mock AWS S3 service for all tests marked as ``aws``.
- Environment variable ``OPENAI_BASE_URL`` is set
  to ``http://localhost:11434/v1`` (assuming you have `Ollama`_ running) for
  all tests marked as ``openai``.
- ``FILE_REGISTRY.clean_up()`` is executed at the end of each test marked
  as ``fakepy``.

*Filename: conftest.py*

.. code-block:: python

    import os
    from contextlib import suppress

    import pytest

    from fake import FILE_REGISTRY
    from moto import mock_aws
    from pytest_codeblock.constants import CODEBLOCK_MARK

    # Modify test item during collection
    def pytest_collection_modifyitems(config, items):
        for item in items:
            if item.get_closest_marker(CODEBLOCK_MARK):
                # All `pytest-codeblock` tests are automatically assigned
                # a `codeblock` marker, which can be used for customisation.
                # In the example below we add an additional `documentation`
                # marker to `pytest-codeblock` tests.
                item.add_marker(pytest.mark.documentation)
            if item.get_closest_marker("aws"):
                # Apply `mock_aws` to all tests marked as `aws`
                item.obj = mock_aws(item.obj)


    # Setup before test runs
    def pytest_runtest_setup(item):
        if item.get_closest_marker("openai"):
            # Send all OpenAI requests to locally running Ollama for all
            # tests marked as `openai`. The tests would x-pass on environments
            # where Ollama is up and running (assuming, you have created an
            # alias for gpt-4o using one of the available models) and would
            # x-fail on environments, where Ollama isn't runnig.
            os.environ.setdefault("OPENAI_API_KEY", "ollama")
            os.environ.setdefault("OPENAI_BASE_URL", "http://localhost:11434/v1")


    # Teardown after the test ends
    def pytest_runtest_teardown(item, nextitem):
        # Run file clean up on all tests marked as `fakepy`
        if item.get_closest_marker("fakepy"):
            FILE_REGISTRY.clean_up()

docs/changelog.rst

docs/changelog.rst
.. include:: ../CHANGELOG.rst

docs/code_of_conduct.rst

docs/code_of_conduct.rst
.. include:: ../CODE_OF_CONDUCT.rst

docs/conf.py

docs/conf.py
# Configuration file for the Sphinx documentation builder.
#
# For the full list of built-in configuration values, see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
import os
import sys

sys.path.insert(0, os.path.abspath(os.path.join("..", "src")))


# -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information

try:
    import pytest_codeblock

    version = pytest_codeblock.__version__
    project = pytest_codeblock.__title__
    copyright = pytest_codeblock.__copyright__
    author = pytest_codeblock.__author__
except ImportError:
    version = "0.1"
    project = "pytest-codeblock"
    copyright = "2025, Artur Barseghyan <artur.barseghyan@gmail.com>"
    author = "Artur Barseghyan <artur.barseghyan@gmail.com>"

# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration

extensions = [
    "sphinx.ext.autodoc",
    "sphinx.ext.viewcode",
    "sphinx.ext.todo",
    "sphinx_no_pragma",
]

templates_path = ["_templates"]
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]

language = "en"

release = version

# The suffix of source filenames.
source_suffix = {
    ".rst": "restructuredtext",
}

pygments_style = "sphinx"

# -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output

html_theme = "sphinx_rtd_theme"
html_static_path = ["_static"]
# html_extra_path = ["examples"]

prismjs_base = "//cdnjs.cloudflare.com/ajax/libs/prism/1.29.0"

html_css_files = [
    f"{prismjs_base}/themes/prism.min.css",
    f"{prismjs_base}/plugins/toolbar/prism-toolbar.min.css",
    # "https://cdn.jsdelivr.net/gh/barseghyanartur/jsphinx@1.3.4/src/css/sphinx_rtd_theme.css",
    "https://cdn.jsdelivr.net/gh/barseghyanartur/jsphinx/src/css/sphinx_rtd_theme.css",
]

html_js_files = [
    f"{prismjs_base}/prism.min.js",
    f"{prismjs_base}/plugins/autoloader/prism-autoloader.min.js",
    f"{prismjs_base}/plugins/toolbar/prism-toolbar.min.js",
    f"{prismjs_base}/plugins/copy-to-clipboard/prism-copy-to-clipboard.min.js",
    # "https://cdn.jsdelivr.net/gh/barseghyanartur/jsphinx@1.3.4/src/js/download_adapter.js",
    "https://cdn.jsdelivr.net/gh/barseghyanartur/jsphinx/src/js/download_adapter.js",
]

# -- Options for todo extension ----------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/extensions/todo.html#configuration

todo_include_todos = True

# -- Options for Epub output  ----------------------------------------------
epub_title = project
epub_author = author
epub_publisher = "GitHub"
epub_copyright = copyright
epub_identifier = "https://github.com/barseghyanartur/pytest-codeblock"  # URL or ISBN
epub_scheme = "URL"  # or "ISBN"
epub_uid = "https://github.com/barseghyanartur/pytest-codeblock"

docs/contributor_guidelines.rst

docs/contributor_guidelines.rst
.. include:: ../CONTRIBUTING.rst

docs/documentation.rst

docs/documentation.rst
Project documentation
=====================
Contents:

.. contents:: Table of Contents

.. toctree::
   :maxdepth: 2

   index
   restructured_text
   markdown
   security
   contributor_guidelines
   code_of_conduct
   changelog
   package

docs/index.rst

docs/index.rst
.. include:: ../README.rst
.. include:: documentation.rst

docs/llms.rst

docs/llms.rst
.. include:: ../README.rst

----

.. include:: restructured_text.rst

----

.. include:: markdown.rst

----

.. include:: security.rst

----

.. include:: contributor_guidelines.rst

----

.. include:: changelog.rst

----

.. include:: package.rst

----

.. include:: source_tree.rst

docs/markdown.rst

docs/markdown.rst
Markdown
========

.. External references
.. _Markdown: https://daringfireball.net/projects/markdown/
.. _pytest: https://docs.pytest.org
.. _Django: https://www.djangoproject.com
.. _pip: https://pypi.org/project/pip/
.. _uv: https://pypi.org/project/uv/
.. _fake.py: https://github.com/barseghyanartur/fake.py
.. _boto3: https://github.com/boto/boto3
.. _moto: https://github.com/getmoto/moto
.. _openai: https://github.com/openai/openai-python
.. _Ollama: https://github.com/ollama/ollama

Usage examples
--------------

Any fenced code block with a recognized Python language tag (e.g., ``python``,
``py``) will be collected and executed automatically, if
your `pytest`_ :ref:`configuration <configuration>` allows that.

Standalone code blocks
~~~~~~~~~~~~~~~~~~~~~~

.. note:: Note that ``name`` value has a ``test_`` prefix.

*Filename: README.md*

.. code-block:: markdown

    ```python name=test_basic_example
    import math

result = math.pow(3, 2)
assert res    ult == 9
        ```

----

Grouping multiple code blocks
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

It's possible to split one logical test into multiple blocks by specifying
the same name.

.. note:: Note that both snippts share the same ``name``
          value (``test_grouping_example``).

*Filename: README.md*

.. code-block:: markdown

    ```python name=test_grouping_example
    x = 1
    ```

    Some intervening text.

    ```python name=test_grouping_example
    print(x + 1)  # Uses x from the first snippet
    ```

The above mentioned three snippets will run as a single test.

----

Adding pytest markers to code blocks
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

It's possible to add custom pytest markers to your code blocks. That allows
adding custom logic and mocking in your ``conftest.py``.

In the example below, ``django_db`` marker is added to the code block.

.. note:: Note the ``pytestmark`` directive ``django_db`` marker.

*Filename: README.md*

.. code-block:: markdown

    <!-- pytestmark: django_db -->
    ```python name=test_django
    from django.contrib.auth.models import User

user = User.objects.first()
        ```

Customisation/hooks
-------------------
Tests can be extended and fine-tuned using `pytest`_'s standard hook system.

Below is an example workflow:

1. **Add custom pytest markers** to the code
   blocks (``fakepy``, ``aws``, ``openai``).
2. **Implement pytest hooks** in ``conftest.py`` to react to those markers.

Add custom pytest markers
~~~~~~~~~~~~~~~~~~~~~~~~~

Add ``fakepy`` marker
^^^^^^^^^^^^^^^^^^^^^

The example code below will generate a PDF file with random text
using `fake.py`_ library. Note, that a ``fakepy`` marker is added to
the code block.

In the `Implement pytest hooks`_ section, you will see what can be done
with the markers.

.. note:: Note the ``pytestmark`` directive ``fakepy`` marker.

*Filename: README.md*

.. code-block:: markdown

    <!-- pytestmark: fakepy -->
    ```python name=test_create_pdf_file
    from fake import FAKER

FAKER.pdf_file()
        ```

Add ``aws`` marker
^^^^^^^^^^^^^^^^^^

Sample `boto3`_ code to create a bucket on AWS S3.

.. note:: Note the ``pytestmark`` directive ``aws`` marker.

*Filename: README.md*

.. code-block:: markdown

    <!-- pytestmark: aws -->
    ```python name=test_create_bucket
    import boto3

s3 = boto3.client("s3", region_name="us-east-1")
s3.create_    bucket(Bucket="my-bucket")
assert "my-bucket" in     [b["Name"] for b in s3.list_buckets()    ["Buckets"]]
    ```

Add ``openai`` marker
^^^^^^^^^^^^^^^^^^^^^

Sample `openai`_ code to ask LLM to tell a joke. Note, that next to a
custom ``openai`` marker, ``xfail`` marker is used, which allows underlying
code to fail, without marking entire test suite as failed.

.. note:: Note the ``pytestmark`` directive ``xfail`` and ``openai`` markers.

*Filename: README.md*

.. code-block:: markdown

    <!-- pytestmark: xfail -->
    <!-- pytestmark: openai -->
    ```python name=test_tell_me_a_joke
    from openai import OpenAI

client = OpenAI()
completion = client.chat.completions.create(
    model="gpt-4o",
    mes    sages=[
        {"    role": "developer", "content": "You are a fam    ous comedian."},
            {"role": "    user", "content": "Tell me a joke."},
    ],
)

assert isinstance(comple    tion.choices[0].message.content, str)
                ```

----

Implement pytest hooks
~~~~~~~~~~~~~~~~~~~~~~

.. include:: _implement_pytest_hooks.rst

docs/package.rst

docs/package.rst
Package
=======

.. toctree::
   :maxdepth: 20

   fake

Indices and tables
==================

* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`

docs/restructured_text.rst

docs/restructured_text.rst
reStructuredText
================

.. External references
.. _reStructuredText: https://docutils.sourceforge.io/rst.html
.. _pytest: https://docs.pytest.org
.. _Django: https://www.djangoproject.com
.. _pip: https://pypi.org/project/pip/
.. _uv: https://pypi.org/project/uv/
.. _fake.py: https://github.com/barseghyanartur/fake.py
.. _boto3: https://github.com/boto/boto3
.. _moto: https://github.com/getmoto/moto
.. _openai: https://github.com/openai/openai-python
.. _Ollama: https://github.com/ollama/ollama

The following directives are supported:

- ``.. code-block:: python``
- ``.. code:: python``
- ``.. codeblock-name: <name>``
- ``.. literalinclude::``

Any code directive, such as ``.. code-block:: python``, ``.. code:: python``,
``.. literalinclude::`` or literal blocks with a
preceding ``.. codeblock-name: <name>``, will be collected and executed
automatically, if your `pytest`_ :ref:`configuration <configuration>` allows
that.

Usage examples
--------------

Standalone code blocks
~~~~~~~~~~~~~~~~~~~~~~

``code-block`` directive
^^^^^^^^^^^^^^^^^^^^^^^^

.. note:: Note that ``:name:`` value has a ``test_`` prefix.

*Filename: README.rst*

.. code-block:: rst

    .. code-block:: python
       :name: test_basic_example

       import math

       result = math.pow(3, 2)
       assert result == 9

----

``literalinclude`` directive
^^^^^^^^^^^^^^^^^^^^^^^^^^^^

*Filename: README.rst*

.. code-block:: rst

    .. literalinclude:: examples/python/basic_example.py
        :name: test_li_basic_example

----

``codeblock-name`` directive
^^^^^^^^^^^^^^^^^^^^^^^^^^^^

You can also use a literal block with a preceding name comment:

*Filename: README.rst*

.. code-block:: rst

    .. codeblock-name: test_grouping_example_literal_block
    This is a literal block::

       y = 5
       print(y * 2)

----

Grouping multiple ``code-block`` directives
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

It's possible to split one logical test into multiple blocks.
They will be tested under the first ``:name:`` specified.
Note the ``.. continue::`` directive.

.. note:: Note that ``continue`` directive of
          the ``test_grouping_example_part_2``
          and ``test_grouping_example_part_3`` refers to
          the ``test_grouping_example``.

*Filename: README.rst*

.. code-block:: rst

    .. code-block:: python
       :name: test_grouping_example

       x = 1

    Some intervening text.

    .. continue: test_grouping_example
    .. code-block:: python
       :name: test_grouping_example_part_2

       y = x + 1  # Uses x from the first snippet
       assert y == 2

    Some intervening text.

    .. continue: test_grouping_example
    .. code-block:: python
       :name: test_grouping_example_part_3

       print(y)  # Uses y from the previous snippet

The above mentioned three snippets will run as a single test.

----

Adding pytest markers to ``code-block`` and ``literalinclude`` directives
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

It's possible to add custom pytest markers to your ``code-block``
or ``literalinclude`` directives. That allows adding custom logic and mocking
in your ``conftest.py``.

In the example below, ``django_db`` marker is added to the ``code-block``
directive.

.. note:: Note the ``pytestmark`` directive ``django_db`` marker.

*Filename: README.rst*

.. code-block:: rst

    .. pytestmark: django_db
    .. code-block:: python
        :name: test_django

        from django.contrib.auth.models import User

        user = User.objects.first()

----

In the example below, ``django_db`` marker is added to the ``literalinclude``
directive.

*Filename: README.rst*

.. code-block:: rst

    .. pytestmark: django_db
    .. literalinclude:: examples/python/django_example.py
        :name: test_li_django_example

Customisation/hooks
-------------------
Tests can be extended and fine-tuned using `pytest`_'s standard hook system.

Below is an example workflow:

1. **Add custom pytest markers** to the ``code-block``
   or ``literalinclude`` (``fakepy``, ``aws``, ``openai``).
2. **Implement pytest hooks** in ``conftest.py`` to react to those markers.

Add custom pytest markers
~~~~~~~~~~~~~~~~~~~~~~~~~

Add ``fakepy`` marker
^^^^^^^^^^^^^^^^^^^^^

The example code below will generate a PDF file with random text
using `fake.py`_ library. Note, that a ``fakepy`` marker is added to
the ``code-block``.

In the `Implement pytest hooks`_ section, you will see what can be done
with the markers.

.. note:: Note the ``pytestmark`` directive ``fakepy`` marker.

*Filename: README.rst*

.. code-block:: rst

    .. pytestmark: fakepy
    .. code-block:: python
        :name: test_create_pdf_file

        from fake import FAKER

        FAKER.pdf_file()

----

In the example code below, a ``fakepy`` marker is added to
the ``literalinclude`` block.

*Filename: README.rst*

.. code-block:: rst

    .. pytestmark: fakepy
    .. literalinclude:: examples/python/create_pdf_file_example.py
        :name: test_li_create_pdf_file

----

Add ``aws`` marker
^^^^^^^^^^^^^^^^^^

Sample `boto3`_ code to create a bucket on AWS S3.

.. note:: Note the ``pytestmark`` directive ``aws`` marker.

*Filename: README.rst*

.. code-block:: rst

    .. pytestmark: aws
    .. code-block:: python
        :name: test_create_bucket

        import boto3

        s3 = boto3.client("s3", region_name="us-east-1")
        s3.create_bucket(Bucket="my-bucket")
        assert "my-bucket" in [b["Name"] for b in s3.list_buckets()["Buckets"]]

----

Add ``openai`` marker
^^^^^^^^^^^^^^^^^^^^^

Sample `openai`_ code to ask LLM to tell a joke. Note, that next to a
custom ``openai`` marker, ``xfail`` marker is used, which allows underlying
code to fail, without marking entire test suite as failed.

.. note:: Note the ``pytestmark`` directive ``xfail`` and ``openai`` markers.

*Filename: README.rst*

.. code-block:: rst

    .. pytestmark: xfail
    .. pytestmark: openai
    .. code-block:: python
        :name: test_tell_me_a_joke

        from openai import OpenAI

        client = OpenAI()
        completion = client.chat.completions.create(
            model="gpt-4o",
            messages=[
                {"role": "developer", "content": "You are a famous comedian."},
                {"role": "user", "content": "Tell me a joke."},
            ],
        )

        assert isinstance(completion.choices[0].message.content, str)

----

Implement pytest hooks
~~~~~~~~~~~~~~~~~~~~~~

.. include:: _implement_pytest_hooks.rst

docs/security.rst

docs/security.rst
.. include:: ../SECURITY.rst

docs/source_tree.rst

docs/source_tree.rst
Project source-tree
===================

Below is the layout of our project (to 10 levels), followed by
the contents of each key file.

.. code-block:: bash
   :caption: Project directory layout

   pytest-codeblock/

   ├── docs
   │   ├── _static
   │   ├── _templates
   │   ├── _implement_pytest_hooks.rst
   │   ├── changelog.rst
   │   ├── code_of_conduct.rst
   │   ├── conf.py
   │   ├── conf.py.distrib
   │   ├── contributor_guidelines.rst
   │   ├── documentation.rst
   │   ├── index.rst
   │   ├── index.rst.distrib
   │   ├── llms.rst
   │   ├── make.bat
   │   ├── Makefile
   │   ├── markdown.rst
   │   ├── package.rst
   │   ├── requirements.txt
   │   ├── restructured_text.rst
   │   ├── security.rst
   │   └── source_tree.rst
   ├── examples
   │   ├── md_example
   │   │   ├── customisation.md
   │   │   └── README.md
   │   ├── python
   │   │   ├── __init__.py
   │   │   ├── basic_example.py
   │   │   ├── create_bucket_example.py
   │   │   ├── create_pdf_file_example.py
   │   │   ├── django_example.py
   │   │   └── tell_me_a_joke_example.py
   │   └── rst_example
   │       ├── __pycache__
   │       ├── __init__.py
   │       ├── customisation.rst
   │       ├── django_settings.py
   │       └── README.rst
   ├── scripts
   │   └── generate_project_source_tree.py
   ├── src
   │   └── pytest_codeblock
   │       ├── __pycache__
   │       ├── tests
   │       │   ├── __pycache__
   │       │   ├── __init__.py
   │       │   ├── test_pytest_codeblock.py
   │       │   └── tests.rst
   │       ├── __init__.py
   │       ├── collector.py
   │       ├── constants.py
   │       ├── md.py
   │       ├── rst.py

docs/_implement_pytest_hooks.rst
--------------------------------

.. literalinclude:: _implement_pytest_hooks.rst
   :language: rst
   :caption: docs/_implement_pytest_hooks.rst

docs/changelog.rst
------------------

.. literalinclude:: changelog.rst
   :language: rst
   :caption: docs/changelog.rst

docs/code_of_conduct.rst
------------------------

.. literalinclude:: code_of_conduct.rst
   :language: rst
   :caption: docs/code_of_conduct.rst

docs/conf.py
------------

.. literalinclude:: conf.py
   :language: python
   :caption: docs/conf.py

docs/contributor_guidelines.rst
-------------------------------

.. literalinclude:: contributor_guidelines.rst
   :language: rst
   :caption: docs/contributor_guidelines.rst

docs/documentation.rst
----------------------

.. literalinclude:: documentation.rst
   :language: rst
   :caption: docs/documentation.rst

docs/index.rst
--------------

.. literalinclude:: index.rst
   :language: rst
   :caption: docs/index.rst

docs/llms.rst
-------------

.. literalinclude:: llms.rst
   :language: rst
   :caption: docs/llms.rst

docs/markdown.rst
-----------------

.. literalinclude:: markdown.rst
   :language: rst
   :caption: docs/markdown.rst

docs/package.rst
----------------

.. literalinclude:: package.rst
   :language: rst
   :caption: docs/package.rst

docs/restructured_text.rst
--------------------------

.. literalinclude:: restructured_text.rst
   :language: rst
   :caption: docs/restructured_text.rst

docs/security.rst
-----------------

.. literalinclude:: security.rst
   :language: rst
   :caption: docs/security.rst

docs/source_tree.rst
--------------------

.. literalinclude:: source_tree.rst
   :language: rst
   :caption: docs/source_tree.rst

examples/md_example/README.md
-----------------------------

.. literalinclude:: ../examples/md_example/README.md
   :language: markdown
   :caption: examples/md_example/README.md

examples/md_example/customisation.md
------------------------------------

.. literalinclude:: ../examples/md_example/customisation.md
   :language: markdown
   :caption: examples/md_example/customisation.md

examples/python/__init__.py
---------------------------

.. literalinclude:: ../examples/python/__init__.py
   :language: python
   :caption: examples/python/__init__.py

examples/python/basic_example.py
--------------------------------

.. literalinclude:: ../examples/python/basic_example.py
   :language: python
   :caption: examples/python/basic_example.py

examples/python/create_bucket_example.py
----------------------------------------

.. literalinclude:: ../examples/python/create_bucket_example.py
   :language: python
   :caption: examples/python/create_bucket_example.py

examples/python/create_pdf_file_example.py
------------------------------------------

.. literalinclude:: ../examples/python/create_pdf_file_example.py
   :language: python
   :caption: examples/python/create_pdf_file_example.py

examples/python/django_example.py
---------------------------------

.. literalinclude:: ../examples/python/django_example.py
   :language: python
   :caption: examples/python/django_example.py

examples/python/tell_me_a_joke_example.py
-----------------------------------------

.. literalinclude:: ../examples/python/tell_me_a_joke_example.py
   :language: python
   :caption: examples/python/tell_me_a_joke_example.py

examples/rst_example/README.rst
-------------------------------

.. literalinclude:: ../examples/rst_example/README.rst
   :language: rst
   :caption: examples/rst_example/README.rst

examples/rst_example/__init__.py
--------------------------------

.. literalinclude:: ../examples/rst_example/__init__.py
   :language: python
   :caption: examples/rst_example/__init__.py

examples/rst_example/customisation.rst
--------------------------------------

.. literalinclude:: ../examples/rst_example/customisation.rst
   :language: rst
   :caption: examples/rst_example/customisation.rst

examples/rst_example/django_settings.py
---------------------------------------

.. literalinclude:: ../examples/rst_example/django_settings.py
   :language: python
   :caption: examples/rst_example/django_settings.py

scripts/generate_project_source_tree.py
---------------------------------------

.. literalinclude:: ../scripts/generate_project_source_tree.py
   :language: python
   :caption: scripts/generate_project_source_tree.py

src/pytest_codeblock/__init__.py
--------------------------------

.. literalinclude:: ../src/pytest_codeblock/__init__.py
   :language: python
   :caption: src/pytest_codeblock/__init__.py

src/pytest_codeblock/collector.py
---------------------------------

.. literalinclude:: ../src/pytest_codeblock/collector.py
   :language: python
   :caption: src/pytest_codeblock/collector.py

src/pytest_codeblock/constants.py
---------------------------------

.. literalinclude:: ../src/pytest_codeblock/constants.py
   :language: python
   :caption: src/pytest_codeblock/constants.py

src/pytest_codeblock/md.py
--------------------------

.. literalinclude:: ../src/pytest_codeblock/md.py
   :language: python
   :caption: src/pytest_codeblock/md.py

src/pytest_codeblock/rst.py
---------------------------

.. literalinclude:: ../src/pytest_codeblock/rst.py
   :language: python
   :caption: src/pytest_codeblock/rst.py

src/pytest_codeblock/tests/__init__.py
--------------------------------------

.. literalinclude:: ../src/pytest_codeblock/tests/__init__.py
   :language: python
   :caption: src/pytest_codeblock/tests/__init__.py

src/pytest_codeblock/tests/test_pytest_codeblock.py
---------------------------------------------------

.. literalinclude:: ../src/pytest_codeblock/tests/test_pytest_codeblock.py
   :language: python
   :caption: src/pytest_codeblock/tests/test_pytest_codeblock.py

src/pytest_codeblock/tests/tests.rst
------------------------------------

.. literalinclude:: ../src/pytest_codeblock/tests/tests.rst
   :language: rst
   :caption: src/pytest_codeblock/tests/tests.rst

examples/md_example/README.md

examples/md_example/README.md
# Markdown example project

This is a minimal example showing how pytest-codeblock will discover and run
only Python snippets whose `name` starts with test_.

## Simple assertion

```python name=test_simple_assert
# A trivial test that always passes
assert 2 + 2 == 4
```

## Multi-part example

It's possible to split one logical test into multiple blocks. All of them
share the same ``name``:

```python name=test_compute_square
import math
```

Some intervening text.

```python name=test_compute_square
result = math.pow(3, 2)
assert result == 9
```

Some intervening text.

```python name=test_compute_square
print(result)
```

## Ignored snippets

Blocks without a `name` or without the `test_` prefix are **not** collected:

```python name=example_not_test
# Name does not start with `test_`, so this is ignored
```

## Non-Python blocks are also ignored

```bash name=test_should_be_ignored
echo "Not Python → skipped"
```

## Custom pytest marks

<!-- pytestmark: django_db -->
```python name=test_django
from django.contrib.auth.models import User

user = User.objects.first()
```

examples/md_example/customisation.md

examples/md_example/customisation.md
# Customisation

Customisation examples.

## External references

- [openai](https://github.com/openai/openai-python)
- [moto](https://docs.getmoto.org)
- [fake.py](https://github.com/barseghyanartur/fake.py)

## `fake.py` example

<!-- pytestmark: fakepy -->
```python name=test_create_pdf_file
from fake import FAKER

FAKER.pdf_file()
```

## `moto` example

<!-- pytestmark: aws -->
```python name=test_create_bucket
import boto3

s3 = boto3.client("s3", region_name="us-east-1")
s3.create_bucket(Bucket="my-bucket")
assert "my-bucket" in [b["Name"] for b in s3.list_buckets()["Buckets"]]
```

## `openai` example

<!-- pytestmark: xfail -->
<!-- pytestmark: openai -->
```python name=test_tell_me_a_joke
from openai import OpenAI

client = OpenAI()
completion = client.chat.completions.create(
    model="gpt-4o",
    messages=[
        {"role": "developer", "content": "You are a famous comedian."},
        {"role": "user", "content": "Tell me a joke."},
    ],
)

assert isinstance(completion.choices[0].message.content, str)
```

examples/python/__init__.py

examples/python/__init__.py

examples/python/basic_example.py

examples/python/basic_example.py
import math

result = math.pow(3, 2)
assert result == 9

examples/python/create_bucket_example.py

examples/python/create_bucket_example.py
import boto3

s3 = boto3.client("s3", region_name="us-east-1")
s3.create_bucket(Bucket="my-bucket")
assert "my-bucket" in [b["Name"] for b in s3.list_buckets()["Buckets"]]

examples/python/create_pdf_file_example.py

examples/python/create_pdf_file_example.py
from fake import FAKER

file = FAKER.pdf_file()

assert file.data["storage"].exists(str(file))

examples/python/django_example.py

examples/python/django_example.py
from django.contrib.auth.models import User

user = User.objects.first()

examples/python/tell_me_a_joke_example.py

examples/python/tell_me_a_joke_example.py
from openai import OpenAI

client = OpenAI()
completion = client.chat.completions.create(
    model="gpt-4o",
    messages=[
        {"role": "developer", "content": "You are a famous comedian."},
        {"role": "user", "content": "Tell me a joke."},
    ],
)

assert isinstance(completion.choices[0].message.content, str)

examples/rst_example/README.rst

examples/rst_example/README.rst
reStructuredText example project
================================

This is a minimal example showing how `pytest-codeblock` will discover
and run only Python snippets whose `:name:` starts with `test_`.

Simple assertion
----------------

.. code-block:: python
   :name: test_simple_assert

   # A trivial test that always passes
   assert 2 + 2 == 4

Multi-part example
------------------

It's possible to split one logical test into multiple blocks.
They will be tested under the first ``:name:`` specified.
Note the ``.. continue::`` directive.

.. code-block:: python
   :name: test_compute_square

   import math

Some intervening text.

.. continue: test_compute_square
.. code-block:: python
   :name: test_compute_square_part_2

   result = math.pow(3, 2)
   assert result == 9

Some intervening text.

.. continue: test_compute_square
.. code-block:: python
   :name: test_compute_square_part_3

   print(result)

Ignored snippets
----------------

Blocks without a `:name:` or without the `test_` prefix are **not** collected:

.. code-block:: python

   # No :name:, so this is ignored

.. code-block:: python
   :name: example_not_test

   # Name does not start with `test_`, so this is ignored

Non-Python blocks are also ignored
----------------------------------

.. code-block:: bash
   :name: test_should_be_ignored

   echo "Not Python → skipped"

Custom pytest marks
-------------------
.. pytestmark: django_db
.. code-block:: python
    :name: test_django

    from django.contrib.auth.models import User

    user = User.objects.first()

examples/rst_example/__init__.py

examples/rst_example/__init__.py

examples/rst_example/customisation.rst

examples/rst_example/customisation.rst
Customisation
=============
Customisation examples.

.. External references
.. _openai: https://github.com/openai/openai-python
.. _moto: https://docs.getmoto.org
.. _fake.py: https://github.com/barseghyanartur/fake.py

`fake.py`_ example for `.. code-block::` directive
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: rst

    .. pytestmark: fakepy
    .. code-block:: python
        :name: test_create_pdf_file

        from fake import FAKER

        file = FAKER.pdf_file()

        assert file.data["storage"].exists(str(file))

`moto`_ example for `.. code-block::` directive
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: rst

    .. pytestmark: aws
    .. code-block:: python
        :name: test_create_bucket

        import boto3

        s3 = boto3.client("s3", region_name="us-east-1")
        s3.create_bucket(Bucket="my-bucket")
        assert "my-bucket" in [b["Name"] for b in s3.list_buckets()["Buckets"]]

`openai`_ example for `.. code-block::` directive
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: rst

    .. pytestmark: xfail
    .. pytestmark: openai
    .. code-block:: python
        :name: test_tell_me_a_joke

        from openai import OpenAI

        client = OpenAI()
        completion = client.chat.completions.create(
            model="gpt-4o",
            messages=[
                {"role": "developer", "content": "You are a famous comedian."},
                {"role": "user", "content": "Tell me a joke."},
            ],
        )

        assert isinstance(completion.choices[0].message.content, str)

`fake.py`_ example for `.. literalinclude::` directive
------------------------------------------------------
.. code-block:: rst

    .. pytestmark: fakepy
    .. literalinclude:: examples/python/create_pdf_file_example.py
        :name: test_li_create_pdf_file

`moto`_ example for `.. literalinclude::` directive
---------------------------------------------------
.. code-block:: rst

    .. pytestmark: aws
    .. literalinclude:: examples/python/create_bucket_example.py
        :name: test_li_create_bucket

`openai`_ example for `.. literalinclude::` directive
-----------------------------------------------------

.. code-block:: rst

    .. pytestmark: xfail
    .. pytestmark: aws
    .. literalinclude:: examples/python/tell_me_a_joke_example.py
        :name: test_li_tell_me_a_joke

examples/rst_example/django_settings.py

examples/rst_example/django_settings.py
from pathlib import Path

from django import conf, http, urls
from django.core.handlers import asgi

# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent

# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
]

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.sqlite3",
        "NAME": BASE_DIR / "django_db.sqlite3",
    }
}

conf.settings.configure(
    ALLOWED_HOSTS="*",
    ROOT_URLCONF=__name__,
    INSTALLED_APPS=INSTALLED_APPS,
    DATABASES=DATABASES,
)

app = asgi.ASGIHandler()


async def root(_request: http.HttpRequest) -> http.JsonResponse:
    return http.JsonResponse({"message": "OK"})


urlpatterns = [urls.path("", root)]

scripts/generate_project_source_tree.py

scripts/generate_project_source_tree.py
#!/usr/bin/env python3
import argparse
import fnmatch
import os
from pathlib import Path


def build_tree(
    path: Path,
    max_depth: int,
    ignore_patterns: list,
    whitelist_dirs: list,
    include_all: bool,
    root: Path,
    prefix: str = "",
) -> str:
    """
    Recursively build an ASCII tree up to max_depth, applying whitelist and
    ignore rules.
    """
    if max_depth < 0:
        return ""
    entries = sorted(
        path.iterdir(), key=lambda p: (p.is_file(), p.name.lower())
    )
    lines = []
    for i, entry in enumerate(entries):
        rel_path = entry.relative_to(root).as_posix()
        # Skip ignored patterns
        if any(fnmatch.fnmatch(rel_path, pat) for pat in ignore_patterns):
            continue
        # Enforce whitelist if not including all
        if not include_all and whitelist_dirs and not any(
            rel_path.startswith(w.rstrip("/")) for w in whitelist_dirs
        ):
            continue
        connector = "└── " if i == len(entries) - 1 else "├── "
        lines.append(f"{prefix}{connector}{entry.name}")
        # Recurse into directories
        if entry.is_dir():
            extension = "    " if i == len(entries) - 1 else "│   "
            subtree = build_tree(
                entry,
                max_depth - 1,
                ignore_patterns,
                whitelist_dirs,
                include_all,
                root,
                prefix + extension,
            )
            if subtree:
                lines += subtree.splitlines()
    return "\n".join(lines)


def detect_language(path: Path) -> str:
    """Map file suffix to Sphinx language."""
    mapping = {
        ".py": "python",
        ".js": "javascript",
        ".java": "java",
        ".md": "markdown",
        ".yaml": "yaml",
        ".yml": "yaml",
        ".json": "json",
        ".sh": "bash",
        ".rst": "rst",
    }
    return mapping.get(path.suffix, "")


def main():
    p = argparse.ArgumentParser(
        description="Auto-generate a .rst with tree + literalinclude blocks"
    )
    p.add_argument(
        "-p",
        "--project-root",
        type=Path,
        default=Path("."),
        help="Path to your project directory",
    )
    p.add_argument(
        "-d",
        "--depth",
        type=int,
        default=10,
        help="How many levels deep to print in the tree",
    )
    p.add_argument(
        "-o",
        "--output",
        type=Path,
        default=Path("docs/source_tree.rst"),
        help="Where to write the generated .rst",
    )
    p.add_argument(
        "-e",
        "--ext",
        nargs="+",
        default=[".py", ".md", ".js", ".rst"],
        help="Which file extensions to include via literalinclude",
    )
    p.add_argument(
        "-i",
        "--ignore",
        nargs="+",
        default=["__pycache__", "*.pyc", "*.py,cover"],
        help="Ignore files or dirs matching these glob patterns (relative to "
             "project root)",
    )
    p.add_argument(
        "-w",
        "--whitelist",
        nargs="+",
        default=["src", "docs", "examples", "scripts"],
        help="Directories (relative to project root) to include "
             "unless --include-all is given",
    )
    p.add_argument(
        "--include-all",
        action="store_true",
        help="Include all files regardless of whitelist",
    )
    args = p.parse_args()

    root = args.project_root.resolve()
    ignore_patterns = args.ignore
    whitelist_dirs = args.whitelist
    include_all = args.include_all
    output = args.output.resolve()
    output_dir = output.parent.resolve()

    # Header + tree
    header = f"""Project source-tree
===================

Below is the layout of our project (to {args.depth} levels), followed by
the contents of each key file.

.. code-block:: bash
   :caption: Project directory layout

   {root.name}/
"""
    tree = build_tree(
        root,
        args.depth,
        ignore_patterns,
        whitelist_dirs,
        include_all,
        root,
        prefix="   ",
    )
    out = [header, tree, ""]

    # Walk and collect files
    for filepath in sorted(root.rglob("*")):
        if not filepath.is_file() or filepath.suffix not in args.ext:
            continue
        rel_path = filepath.relative_to(root).as_posix()
        # Skip ignored
        if any(fnmatch.fnmatch(rel_path, pat) for pat in ignore_patterns):
            continue
        # Enforce whitelist
        if (
            not include_all
            and whitelist_dirs
            and not any(
            rel_path.startswith(w.rstrip("/")) for w in whitelist_dirs)
        ):
            continue

        # Compute include path relative to output_dir
        include_path = os.path.relpath(filepath, output_dir).replace(
            os.sep, "/"
        )
        title = rel_path
        underline = "-" * len(title)
        lang = detect_language(filepath)
        out += [
            title,
            underline,
            "",
            f".. literalinclude:: {include_path}",
            f"   :language: {lang}" if lang else "",
            f"   :caption: {rel_path}",
            # "   :linenos:",
            "",
        ]

    # Write output
    args.output.write_text("\n".join(line for line in out if line is not None))
    print(f"Wrote {args.output}")


if __name__ == "__main__":
    main()

src/pytest_codeblock/__init__.py

src/pytest_codeblock/__init__.py
from pathlib import Path

from .md import MarkdownFile
from .rst import RSTFile

__title__ = "pytest-codeblock"
__version__ = "0.2"
__author__ = "Artur Barseghyan <artur.barseghyan@gmail.com>"
__copyright__ = "2025 Artur Barseghyan"
__license__ = "MIT"
__all__ = (
    "pytest_collect_file",
)


def pytest_collect_file(parent, path):
    """Collect .md and .rst files for codeblock tests."""
    # Determine file extension (works for py.path or pathlib.Path)
    file_name = str(path).lower()
    if file_name.endswith((".md", ".markdown")):
        # Use the MarkdownFile collector for Markdown files
        return MarkdownFile.from_parent(parent=parent, path=Path(path))
    if file_name.endswith(".rst"):
        # Use the RSTFile collector for reStructuredText files
        return RSTFile.from_parent(parent=parent, path=Path(path))
    return None

src/pytest_codeblock/collector.py

src/pytest_codeblock/collector.py
from dataclasses import dataclass, field
from typing import Optional

__author__ = "Artur Barseghyan <artur.barseghyan@gmail.com>"
__copyright__ = "2025 Artur Barseghyan"
__license__ = "MIT"
__all__ = (
    "CodeSnippet",
    "group_snippets",
)


@dataclass
class CodeSnippet:
    """Data container for an extracted code snippet."""
    code: str  # The code content
    line: int  # Starting line number in the source
    name: Optional[str] = None  # Identifier for grouping (None if anonymous)
    marks: list[str] = field(default_factory=list)
    # Collected pytest marks (e.g. ['django_db']), parsed from doc comments


def group_snippets(snippets: list[CodeSnippet]) -> list[CodeSnippet]:
    """
    Merge snippets with the same name into one CodeSnippet,
    concatenating their code and accumulating marks.
    Unnamed snippets get unique auto-names.
    """
    combined: list[CodeSnippet] = []
    seen: dict[str, CodeSnippet] = {}
    anon_count = 0

    for sn in snippets:
        key = sn.name
        if not key:
            anon_count += 1
            key = f"codeblock{anon_count}"

        if key in seen:
            seen_sn = seen[key]
            seen_sn.code += "\n" + sn.code
            seen_sn.marks.extend(sn.marks)
        else:
            sn.marks = list(sn.marks)  # copy
            seen[key] = sn
            combined.append(sn)

    return combined

src/pytest_codeblock/constants.py

src/pytest_codeblock/constants.py
__author__ = "Artur Barseghyan <artur.barseghyan@gmail.com>"
__copyright__ = "2025 Artur Barseghyan"
__license__ = "MIT"
__all__ = (
    "CODEBLOCK_MARK",
    "DJANGO_DB_MARKS",
    "TEST_PREFIX",
)

DJANGO_DB_MARKS = {
    "django_db",
    "db",
    "transactional_db",
}

TEST_PREFIX = "test_"

CODEBLOCK_MARK = "codeblock"

src/pytest_codeblock/md.py

src/pytest_codeblock/md.py
import re
from typing import Optional

import pytest

from .collector import CodeSnippet, group_snippets
from .constants import CODEBLOCK_MARK, DJANGO_DB_MARKS, TEST_PREFIX

__author__ = "Artur Barseghyan <artur.barseghyan@gmail.com>"
__copyright__ = "2025 Artur Barseghyan"
__license__ = "MIT"
__all__ = (
    "MarkdownFile",
    "parse_markdown",
)


def parse_markdown(text: str) -> list[CodeSnippet]:
    """
    Parse Markdown text and extract Python code snippets as CodeSnippet
    objects.

    Supports:
      - <!-- pytestmark: <mark> --> comments immediately before a code fence
      - <!-- codeblock-name: <name> --> comments for naming
      - Fenced code blocks with ```python (and optional name=<name> in the
        info string)

    Captures each snippet’s name, code, starting line, and any pytest marks.
    """
    snippets: list[CodeSnippet] = []
    lines = text.splitlines()
    pending_name: Optional[str] = None
    pending_marks: list[str] = [CODEBLOCK_MARK]
    in_block = False
    fence = ""
    block_indent = 0
    code_buffer: list[str] = []
    snippet_name: Optional[str] = None
    start_line = 0

    for idx, line in enumerate(lines, start=1):
        stripped = line.strip()

        if not in_block:
            # Check for pytest mark comment
            if stripped.startswith("<!--") and "pytestmark:" in stripped:
                m = re.match(r"<!--\s*pytestmark:\s*(\w+)\s*-->", stripped)
                if m:
                    pending_marks.append(m.group(1))
                continue

            # Check for name comment
            if stripped.startswith("<!--") and "codeblock-name:" in stripped:
                m = re.match(
                    r"<!--\s*codeblock-name:\s*([^ >]+)\s*-->", stripped
                )
                if m:
                    pending_name = m.group(1)
                continue

            # Start of fenced code block?
            if line.lstrip().startswith("```"):
                indent = len(line) - len(line.lstrip())
                m = re.match(r"^`{3,}", line.lstrip())
                if not m:
                    continue
                fence = m.group(0)
                info = line.lstrip()[len(fence):].strip()
                parts = info.split(None, 1)
                lang = parts[0].lower() if parts else ""
                extra = parts[1] if len(parts) > 1 else ""
                if lang in ("python", "py", "python3"):
                    in_block = True
                    block_indent = indent
                    start_line = idx + 1
                    code_buffer = []
                    # determine name from info string or pending comment
                    snippet_name = None
                    for token in extra.split():
                        if (
                            token.startswith("name=")
                            or token.startswith("name:")
                        ):
                            snippet_name = (
                                token.split("=", 1)[-1]
                                if "=" in token
                                else token.split(":", 1)[-1]
                            )
                            break
                    if snippet_name is None:
                        snippet_name = pending_name
                    # reset pending_name; marks stay until block closes
                    pending_name = None
                continue

        else:
            # inside a fenced code block
            if line.lstrip().startswith(fence):
                # end of block
                in_block = False
                code_text = "\n".join(code_buffer)
                snippets.append(CodeSnippet(
                    name=snippet_name,
                    code=code_text,
                    line=start_line,
                    marks=pending_marks.copy(),
                ))
                # reset pending marks after collecting
                pending_marks.clear()
                snippet_name = None
            else:
                # collect code lines (dedent by block_indent)
                if line.strip() == "":
                    code_buffer.append("")
                else:
                    if len(line) >= block_indent:
                        code_buffer.append(line[block_indent:])
                    else:
                        code_buffer.append(line.lstrip())
            continue

    return snippets


class MarkdownFile(pytest.File):
    """
    Collector for Markdown files, extracting only `test_`-prefixed code
    snippets.
    """
    def collect(self):
        text = self.path.read_text(encoding="utf-8")
        raw = parse_markdown(text)
        # keep only snippets named test_*
        tests = [
            sn for sn in raw if sn.name and sn.name.startswith(TEST_PREFIX)
        ]
        combined = group_snippets(tests)

        for sn in combined:
            # generate a real pytest Function so fixtures work
            if DJANGO_DB_MARKS.intersection(sn.marks):
                def make_func(code):
                    def test_block(db):
                        exec(code, {})
                    return test_block
            else:
                def make_func(code):
                    def test_block():
                        exec(code, {})
                    return test_block

            callobj = make_func(sn.code)
            fn = pytest.Function.from_parent(
                parent=self,
                name=sn.name,
                callobj=callobj,
            )
            # apply any marks (e.g. django_db)
            for m in sn.marks:
                fn.add_marker(getattr(pytest.mark, m))
            yield fn

src/pytest_codeblock/rst.py

src/pytest_codeblock/rst.py
import re
import textwrap
import traceback
from pathlib import Path
from typing import Optional, Union

import pytest

from .collector import CodeSnippet, group_snippets
from .constants import CODEBLOCK_MARK, DJANGO_DB_MARKS, TEST_PREFIX

__author__ = "Artur Barseghyan <artur.barseghyan@gmail.com>"
__copyright__ = "2025 Artur Barseghyan"
__license__ = "MIT"
__all__ = (
    "RSTFile",
    "parse_rst",
    "resolve_literalinclude_path",
    "get_literalinclude_content",
)


def resolve_literalinclude_path(
    base_dir: Union[str, Path],
    include_path: str,
) -> Optional[str]:
    """
    Resolve the full path for a literalinclude directive.
    Returns None if the file doesn't exist.
    """
    _include_path = Path(include_path)

    # If `include_path` is already absolute or relative and exists, done
    if _include_path.exists():
        return str(_include_path.resolve())

    # If base_path is a file, switch to its parent directory
    _base_path = Path(base_dir)
    if _base_path.is_file():
        _base_path = _base_path.parent

    try:
        full_path = _base_path / include_path
        if full_path.exists():
            return str(full_path.resolve())
    except Exception:
        pass
    return None


def get_literalinclude_content(path):
    try:
        with open(path) as f:
            return f.read()
    except Exception as e:
        raise RuntimeError(
            f"Failed to read literalinclude file {path}: {e}"
        ) from e


def parse_rst(text: str, base_dir: Path) -> list[CodeSnippet]:
    """
    Parse an RST document into CodeSnippet objects, capturing:
      - .. pytestmark: <mark>
      - .. continue: <name>
      - .. codeblock-name: <name>
      - .. code-block:: python
    """
    snippets: list[CodeSnippet] = []
    lines = text.splitlines()
    n = len(lines)

    pending_name: Optional[str] = None
    pending_marks: list[str] = [CODEBLOCK_MARK]
    pending_continue: Optional[str] = None
    i = 0

    while i < n:
        line = lines[i]

        # --------------------------------------------------------------------
        # Collect `.. pytestmark: xyz`
        # --------------------------------------------------------------------
        m = re.match(r"^\s*\.\.\s*pytestmark:\s*(\w+)\s*$", line)
        if m:
            pending_marks.append(m.group(1))
            i += 1
            continue

        # --------------------------------------------------------------------
        # The `.. literalinclude` directive
        # --------------------------------------------------------------------
        if line.strip().startswith(".. literalinclude::"):
            path = line.split(".. literalinclude::", 1)[1].strip()
            name = None

            # Look ahead for name
            j = i + 1
            while j < len(lines) and lines[j].strip():
                if ":name:" in lines[j]:
                    name = lines[j].split(":name:", 1)[1].strip()
                    break
                j += 1

            if name and name.startswith("test_"):
                full_path = resolve_literalinclude_path(base_dir, path)
                if full_path:
                    snippet = CodeSnippet(
                        code=get_literalinclude_content(full_path),
                        line=i + 1,
                        name=name,
                        marks=pending_marks.copy(),
                    )
                    snippets.append(snippet)

            i = j + 1
            continue

        # --------------------------------------------------------------------
        # Collect `.. continue: foo`
        # --------------------------------------------------------------------
        m = re.match(r"^\s*\.\.\s*continue:\s*(\S+)\s*$", line)
        if m:
            pending_continue = m.group(1)
            i += 1
            continue

        # --------------------------------------------------------------------
        # Collect `.. codeblock-name: foo`
        # --------------------------------------------------------------------
        m = re.match(r"^\s*\.\.\s*codeblock-name:\s*(\S+)\s*$", line)
        if m:
            pending_name = m.group(1)
            i += 1
            continue

        # --------------------------------------------------------------------
        # The `.. code-block` directive
        # --------------------------------------------------------------------
        m = re.match(r"^(\s*)\.\. (?:code-block|code)::\s*(\w+)", line)
        if m:
            base_indent = len(m.group(1))
            lang = m.group(2).lower()
            if lang in ("python", "py", "python3"):
                # Parse :name: option
                name_val: Optional[str] = None
                j = i + 1
                while j < n:
                    ln = lines[j]
                    if not ln.strip():
                        j += 1
                        continue
                    indent = len(ln) - len(ln.lstrip())
                    if ln.lstrip().startswith(":") and indent > base_indent:
                        opt = ln.lstrip()
                        if opt.lower().startswith(":name:"):
                            name_val = opt.split(":", 2)[2].strip().split()[0]
                        j += 1
                        continue
                    break
                # The j is first code line
                if j >= n:
                    i = j
                    continue
                first = lines[j]
                content_indent = len(first) - len(first.lstrip())
                if content_indent <= base_indent:
                    i = j
                    continue
                # Collect code
                buf: list[str] = []
                k = j
                while k < n:
                    ln = lines[k]
                    if not ln.strip():
                        buf.append("")
                        k += 1
                        continue
                    ind = len(ln) - len(ln.lstrip())
                    if ind >= content_indent:
                        buf.append(ln[content_indent:])
                        k += 1
                    else:
                        break
                # Decide snippet name: continue overrides name_val/pending_name
                if pending_continue:
                    sn_name = pending_continue
                    pending_continue = None
                else:
                    sn_name = name_val or pending_name
                sn_marks = pending_marks.copy()
                pending_name = None
                pending_marks.clear()

                snippets.append(CodeSnippet(
                    name=sn_name,
                    code="\n".join(buf),
                    line=j + 1,
                    marks=sn_marks,
                ))

                i = k
                continue
            else:
                i += 1
                continue

        # --------------------------------------------------------------------
        # The literal-block via "::"
        # --------------------------------------------------------------------
        if line.rstrip().endswith("::") and pending_name:
            # Similar override logic
            if pending_continue:
                sn_name = pending_continue
                pending_continue = None
            else:
                sn_name = pending_name
            sn_marks = pending_marks.copy()
            pending_name = None
            pending_marks.clear()
            j = i + 1
            if j < n and not lines[j].strip():
                j += 1
            if j >= n:
                i = j
                continue
            first = lines[j]
            content_indent = len(first) - len(first.lstrip())
            buf: list[str] = []
            k = j
            while k < n:
                ln = lines[k]
                if not ln.strip():
                    buf.append("")
                    k += 1
                    continue
                ind = len(ln) - len(ln.lstrip())
                if ind >= content_indent:
                    buf.append(ln[content_indent:])
                    k += 1
                else:
                    break
            snippets.append(CodeSnippet(
                name=sn_name,
                code="\n".join(buf),
                line=j + 1,
                marks=sn_marks,
            ))
            i = k
            continue

        i += 1

    return snippets


class RSTFile(pytest.File):
    """Collect RST code-block tests as real test functions."""
    def collect(self):
        text = self.path.read_text(encoding="utf-8")
        raw = parse_rst(text, self.path)

        # Only keep test_* snippets
        tests = [
            sn for sn in raw if sn.name and sn.name.startswith(TEST_PREFIX)
        ]
        combined = group_snippets(tests)

        for sn in combined:
            # Bind the values we need so we don't close over `sn` itself
            _sn_name = sn.name
            _fpath = str(self.path)

            # Create a Python function for this snippet
            if DJANGO_DB_MARKS.intersection(sn.marks):
                # Function *requests* the db fixture
                def make_func(code, sn_name=_sn_name, fpath=_fpath):
                    def test_block(db):
                        compiled = compile(code, fpath, "exec")
                        try:
                            exec(compiled, {})
                        except Exception as err:
                            raise Exception(
                                f"Error in "
                                f"codeblock `{sn_name}` in {fpath}:\n"
                                f"\n{textwrap.indent(code, prefix='    ')}\n\n"
                                f"{traceback.format_exc()}"
                            ) from err
                    return test_block
            else:
                def make_func(code, sn_name=_sn_name, fpath=_fpath):
                    def test_block():
                        compiled = compile(code, fpath, "exec")
                        try:
                            exec(compiled, {})
                        except Exception as err:
                            raise Exception(
                                f"Error in "
                                f"codeblock `{sn_name}` in {fpath}:\n"
                                f"\n{textwrap.indent(code, prefix='    ')}\n\n"
                                f"{traceback.format_exc()}"
                            ) from err
                    return test_block

            callobj = make_func(sn.code)

            fn = pytest.Function.from_parent(
                parent=self,
                name=sn.name,
                callobj=callobj
            )
            # Re-apply any pytest.mark.<foo> markers
            for m in sn.marks:
                fn.add_marker(getattr(pytest.mark, m))
            yield fn

src/pytest_codeblock/tests/__init__.py

src/pytest_codeblock/tests/__init__.py

src/pytest_codeblock/tests/test_pytest_codeblock.py

src/pytest_codeblock/tests/test_pytest_codeblock.py
from pytest_codeblock.collector import CodeSnippet, group_snippets
from pytest_codeblock.md import parse_markdown
from pytest_codeblock.rst import (
    get_literalinclude_content,
    parse_rst,
    resolve_literalinclude_path,
)

__author__ = "Artur Barseghyan <artur.barseghyan@gmail.com>"
__copyright__ = "2025 Artur Barseghyan"
__license__ = "MIT"
__all__ = (
    "test_group_snippets_different_names",
    "test_group_snippets_merges_named",
    "test_parse_markdown_simple",
    "test_parse_markdown_with_pytestmark",
    "test_parse_rst_literalinclude",
    "test_parse_rst_simple",
    "test_resolve_literalinclude_and_content",
)


def test_group_snippets_merges_named():
    # Two snippets with the same name should be combined
    sn1 = CodeSnippet(name="foo", code="a=1", line=1, marks=["codeblock"])
    sn2 = CodeSnippet(name="foo", code="b=2", line=2, marks=["codeblock", "m"])
    combined = group_snippets([sn1, sn2])
    assert len(combined) == 1
    cs = combined[0]
    assert cs.name == "foo"
    # Both code parts should appear
    assert "a=1" in cs.code
    assert "b=2" in cs.code
    # Marks should accumulate
    assert "m" in cs.marks


def test_group_snippets_different_names():
    # Snippets with different names are not grouped
    sn1 = CodeSnippet(name="foo", code="x=1", line=1)
    sn2 = CodeSnippet(name="bar", code="y=2", line=2)
    combined = group_snippets([sn1, sn2])
    assert len(combined) == 2
    assert combined[0].name.startswith("foo")
    assert combined[1].name.startswith("bar")


def test_parse_markdown_simple():
    text = """
```python name=test_example
x=1
assert x==1
```"""
    snippets = parse_markdown(text)
    assert len(snippets) == 1
    sn = snippets[0]
    assert sn.name == "test_example"
    assert "x=1" in sn.code


def test_parse_markdown_with_pytestmark():
    text = """
<!-- pytestmark: django_db -->
```python name=test_db
from django.db import models
```"""
    snippets = parse_markdown(text)
    assert len(snippets) == 1
    sn = snippets[0]
    # Should include both default and django_db marks
    assert "django_db" in sn.marks
    assert "codeblock" in sn.marks


def test_resolve_literalinclude_and_content(tmp_path):
    base = tmp_path / "dir"
    base.mkdir()
    file = base / "a.py"
    file.write_text("print('hello')")
    # Absolute path resolution
    abs_path = resolve_literalinclude_path(base, str(file))
    assert abs_path == str(file.resolve())
    # Relative path resolution
    rel_path = resolve_literalinclude_path(base, "a.py")
    assert rel_path == str(file.resolve())
    # Content read
    content = get_literalinclude_content(str(file))
    assert content == "print('hello')"


def test_parse_rst_simple(tmp_path):
    # Basic code-block directive
    rst = """
.. code-block:: python
   :name: test_simple

   a=2
   assert a==2
"""
    snippets = parse_rst(rst, tmp_path)
    assert len(snippets) == 1
    sn = snippets[0]
    assert sn.name == "test_simple"
    assert "a=2" in sn.code


def test_parse_rst_literalinclude(tmp_path):
    # Create an external file to include
    include_dir = tmp_path / "inc"
    include_dir.mkdir()
    target = include_dir / "foo.py"
    target.write_text("z=3\nassert z==3")
    rst = f"""
.. literalinclude:: {target.name}
   :name: test_li
"""
    snippets = parse_rst(rst, include_dir)
    assert len(snippets) == 1
    sn = snippets[0]
    assert sn.name == "test_li"
    assert "z=3" in sn.code

src/pytest_codeblock/tests/tests.rst

src/pytest_codeblock/tests/tests.rst
Tests
=====

.. code-block:: python
    :name: test_group_snippets_merges_named

    import pytest
    from pathlib import Path

    from pytest_codeblock.collector import CodeSnippet, group_snippets
    from pytest_codeblock.md import parse_markdown
    from pytest_codeblock.rst import (
        parse_rst,
        resolve_literalinclude_path,
        get_literalinclude_content,
    )

    # Two snippets with the same name should be combined
    sn1 = CodeSnippet(name="foo", code="a=1", line=1, marks=["codeblock"])
    sn2 = CodeSnippet(name="foo", code="b=2", line=2, marks=["codeblock", "m"])
    combined = group_snippets([sn1, sn2])
    assert len(combined) == 1
    cs = combined[0]
    assert cs.name == "foo"
    # Both code parts should appear
    assert "a=1" in cs.code
    assert "b=2" in cs.code
    # Marks should accumulate
    assert "m" in cs.marks

----

.. code-block:: python
    :name: test_group_snippets_different_names

    import pytest
    from pathlib import Path

    from pytest_codeblock.collector import CodeSnippet, group_snippets
    from pytest_codeblock.md import parse_markdown
    from pytest_codeblock.rst import (
        parse_rst,
        resolve_literalinclude_path,
        get_literalinclude_content,
    )

    # Snippets with different names are not grouped
    sn1 = CodeSnippet(name="foo", code="x=1", line=1)
    sn2 = CodeSnippet(name="bar", code="y=2", line=2)
    combined = group_snippets([sn1, sn2])
    assert len(combined) == 2
    assert combined[0].name.startswith("foo")
    assert combined[1].name.startswith("bar")

----

.. code-block:: python
    :name: test_parse_markdown_simple

    import pytest
    from pathlib import Path

    from pytest_codeblock.collector import CodeSnippet, group_snippets
    from pytest_codeblock.md import parse_markdown
    from pytest_codeblock.rst import (
        parse_rst,
        resolve_literalinclude_path,
        get_literalinclude_content,
    )

    text = """
    ```python name=test_example
    x=1
    assert x==1
    ```"""
    snippets = parse_markdown(text)
    assert len(snippets) == 1
    sn = snippets[0]
    assert sn.name == "test_example"
    assert "x=1" in sn.code

----

.. code-block:: python
    :name: test_parse_markdown_with_pytestmark

    import pytest
    from pathlib import Path

    from pytest_codeblock.collector import CodeSnippet, group_snippets
    from pytest_codeblock.md import parse_markdown
    from pytest_codeblock.rst import (
        parse_rst,
        resolve_literalinclude_path,
        get_literalinclude_content,
    )

    text = """
    <!-- pytestmark: django_db -->
    ```python name=test_db
    from django.db import models
    ```"""
    snippets = parse_markdown(text)
    assert len(snippets) == 1
    sn = snippets[0]
    # Should include both default and django_db marks
    assert "django_db" in sn.marks
    assert "codeblock" in sn.marks