diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 456e4b7..3d0360b 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -70,6 +70,12 @@ dogfood the tool they describe; a broken example is a failing test. ```` ```{doctest} ```` directive — the finder collects both, plus bare doctest blocks in reST. Use ```` ```console ```` for shell commands at a `$` prompt. +- When the block is written to be pasted, drop the prompt and fence it + as ```` ```{testcode} ````, with ```` ```{testoutput} ```` for what it + prints and `:hide:` for a block that asserts without rendering. A + page's `{testcode}` blocks are named for the page and share one + namespace; a `>>>` block joins them only at document scope, so keep + each page to one form unless it runs at that scope. - `ELLIPSIS` and `NORMALIZE_WHITESPACE` are on globally via `doctest_optionflags`, so variable output can elide with `...` without a per-example flag. diff --git a/docs/modules/doctest_docutils/examples.md b/docs/modules/doctest_docutils/examples.md index 0f55c93..e3b7069 100644 --- a/docs/modules/doctest_docutils/examples.md +++ b/docs/modules/doctest_docutils/examples.md @@ -23,6 +23,46 @@ examples aligned with {class}`doctest_docutils.DocutilsDocTestFinder`. ['md', 'rst'] ``` +A directive keeps a reader's view of the example clean: the rendered page drops +the `# doctest: +NORMALIZE_WHITESPACE` written below, while the run still +applies it, so the two spaces in the printed output match the one below them. + +```{doctest} +>>> print("a b") # doctest: +NORMALIZE_WHITESPACE +a b +``` + +## Blocks that share a namespace + +Blocks naming the same group collect as one test, so the second reads what the +first bound: + +```python +>>> import doctest_docutils +>>> finder = doctest_docutils.DocutilsDocTestFinder() +>>> source = ( +... "```{doctest} intro\n>>> greeting = 'hello'\n```\n" +... "\nProse between the blocks.\n\n" +... "```{doctest} intro\n>>> greeting.upper()\n'HELLO'\n```\n" +... ) +>>> tests = finder.find(source, "example.md") +>>> [(test.name, len(test.examples)) for test in tests] +[('intro', 2)] +``` + +Blocks naming no group keep a namespace each, until you ask for the page: + +```python +>>> import doctest_docutils +>>> page = "```python\n>>> alone = 1\n```\n\n```python\n>>> alone\n1\n```\n" +>>> apart = doctest_docutils.DocutilsDocTestFinder() +>>> [test.name for test in apart.find(page, "example.md")] +['example.md[0]', 'example.md[1]'] +>>> shared = doctest_docutils.DocutilsDocTestFinder(namespace_scope="document") +>>> [test.name for test in shared.find(page, "example.md")] +['example.md'] +``` + ## Finder result names {class}`~doctest_docutils.DocutilsDocTestFinder` names collected examples with diff --git a/docs/modules/doctest_docutils/how-to.md b/docs/modules/doctest_docutils/how-to.md index da5be6f..8fbec58 100644 --- a/docs/modules/doctest_docutils/how-to.md +++ b/docs/modules/doctest_docutils/how-to.md @@ -26,6 +26,135 @@ Pass `-v` for verbose standard-library doctest output: $ python -m doctest_docutils README.md -v ``` +## Let a page build one example across several blocks + +Every block runs against a namespace of its own, so a name bound in one block is +gone by the next and any block can be run on its own. When a page is one session +told in pieces, widen the namespace to the whole page: + +```console +$ python -m doctest_docutils README.md --namespace-scope document +``` + +Blocks that name a group share that group's namespace at either setting, because +naming a group is the author asking for it. A group is named as the directive's +argument, `.. doctest:: intro` in reStructuredText and its `{doctest} intro` +fence in Markdown. `--namespace-scope document` also pools the blocks that name +none. + +One name a group cannot take is the one the page would generate for a block that +declares none — the page's own name at `--namespace-scope document`, the page and +the block's position at the default. Both would answer to one namespace and one +node id, so a page spelling both stops with +{exc}`~doctest_docutils.NamespaceNameCollisionError` rather than merging them. +Rename the group; a page whose every block names one generates nothing to collide +with, so `.. doctest:: README.md` on such a page is only a style choice. + +Sharing costs you the guarantee that a block stands alone: a block that reads an +earlier binding fails when it is read, or run, by itself. See +{ref}`the pytest plugin's how-to ` for the same +choice under pytest, spelled `--doctest-docutils-namespace-scope` there, and for +what sharing costs a test run. + +A shared page is reported as one item by default. Ask for one item per block, +each named for where the block sits, when you want to read the run block by +block: + +```console +$ python -m doctest_docutils README.md --namespace-scope document --namespace-items per-block -v +``` + +A passing page prints nothing without `-v`. What changes without it is a +failure's heading, which names the block — `in README.md[1]` rather than +`in README.md`. + +Nothing here schedules the blocks apart, so they share the namespace either way. +Under pytest they can be scheduled apart, which is what +{ref}`the plugin's how-to ` covers. + +## Write a block a reader is meant to paste + +A `>>>` prompt is for a session a reader reads. When the block is there to be +copied into a file, the prompt is in the way, and an expected-output line beneath +it puts an assertion into whatever the reader pasted. Such a page carries no +prompt at all — and a finder that goes looking for `>>>` cannot see it. + +Write those blocks as `{testcode}`, the directive {mod}`sphinx.ext.doctest` +defines. The body is plain Python, run the way a module body runs, so it takes as +many statements as it likes and a bare expression on the last line prints +nothing: + +```{testcode} +greeting = "hello" +shouted = greeting.upper() +``` + +A `{testcode}` expects to print nothing. When it does print, say what with a +`{testoutput}` block under it: + +```{testcode} +print(shouted) +``` + +```{testoutput} +HELLO +``` + +The two blocks above share a namespace, so the second reads what the first bound. +A `{testcode}` that names no group is named for its page, because sharing the +page is the whole point of the form — a visible block and the hidden one +asserting on it have to meet somewhere. Name a group as the directive's +argument, `{testcode} intro`, to keep two runs on one page apart. + +A `>>>` block is named for its page only where the scope above says so. So at +`--namespace-scope document` the two forms land in the same namespace and read +each other's names, and at the default they do not. Write a page that mixes them +at document scope, or keep each page to one form. + +A page written this way sets up the same way, with no prompt: + +````markdown +```{testsetup} +base = 40 +``` +```` + +A `{testsetup}` and `{testcleanup}` may still be written with prompts, which is +how the rest of these docs write them; the prompt decides how the body is read. +A page holding a `{testcode}` names its unnamed setup for the page too, so the +setup a prompt-free page writes reaches the code it is for. + +That is what lets a page assert without showing its assertions. Mark a block +`:hide:` and it runs while every builder drops it, so the reader meets only the +block written to be pasted: + +````markdown +```{testcode} +:hide: + +assert shouted == "HELLO" +``` +```` + +```{testcode} +:hide: + +assert shouted == "HELLO" +``` + +The page you are reading has that hidden block in it, immediately above. + +`{testoutput}` takes `:options:` for the doctest flags the comparison runs under, +and both directives take `:skipif:`. `:pyversion:` parses, because Sphinx +declares it here, but neither Sphinx nor this runner acts on it outside +`{doctest}` — the page says so when you use it. Guard a block with `:skipif:` +instead. + +The cost of the prompt-free form is that there is no interleaving: one block is +one example, so a `{testoutput}` says what the block prints in total rather than +what any line in it prints. A failure quotes the block entire, so the reader sees +where they are. + ## Compare with stdlib doctest Use the stdlib command when you are checking Python modules or plain text that diff --git a/docs/modules/doctest_docutils/index.md b/docs/modules/doctest_docutils/index.md index e63866e..2bb46d7 100644 --- a/docs/modules/doctest_docutils/index.md +++ b/docs/modules/doctest_docutils/index.md @@ -23,7 +23,8 @@ Run your first documentation doctest from a Markdown page. :::{grid-item-card} How-to :link: how-to :link-type: doc -Choose files, run verbose output, and map the command to stdlib doctest. +Choose files, share a namespace across blocks, run verbose output, and map +the command to stdlib doctest. ::: :::{grid-item-card} Examples diff --git a/docs/modules/pytest_doctest_docutils/fixtures.md b/docs/modules/pytest_doctest_docutils/fixtures.md index 9e5fb8e..635a535 100644 --- a/docs/modules/pytest_doctest_docutils/fixtures.md +++ b/docs/modules/pytest_doctest_docutils/fixtures.md @@ -29,6 +29,13 @@ Then the documentation page can use the helper by name: add(2, 3) ``` +A helper like this one holds nothing, so how long it lives never comes up. +Seeding a *resource* — a server, a connection, a temporary directory — is where +it does, because the fixture's scope decides how long the object a page saved +stays usable. See {ref}`what per-block items cost +` before carrying one across several +blocks of a page. + ## Autouse fixtures Autouse fixtures in a visible `conftest.py` are parsed for `.rst` and `.md` diff --git a/docs/modules/pytest_doctest_docutils/how-to.md b/docs/modules/pytest_doctest_docutils/how-to.md index aaeef3c..79b538b 100644 --- a/docs/modules/pytest_doctest_docutils/how-to.md +++ b/docs/modules/pytest_doctest_docutils/how-to.md @@ -32,6 +32,363 @@ Disable Python-module collection explicitly with $ py.test src/ --no-doctest-docutils-modules ``` +## Let a page build one example across several blocks + +Every block on a page runs against a namespace of its own. A name bound in one +block is gone by the next, which is what lets a reader copy any single block out +of the page and run it. Most pages want that, and it is what you get with no +configuration at all. + +A narrative page often wants the opposite: the prose walks through one session a +piece at a time. Name a group on the blocks that belong together, as the +directive's argument: + +````markdown +```{doctest} intro +>>> greeting = "hello" +``` + +Prose between the two blocks. + +```{doctest} intro +>>> greeting.upper() +'HELLO' +``` +```` + +reStructuredText names a group the same way: + +```rst +.. doctest:: intro + + >>> greeting = "hello" +``` + +A group collects as one item — `page.md::intro` — holding every block that named +it, in page order. A group reaches as far as the page it is written on: the same +name on a second page is a second namespace. + +A block can name several groups at once, comma separated, and joins each of +them — it runs once per group, against that group's namespace. `*` stands for +every group the page declares, which is how you write one setup for all of them: + +```rst +.. testsetup:: * + + >>> import math +``` + +`.. testsetup::` and `.. testcleanup::` run before and after the rest of their +group whatever order the page writes them in, so you can move them out of a +reader's way. Their output is still checked, unlike in Sphinx, so a setup that +raises is reported rather than swallowed. A failing example ends its namespace, +which means that namespace's cleanup does not run. + +Only the directive form can name a group. A plain ```` ```python ```` fence, a +bare fence, an indented block, and a reStructuredText doctest block have nowhere +to write one. To share state between those, widen the page for a single run: + +```console +$ pytest docs/ --doctest-docutils-namespace-scope=document +``` + +Or settle it for the project: + +```ini +[pytest] +doctest_docutils_namespace_scope = document +``` + +Under `document`, the blocks that name no group share one namespace per page, +named for the page. Named groups still partition it — declaring a group is the +author asking for sharing, so a group is its own namespace at either setting. + +### What sharing costs + +A namespace is one item. That is what keeps a shared page correct under +`pytest -n auto`: no worker is ever handed half of a session. It also means: + +- The namespace passes or fails as a single line, and a failure stops the + examples after it unless you pass `--doctest-continue-on-failure`. Stopping + is what keeps a half-built namespace out of the blocks below: they never run. + With `--doctest-continue-on-failure` they do run, against a namespace missing + whatever the failed example would have bound, so one broken line can report + as a first failure followed by a run of {exc}`NameError`s that are not + independent. +- A function-scoped fixture sets up once per namespace instead of once per + block. A page whose blocks each expect a fresh fixture belongs at `block`. + A block gated end to end is the exception: it is its own item, and it is + marked skipped before setup, so it neither shares that setup nor pays for + one of its own. +- A block whose every example is skipped is the one thing a namespace does not + hold. It binds nothing the other blocks could read, so it is lifted back out + and collects as an item of its own — `page.rst::intro[1]` — and still + reports `SKIPPED`. The page collects one item more than it has namespaces + for each such block, which is one more line in `--collect-only` and one more + entry in a JUnit report. +- The report's numbered gutter spans the whole namespace, so the prose between + two blocks shows up as blank numbered lines above the failing prompt. + +One page shape moves further. docutils numbers a bare reStructuredText doctest +block by its *last* line, so that block's examples report lines below where it +sits whether or not it shares anything. Sharing extends the reach of that: a +block written close underneath one is pushed past it, by as many lines as the +two overlap. The gutter still ends on the failing prompt. Write those blocks as +`.. doctest::` directives when the exact line matters — a directive is numbered +by the line it opens on. + +### Keep a node id for every block + +The item a namespace collects as is the thing you can point pytest at. When a +namespace is one item, that reach stops at the page: `--lf` re-runs the whole +page rather than the block that failed, `-k` and `--deselect` cannot single a +block out, a JUnit report names the page, and there is no id to paste back +while you iterate on one block. + +Those reaches are worth most where each block has a namespace of its own, the +default scope. A block that reads what an earlier one bound is a fragment of a +session, so selecting it alone — by id, by `-k`, or by `--lf` after it failed — +runs it without the block it depends on. See +{ref}`what per-block items cost `. + +Take the other trade when you want those, and a fixture per block, more than +you want the single line: + +```console +$ pytest docs/ --doctest-docutils-namespace-items=per-block +``` + +Or settle it for the project: + +```ini +[pytest] +doctest_docutils_namespace_items = per-block +``` + +Every block is an item again, under the id it carries when nothing is shared — +`page.md::page.md[1]`, or `page.rst::intro[1]` inside a group — and the blocks +of one namespace are handed the *same* globals rather than a copy each. A +function-scoped fixture is back to setting up once per block, which is what a +project promising a fresh fixture for every example needs. + +The two settings answer different questions, and both still apply: the scope +says what shares a namespace, this one says whether sharing costs the blocks +their ids. At the default scope no page state is shared either way — but +selecting `per-block` is still the opt-in to a live shared mapping, because a +page that declares a group shares one whatever the scope. + +A run that keeps a node id for every block says so in its header, so you can +tell from the report which one you got. The scope rides along on the same line: + +```text +doctest-docutils: namespace items: per-block, namespace scope: document +``` + +A run that only widens the scope is not announced; the default layout reports +nothing, so the header of a project that never touched this setting reads as it +always has. + +(pytest_doctest_docutils-per-block-costs)= + +### What per-block items cost + +A live namespace is a Python object, so it neither crosses a process boundary +nor outlives the fixtures that filled it. The cost shows up in five places. + +Under `pytest-xdist`, two blocks of one namespace landing on different workers +would leave the second reading a namespace the first never built. Which +scheduler you get decides whether that can happen, and `-n` on its own does not +choose one: `pytest-xdist` fills it in with `--dist load`, which distributes by +item. + +So the plugin fills it in first, with file-level scheduling. `pytest docs/ -n +auto` keeps every page on one worker and your shared pages pass: + +```console +$ pytest docs/ -n auto +``` + +Nothing about the run changes otherwise — the node ids stay the ones the layout +collects, and `-v` names the scheduler that ran if you want to see it. File +level is as fine-grained as this can go. `loadgroup` would suit the +`xdist_group` marker the plugin emits per namespace, but that group reaches a +scheduler through a node-id suffix the *worker* writes, from the worker's own +`--dist` value, so no substitute made on the controller can use it. + +Name a scheduler yourself and it is yours. `--dist loadfile`, `--dist +loadgroup`, `--dist loadscope` and `--dist each` all keep a namespace whole, +and `loadgroup` is the finer grained of the two obvious ones: `loadfile` pins a +whole file to one worker, while the group is the file plus the namespace, so a +page holding several namespaces still spreads. + +```console +$ pytest docs/ -n auto --dist loadgroup +``` + +Ask for `--dist load` or `--dist worksteal` — by flag or through `addopts` — +and the session stops instead, naming the page it would have split. Overruling +a scheduler you asked for by name would be the plugin deciding it knows better; +reporting a page that is only wrong because of how it was scheduled would be +worse: + +```text +ERROR: doctest_docutils_namespace_items = per-block can hand a namespace's +blocks one globals mapping between them — a page declaring a group does, +whatever the scope — and a mapping cannot cross processes. --dist worksteal +hands a file's items to whichever worker is free, so it can send docs/page.md's +blocks to different workers. Run with --dist loadgroup or --dist loadfile, or +set doctest_docutils_namespace_items = merged. Dropping --dist leaves -n free +to keep each page on one worker. +``` + +That reads the run, not the setting. A run holding no page whose blocks split — +a suite of Python tests, a single-block page, `--collect-only`, one worker — +keeps its workers whatever it asked for, so carrying the layout in your ini +never costs `-n` to a session that has no namespace to protect. + +A test-retry plugin repeats a single item, which a live namespace cannot +survive. Under `merged` a retry re-runs the namespace from its first block, so +the run rebuilds what it needs and a real failure stays a failure. Under +`per-block` the retry re-runs only the block that failed, against the mapping +that block already changed — so an example whose expectation happens to come +true on the second attempt would be reported as a pass. There is no way to +rebuild the namespace for one block, so the repeat is refused instead: + +```text +Failed: page.rst::demo[1] was run twice against a namespace laid out per +block. A repeated block runs against the globals it already changed, so its +result cannot be trusted. Drop --reruns (and anything else that repeats an +item), or set doctest_docutils_namespace_items = merged, which re-runs a +namespace from its first block. +``` + +A block that passes first time is never repeated, so a green run under +`--reruns` is unaffected. + +Running one block by its id has the same shape: `pytest page.md::page.md[1]` +runs that block and nothing else, so a block reading a name an earlier one +bound reports the `NameError` it earns. `-k`, `--deselect` and `--lf` +reach a block the same way and cost the same thing — a `--lf` re-run of a +failure in a shared page reports the missing binding rather than the diff you +were chasing. That is inherent to running a fragment of a session, not +something the setting can hide. + +A namespace shares the mapping, not the lifetime of what a fixture put in it. +Each block is its own item, so a function-scoped fixture tears down between +blocks. The name that fixture fills is rebound fresh for the next block, but a +name a block derived from it is not: it still holds the finalized object, and a +finalized object usually answers rather than raising. A page reads a plausible +wrong value — a cached attribute that looks right beside a connection that is +already closed — with nothing in the report to say so. + +Give a fixture a page carries across its blocks `scope="module"`. A page is +what module scope means here — the collector for a `.md` or `.rst` page is a +{class}`pytest.Module`, as pytest's own text-doctest collector is — so the +fixture sets up once for the page and tears down when the page ends, and the +object a block saves stays the object the next block reads. + +Name `module` rather than reaching for anything wider. `class` has no node to +attach to on a page, so it silently falls back to setting up per block, and +`package` anchors to the directory holding the `conftest.py` that *defines* the +fixture, and only when that directory is an importable package — a fixture +defined further up resolves to the whole run however the page's own directory +looks. Only `function`, `module` and `session` mean what they say here. + +Two things follow from a page being a module for scope and nothing else. A +module-scoped fixture cannot request a function-scoped one, so reaching for +{ref}`tmp_path ` stops the page with pytest's `ScopeMismatch` +— ask for `tmp_path_factory` instead. And no +module object stands behind a page, +so `request.module` is `None`; a `conftest.py` shared with `.py` tests that +reads it works on those and breaks here. `request.path` names the page. + +Across worker processes it is a page per worker, not a page per run. Leaving +`-n` unadorned keeps a page whole, and so does `--dist loadfile`; asking for +`--dist loadgroup` while every block is its own namespace groups by block +instead, which hands one page to two workers and sets the fixture up in each. + +And because the blocks share one mapping, they share whatever lives in it — +including `__future__` flags, which {mod}`doctest` derives from the namespace at +run time. A `from __future__ import ...` in one block is in force for the rest +of its namespace. + +## Set options for a whole block + +A `{doctest}` directive can carry the flags its examples would otherwise repeat. +`:options:` takes the same names as the inline `# doctest:` comment, and an +example that writes its own flag wins over the directive's: + +```rst +.. doctest:: + :options: +ELLIPSIS + + >>> print("hello world") + hello ... +``` + +`:skipif:` skips a block on a condition the page works out for itself, so you +don't have to write `+SKIP` by hand for one interpreter or one platform. It +takes a Python expression, which is **evaluated** when the page is read, and a +true result marks the block `+SKIP`: + +```rst +.. doctest:: + :skipif: sys.version_info < (3, 12) + + >>> "a modern interpreter" + 'a modern interpreter' +``` + +That is the same flag `:options: +SKIP` sets, so the two spellings report +alike: the block still collects, still counts, and still answers to its own +node id. The reason pytest prints is the one it prints for any skipped +example, which names the flag rather than your condition: + +```console +$ pytest page.rst -rs +``` + +```text +SKIPPED [1] page.rst: page.rst:6: every example skipped +1 skipped +``` + +Where `:options:` sets defaults an example can override, a condition is a gate +it cannot: an example writing `# doctest: -SKIP` inside a gated block stays +skipped. Sphinx drops such a block before reading it at all, and an example that +could reopen it would run on exactly the interpreter or platform the condition +named. The expression sees `sys` and the globals the document starts +with, not anything the page's own examples bound — it is answered while the +page is being read, before any of them run. Naming anything else stops the page +with {exc}`~doctest_docutils.SkipifExpressionError`, which reports the file, +line, and expression to go fix. + +Reading a page is all `--collect-only` does, so listing a page's items runs its +`:skipif:` expressions; keep them free of side effects. + +Skipping one block of a group leaves the group's other blocks running, and the +skipped one still reports. Because a block with nothing left to run binds no +name the group could read, it does not need to share the group's item: it +collects as one of its own, named for the group and for the block's position on +the page, counted from zero across every doctest block. The second block of +`page.rst` in group `intro` is `page.rst::intro[1]`; on a page merged by +`--doctest-docutils-namespace-scope=document` it is `page.rst::page.rst[1]`, +which is the name that block already carries when every block keeps its own +namespace. So the node id a reader pastes back to pytest does not move with the +scope. + +Two cases stay where they are. A block whose examples are only *partly* +skipped is not a skipped block — it has something left to run, and it reports +with its namespace like any other item. And when *every* block of a namespace +is gated, there is nothing for them to be silent beside: the namespace keeps +them all and reports skipped once, as one item, rather than once per block. + +A skipped block is still parsed, so malformed doctest source in one reports +as an error rather than passing unnoticed — the same as for `:options: +SKIP`. + +`:skipif:` works the same on `.. testsetup::` and `.. testcleanup::`, which +declare the option too. + ## Hide a setup line from rendered docs Mark a prompt line with `# doctest: +HIDE` when your suite should run it but a @@ -48,10 +405,9 @@ The marker changes nothing about the run: the line still executes and its output is still checked. It only tags the line so a documentation renderer can drop it from the rendered page while pytest keeps testing it from source. -The plugin registers the marker as pytest configures, so `# doctest: +HIDE` -parses in `.rst`, `.md`, and Python-module doctests. The standalone -`python -m doctest_docutils` command does not register it, so use the marker -when you run examples through pytest. +Importing {mod}`doctest_docutils` registers the marker, so `# doctest: +HIDE` +parses in `.rst`, `.md`, and Python-module doctests under pytest and under the +standalone `python -m doctest_docutils` command alike. ## Keep pytest's built-in doctest plugin disabled diff --git a/docs/modules/pytest_doctest_docutils/index.md b/docs/modules/pytest_doctest_docutils/index.md index 309c68d..f6c7407 100644 --- a/docs/modules/pytest_doctest_docutils/index.md +++ b/docs/modules/pytest_doctest_docutils/index.md @@ -23,7 +23,8 @@ Run documentation doctests through pytest. :::{grid-item-card} How-to :link: how-to :link-type: doc -Collect docs, Python modules, and option-flagged examples. +Collect docs, Python modules, and option-flagged examples, and let a page +build one session across several blocks. ::: :::{grid-item-card} Fixtures diff --git a/pyproject.toml b/pyproject.toml index 36b38c1..d186ce5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,7 @@ dev = [ "pytest-rerunfailures", "pytest-mock", "pytest-watcher", + "pytest-xdist", # Coverage "codecov", "coverage", @@ -82,6 +83,7 @@ testing = [ "pytest-rerunfailures", "pytest-mock", "pytest-watcher", + "pytest-xdist", ] coverage =[ "codecov", diff --git a/src/doctest_docutils.py b/src/doctest_docutils.py index f8f2cde..5236639 100644 --- a/src/doctest_docutils.py +++ b/src/doctest_docutils.py @@ -2,14 +2,14 @@ from __future__ import annotations +import copy import doctest -import linecache import logging import os import pathlib -import pprint import re import sys +import types import typing as t import docutils @@ -21,8 +21,6 @@ from docutils_compat import findall if t.TYPE_CHECKING: - import types - from docutils.nodes import Node, TextElement logger = logging.getLogger(__name__) @@ -34,6 +32,234 @@ # Allow optional leading whitespace before doctest directive comments. doctestopt_re = re.compile(r"[ \t]*#\s*doctest:.+$", re.MULTILINE) +#: How wide a namespace the blocks of one document share when they name no +#: group. +NamespaceScope = t.Literal["block", "document"] + +#: Accepted :data:`NamespaceScope` names, narrowest first. +NAMESPACE_SCOPES: tuple[NamespaceScope, ...] = ("block", "document") + +#: Scope used when a caller names none: every ungrouped block starts empty. +DEFAULT_NAMESPACE_SCOPE: NamespaceScope = "block" + +#: Whether the blocks of one namespace become a single test or stay one test +#: each, sharing the globals mapping between them. +NamespaceItems = t.Literal["merged", "per-block"] + +#: Accepted :data:`NamespaceItems` names, fewest tests first. +NAMESPACE_ITEMS: tuple[NamespaceItems, ...] = ("merged", "per-block") + +#: Layout used when a caller names none: a namespace is one test. +DEFAULT_NAMESPACE_ITEMS: NamespaceItems = "merged" + +#: Group a ``.. doctest::`` written without an argument lands in, as in +#: :mod:`sphinx.ext.doctest`. It means the author named no group. +_DEFAULT_GROUP = "default" + +#: Group name meaning "every group this document declares", as in +#: :mod:`sphinx.ext.doctest`. It resolves only once the page has been read. +_WILDCARD_GROUP = "*" + +#: Block types that keep :mod:`sphinx.ext.doctest`'s implicit ``default`` +#: group: the visible block a reader pastes and the hidden one asserting on it +#: have to share a namespace without either of them naming a group. +_GROUPED_BLOCK_TYPES = frozenset({"testcode", "testoutput"}) + +#: Block types that join the implicit ``default`` group only once a page holds +#: a ``{testcode}``. Sphinx puts an unnamed ``{testsetup}`` in that group +#: always; gp-libs gives a page's prompt blocks namespaces of their own, and an +#: unnamed setup has always followed them there. Widening it unconditionally +#: would take the setup away from those blocks, so it widens only for the pages +#: that need it — the ones written in Sphinx's prompt-free style. +_PHASE_BLOCK_TYPES = frozenset({"testsetup", "testcleanup"}) + +#: Matches the ``>>>`` opening an interactive example, which tells a +#: ``{testsetup}`` written for :class:`doctest.DocTestParser` apart from one +#: written the way :mod:`sphinx.ext.doctest` writes it. +_PROMPT_RE = re.compile(r"^[ \t]*>>>(?:[ \t]|$)", re.MULTILINE) + +#: ``HIDE`` marks a prompt that rendered documentation drops and a test run +#: keeps. It changes no output check, but a page carrying it fails to parse +#: wherever the name is unregistered, so registration happens on import rather +#: than at any one entry point's setup: ``python -m doctest_docutils`` reaches +#: no further than this module, and pytest's own ``DoctestModule`` parses .py +#: docstrings without ever consulting the plugin's flag lookup. +_HIDE_FLAG = doctest.register_optionflag("HIDE") + + +class NamespaceScopeError(ValueError): + """Raised when a namespace scope is not one of :data:`NAMESPACE_SCOPES`. + + Examples + -------- + >>> print(NamespaceScopeError("per-file")) + Unknown namespace scope: 'per-file'. Expected one of: block, document + """ + + def __init__(self, value: str) -> None: + super().__init__( + f"Unknown namespace scope: {value!r}. " + f"Expected one of: {', '.join(NAMESPACE_SCOPES)}", + ) + + +class NamespaceItemsError(ValueError): + """Raised when a namespace layout is not one of :data:`NAMESPACE_ITEMS`. + + Examples + -------- + >>> print(NamespaceItemsError("one-each")) + Unknown namespace items: 'one-each'. Expected one of: merged, per-block + """ + + def __init__(self, value: str) -> None: + super().__init__( + f"Unknown namespace items: {value!r}. " + f"Expected one of: {', '.join(NAMESPACE_ITEMS)}", + ) + + +class NamespaceNameCollisionError(ValueError): + """Raised when a declared group takes a name the page generates for itself. + + A page generates names of its own: for a block that declares no group — + the page itself at ``"document"`` scope, the page and the block's position + at ``"block"`` scope — and for a gated block lifted out of the namespace + it was declared in. A group spelling one of those asks for a name the page + has already given away, and the two would run as one: state crossing the + partition the author drew, under a single node id. Neither meaning can be + kept over the other, so the page stops rather than merging them quietly. + + Parameters + ---------- + group : str + Group the page declared. + document_name : str + Base name of the document, without its directory. + generated_for : str + What the page generates the same name for. + + Examples + -------- + >>> print( + ... NamespaceNameCollisionError( + ... "page.rst", "page.rst", "a block declaring none at 'document' scope" + ... ) + ... ) + page.rst: group 'page.rst' takes the name this page generates for a block + declaring none at 'document' scope, so the two would share state and one + node id. Rename the group. + + A gated block lifted out of its group is named the same way, so a group + can take that name too: + + >>> print( + ... NamespaceNameCollisionError( + ... "alpha[1]", "page.rst", "a block lifted out of 'alpha'" + ... ) + ... ) + page.rst: group 'alpha[1]' takes the name this page generates for a block + lifted out of 'alpha', so the two would share state and one node id. + Rename the group. + """ + + def __init__(self, group: str, document_name: str, generated_for: str) -> None: + super().__init__( + f"{document_name}: group {group!r} takes the name this page" + f" generates for {generated_for}, so the two would share state and" + " one node id. Rename the group.", + ) + + +class SkipifExpressionError(ValueError): + """Raised when a block's ``:skipif:`` expression cannot be evaluated. + + Examples + -------- + >>> error = NameError("name 'platform' is not defined") + >>> print(SkipifExpressionError("platform.system()", "page.rst", 4, error)) + page.rst:4: :skipif: 'platform.system()' failed: name 'platform' is not defined + """ + + def __init__( + self, + expression: str, + filename: str, + line: int, + error: BaseException, + ) -> None: + super().__init__( + f"{filename}:{line}: :skipif: {expression!r} failed: {error}", + ) + + +def _parse_namespace_scope(value: str) -> NamespaceScope: + """Return `value` as a :data:`NamespaceScope`, rejecting anything else. + + Parameters + ---------- + value : str + Scope name to validate. + + Returns + ------- + NamespaceScope + The scope, unchanged. + + Raises + ------ + NamespaceScopeError + If `value` names no known scope. + + Examples + -------- + >>> _parse_namespace_scope("document") + 'document' + + >>> try: + ... _parse_namespace_scope("per-file") + ... except NamespaceScopeError as exc: + ... print(exc) + Unknown namespace scope: 'per-file'. Expected one of: block, document + """ + if value not in NAMESPACE_SCOPES: + raise NamespaceScopeError(value) + return value + + +def _parse_namespace_items(value: str) -> NamespaceItems: + """Return `value` as a :data:`NamespaceItems`, rejecting anything else. + + Parameters + ---------- + value : str + Layout name to validate. + + Returns + ------- + NamespaceItems + The layout, unchanged. + + Raises + ------ + NamespaceItemsError + If `value` names no known layout. + + Examples + -------- + >>> _parse_namespace_items("per-block") + 'per-block' + + >>> try: + ... _parse_namespace_items("one-each") + ... except NamespaceItemsError as exc: + ... print(exc) + Unknown namespace items: 'one-each'. Expected one of: merged, per-block + """ + if value not in NAMESPACE_ITEMS: + raise NamespaceItemsError(value) + return value + def is_allowed_version(version: str, spec: str) -> bool: """Check `spec` satisfies `version` or not. @@ -53,6 +279,196 @@ def is_allowed_version(version: str, spec: str) -> bool: return Version(version) in SpecifierSet(spec) +class _ExecSource(str): + r"""Example source that runs as a module body, not as one prompt. + + :meth:`doctest.DocTestRunner.run` compiles every example in ``"single"`` + mode, which rejects a body of more than one statement and echoes a bare + expression. A ``{testcode}`` body is plain Python and neither applies, so + it needs ``"exec"``. + + The mode rides on the source because the source is the only part of an + example the compile call is handed: marking it here keeps the choice on + the data, where :func:`copy.copy` and the merge carry it along, instead of + on a mode flag some runner has to be holding at the right moment. + + Examples + -------- + >>> source = _ExecSource("value = 41\n") + >>> isinstance(source, str), source.splitlines() + (True, ['value = 41']) + """ + + +def _compile_source( + source: str, + filename: str, + mode: str, + flags: int = 0, + dont_inherit: bool = False, + optimize: int = -1, +) -> types.CodeType: + r"""Compile one example, letting an :class:`_ExecSource` overrule `mode`. + + Stands in for the built-in :func:`compile` inside the runner loop + :func:`_exec_mode_run` builds. Every other source compiles exactly as the + runner asked, so a ``>>>`` example keeps the echo it was written for. + + Parameters + ---------- + source : str + Example source. An :class:`_ExecSource` asked for ``"single"`` + compiles in ``"exec"``. + filename : str + Name the compiled code reports itself under. + mode : str + Mode the caller asked for. + flags : int + ``__future__`` and compiler flags. + dont_inherit : bool + Whether to ignore the calling frame's ``__future__`` flags. + optimize : int + Optimization level. + + Returns + ------- + types.CodeType + Compiled example. + + Examples + -------- + >>> import contextlib, io + >>> def run(source): + ... captured = io.StringIO() + ... with contextlib.redirect_stdout(captured): + ... exec(_compile_source(source, "", "single"), {"value": 41}) + ... return captured.getvalue() + + A bare expression echoes under the mode the runner asks for, and stays + quiet once the source says it is a ``{testcode}`` body: + + >>> run("value\n") + '41\n' + >>> run(_ExecSource("value\n")) + '' + + ``"single"`` takes one statement; a ``{testcode}`` body takes as many as + it likes: + + >>> namespace = {} + >>> body = _ExecSource("first = 1\nsecond = first + 1\n") + >>> exec(_compile_source(body, "", "single"), namespace) + >>> namespace["second"] + 2 + """ + if mode == "single" and isinstance(source, _ExecSource): + mode = "exec" + # A variable ``mode`` widens the built-in's return type; the runner never + # asks for an AST, so the answer is always a code object. + return t.cast( + types.CodeType, + compile(source, filename, mode, flags, dont_inherit, optimize), + ) + + +def _exec_mode_run() -> types.FunctionType | None: + r"""Return CPython's runner loop reading :func:`_compile_source` as ``compile``. + + ``DocTestRunner.__run`` hard-codes ``"single"`` and resolves ``compile`` + as a global of :mod:`doctest`, which is why :mod:`sphinx.ext.doctest` + rebinds ``doctest.compile`` for the whole process. gp-libs ships as a + ``pytest11`` plugin loaded into every test session, so it rebinds the name + for one function object instead: the code object is CPython's, unread and + uncopied, and only the globals mapping it looks names up in differs. + + The seam is two facts about a private method, so it is checked rather than + assumed. gp-libs is loaded into every session that has it installed, and an + interpreter that moved the method must not take down the test runs of people + who never wrote a ``{testcode}``: a missing seam leaves CPython's own loop + in place and logs why. What that costs is ``{testcode}`` itself, which falls + back to ``single`` mode and fails on any body of more than one statement — + so the seam is also pinned by a test, where the loud failure belongs. + + Returns + ------- + types.FunctionType or None + Copy of CPython's runner loop, still unbound. `None` where this + interpreter does not resolve ``compile`` the way the loop needs. + + Examples + -------- + >>> run = _exec_mode_run() + >>> run.__code__ is doctest.DocTestRunner._DocTestRunner__run.__code__ + True + >>> run.__globals__["compile"] is _compile_source + True + + :mod:`doctest` itself keeps the built-in: + + >>> "compile" in vars(doctest) + False + """ + original = getattr(doctest.DocTestRunner, "_DocTestRunner__run", None) + # A closure would need its cells rebuilt; a loop that stopped reading + # ``compile`` as a global would silently ignore the swap. + if ( + original is None + or original.__code__.co_freevars + or "compile" not in original.__code__.co_names + ): + logger.error( + "doctest runner seam is missing; testcode blocks fall back to " + "single-statement mode", + extra={"doctest_block_type": "testcode"}, + ) + return None + return types.FunctionType( + original.__code__, + {**vars(doctest), "compile": _compile_source}, + original.__name__, + ) + + +class _ExecModeRunnerMixin: + r"""Runner mixin that runs a ``{testcode}`` body the way Sphinx does. + + Mix in ahead of :class:`doctest.DocTestRunner` or + :class:`doctest.DebugRunner`. Everything else about the run — reporting, + the debugger, ``SKIP``, ``FAIL_FAST`` — is CPython's own loop. + + Examples + -------- + >>> import io + >>> example = doctest.Example("value = 41\nvalue\n", "") + >>> example.source = _ExecSource(example.source) + >>> test = doctest.DocTest([example], {}, "page.md", "page.md", 0, None) + >>> _ExecModeRunner().run(test, out=io.StringIO().write) + TestResults(failed=0, attempted=1) + + The stock runner rejects the same example, and says why: + + >>> report = io.StringIO() + >>> doctest.DocTestRunner().run(test, out=report.write) + TestResults(failed=1, attempted=1) + >>> "multiple statements" in report.getvalue() + True + """ + + # Left unset where this interpreter has no seam, so the mixin inherits + # CPython's loop instead of shadowing it with nothing. + if (_exec_mode_run_override := _exec_mode_run()) is not None: + _DocTestRunner__run = _exec_mode_run_override + del _exec_mode_run_override + + +class _ExecModeRunner(_ExecModeRunnerMixin, doctest.DocTestRunner): + """:class:`doctest.DocTestRunner` that honours a ``{testcode}`` body.""" + + +class _ExecModeDebugRunner(_ExecModeRunnerMixin, doctest.DebugRunner): + """:class:`doctest.DebugRunner` that honours a ``{testcode}`` body.""" + + class TestDirective(Directive): """Base class for doctest-related directives.""" @@ -76,7 +492,6 @@ def run(self) -> list[Node]: code = "\n".join(self.content) test = None - logger.debug(f"directive run: self.name {self.name}") if self.name == "doctest": if "" in code: # convert s to ordinary blank lines for presentation @@ -95,7 +510,7 @@ def run(self) -> list[Node]: if self.arguments: groups = [x.strip() for x in self.arguments[0].split(",")] else: - groups = ["default"] + groups = [_DEFAULT_GROUP] node = nodetype(code, code, testnodetype=self.name, groups=groups) self.set_source_info(node) if test is not None: @@ -103,8 +518,13 @@ def run(self) -> list[Node]: node["test"] = test if self.name == "doctest": node["language"] = "pycon3" + elif self.name == "testcode": + node["language"] = "python" + elif self.name == "testoutput": + # don't try to highlight output + node["language"] = "none" node["options"] = {} - if self.name in ("doctest") and "options" in self.options: + if self.name in {"doctest", "testoutput"} and "options" in self.options: # parse doctest-like output comparison flags option_strings = self.options["options"].replace(",", " ").split() for option in option_strings: @@ -123,11 +543,23 @@ def run(self) -> list[Node]: continue flag = doctest.OPTIONFLAGS_BY_NAME[option[1:]] node["options"][flag] = option[0] == "+" + if self.name in _GROUPED_BLOCK_TYPES and "pyversion" in self.options: + # sphinx.ext.doctest declares :pyversion: on these directives and + # acts on it only for ``doctest``. Diverging either way costs more + # than it buys: honouring it would pass a page Sphinx fails, and + # rejecting it would fail a page Sphinx renders. Say so instead. + self.state.document.reporter.warning( + f"'pyversion' has no effect on '{self.name}'; " + "guard the block with ':skipif:' instead.", + line=self.lineno, + ) if self.name == "doctest" and "pyversion" in self.options: try: spec = self.options["pyversion"] python_version = ".".join([str(v) for v in sys.version_info[:3]]) - if not is_allowed_version(spec, python_version): + # Sphinx, which this was ported from, spells the signature + # (spec, version); gp-libs reversed it. The version goes first. + if not is_allowed_version(python_version, spec): flag = doctest.OPTIONFLAGS_BY_NAME["SKIP"] node["options"][flag] = True # Skip the test except InvalidSpecifier: @@ -141,6 +573,7 @@ def run(self) -> list[Node]: node["trim_flags"] = True elif "no-trim-doctest-flags" in self.options: node["trim_flags"] = False + logger.debug("parsed directive", extra={"doctest_block_type": self.name}) return [node] @@ -168,6 +601,38 @@ class DoctestDirective(TestDirective): } +class TestcodeDirective(TestDirective): + """Test code directive. + + Its body is plain Python a reader can select and paste: no prompt, and no + expected output unless a :class:`TestoutputDirective` follows it. + """ + + option_spec: t.ClassVar = { + "hide": directives.flag, + "no-trim-doctest-flags": directives.flag, + "pyversion": directives.unchanged_required, + "skipif": directives.unchanged_required, + "trim-doctest-flags": directives.flag, + } + + +class TestoutputDirective(TestDirective): + """Test output directive. + + Says what the :class:`TestcodeDirective` block above it prints. + """ + + option_spec: t.ClassVar = { + "hide": directives.flag, + "no-trim-doctest-flags": directives.flag, + "options": directives.unchanged, + "pyversion": directives.unchanged_required, + "skipif": directives.unchanged_required, + "trim-doctest-flags": directives.flag, + } + + class MockTabDirective(TestDirective): """Mock tab directive.""" @@ -185,6 +650,8 @@ def setup() -> dict[str, t.Any]: directives.register_directive("testsetup", TestsetupDirective) directives.register_directive("testcleanup", TestcleanupDirective) directives.register_directive("doctest", DoctestDirective) + directives.register_directive("testcode", TestcodeDirective) + directives.register_directive("testoutput", TestoutputDirective) # Third party mock directive: sphinx-inline-tabs @ 2022.01.02.beta11 directives.register_directive("tab", MockTabDirective) @@ -197,7 +664,14 @@ def setup() -> dict[str, t.Any]: parser = doctest.DocTestParser() _DIRECTIVES_READY = False -_REQUIRED_DIRECTIVES = ("doctest", "testsetup", "testcleanup", "tab") +_REQUIRED_DIRECTIVES = ( + "doctest", + "testsetup", + "testcleanup", + "testcode", + "testoutput", + "tab", +) def _directive_registry() -> dict[str, t.Any]: @@ -216,6 +690,921 @@ def _ensure_directives_registered() -> None: _DIRECTIVES_READY = True +def _node_groups(node: nodes.Element) -> list[str]: + """Return every doctest group a block declares, in the order written. + + Only the directive forms carry a ``groups`` attribute: ``.. doctest:: name`` + in reStructuredText and the ``{doctest} name`` fence in Markdown. Declaring + a group is the author asking blocks to share a namespace, so it holds at + every :data:`NamespaceScope`. + + ``default`` is the group a directive lands in when its author wrote no + argument, so it names nothing the author chose. A block that wants it is + asking for its page, which :func:`_page_scoped` answers. + + Parameters + ---------- + node : docutils.nodes.Element + Node a doctest was collected from. + + Returns + ------- + list[str] + Group names, empty for a block that named none. + + Examples + -------- + >>> from docutils import nodes + >>> _node_groups(nodes.literal_block("", "", groups=["intro"])) + ['intro'] + + A comma list names every group the block joins: + + >>> _node_groups(nodes.literal_block("", "", groups=["alpha", "beta"])) + ['alpha', 'beta'] + + A directive written without an argument names no group, and a plain fence + or a reStructuredText doctest block has nowhere to write one: + + >>> _node_groups(nodes.literal_block("", "", groups=["default"])) + [] + >>> _node_groups(nodes.doctest_block("", "")) + [] + + A ``{testcode}`` is read the same way, whatever it lands in: + + >>> _node_groups( + ... nodes.literal_block( + ... "", "", testnodetype="testcode", groups=["default"] + ... ) + ... ) + [] + """ + groups = node.get("groups") + if not isinstance(groups, list): + return [] + names = [str(group).strip() for group in groups] + return [name for name in names if name and name != _DEFAULT_GROUP] + + +def _page_scoped(node: nodes.Element, grouped_types: frozenset[str]) -> bool: + """Say whether a block shares its page whatever the scope says. + + A ``{testcode}`` exists so a visible block and the ``:hide:`` block + asserting on it read one namespace, which is the page. Sphinx spells that + the ``default`` group; here it is the name an ungrouped block already + carries at ``"document"`` scope, so the two forms meet on one page instead + of on a name no author wrote. + + Parameters + ---------- + node : docutils.nodes.Element + Node a doctest was collected from. + grouped_types : frozenset[str] + Block types that share their page. See :data:`_PHASE_BLOCK_TYPES` for + why a page can widen it. + + Returns + ------- + bool + `True` when the block shares its page. + + Examples + -------- + >>> from docutils import nodes + >>> code = nodes.literal_block("", "", testnodetype="testcode") + >>> _page_scoped(code, _GROUPED_BLOCK_TYPES) + True + + A prompt block keeps the scope the run asked for: + + >>> _page_scoped(nodes.doctest_block("", ""), _GROUPED_BLOCK_TYPES) + False + + A ``{testsetup}`` shares the page only where the page asked: + + >>> setup = nodes.literal_block("", "", testnodetype="testsetup") + >>> _page_scoped(setup, _GROUPED_BLOCK_TYPES) + False + >>> _page_scoped(setup, _GROUPED_BLOCK_TYPES | _PHASE_BLOCK_TYPES) + True + """ + return node.get("testnodetype") in grouped_types + + +def _namespace_name( + group: str | None, + scope: NamespaceScope, + document_name: str, + index: int, +) -> str: + """Return the name of the namespace a block runs in. + + The name is also the key blocks merge under and the pytest node id they + collect as, so two blocks share a namespace exactly when they share a name. + + Parameters + ---------- + group : str or None + Group the block declared, from :func:`_node_groups`. + scope : NamespaceScope + Scope chosen for blocks that declared no group. + document_name : str + Base name of the document, without its directory. + index : int + Position of the block in the document, counted from zero. + + Returns + ------- + str + Namespace name. + + Examples + -------- + A declared group names its own namespace at every scope: + + >>> _namespace_name("intro", "block", "page.md", 0) + 'intro' + >>> _namespace_name("intro", "document", "page.md", 0) + 'intro' + + A block that declared none is named for its position, or for the page when + the document shares one namespace: + + >>> _namespace_name(None, "block", "page.md", 3) + 'page.md[3]' + >>> _namespace_name(None, "document", "page.md", 3) + 'page.md' + """ + if group is not None: + return group + if scope == "document": + return document_name + return f"{document_name}[{index}]" + + +def _node_line(node: nodes.Element) -> int: + """Return the file line a block reports itself against. + + docutils leaves ``line`` unset on a doctest block nested inside a + directive, a list item, or a block quote. The node holding it still carries + one, which puts the block within a few lines of its prompts instead of at + the top of the page. + + Parameters + ---------- + node : docutils.nodes.Element + Node the block was collected from. + + Returns + ------- + int + Line to position and report the block against, ``0`` when nothing up + the tree carries one. + + Examples + -------- + >>> from docutils import nodes + >>> block = nodes.doctest_block("", "") + >>> block.line = 6 + >>> _node_line(block) + 6 + + A block the parser left unpositioned borrows the line of whatever holds it: + + >>> nested = nodes.doctest_block("", "") + >>> admonition = nodes.note("", nested) + >>> admonition.line = 7 + >>> _node_line(nested) + 7 + + >>> _node_line(nodes.doctest_block("", "")) + 0 + """ + current: Node | None = node + while current is not None: + if current.line: + return int(current.line) + current = current.parent + return 0 + + +def _skipif(expression: str, globs: dict[str, t.Any]) -> bool: + """Return whether a block's ``:skipif:`` expression asks to skip the block. + + The expression is Python source read from the document and **evaluated**, + the contract :mod:`sphinx.ext.doctest` documents. It sees a copy of the + globals the document starts with — the `globs` handed to + :meth:`DocutilsDocTestFinder.find` — and nothing the page's own examples + bound, because it is answered while the page is being read, before any of + them run. + + Sphinx seeds that namespace from its ``doctest_global_setup`` setting; + gp-libs has no such setting, so it binds :mod:`sys` instead unless the + document bound the name itself. Without it the option could not answer the + two questions it is written for, the Python version and the platform. + + Parameters + ---------- + expression : str + Python expression from the directive's ``:skipif:`` option. + globs : dict[str, typing.Any] + Globals the document starts with. + + Returns + ------- + bool + Whether the block's examples are marked :data:`doctest.SKIP`. + + Examples + -------- + >>> _skipif("True", {}) + True + >>> _skipif("False", {}) + False + + :mod:`sys` answers for the interpreter running the page: + + >>> _skipif("sys.version_info < (3, 10)", {}) + False + + The document's starting globals are in scope, and win: + + >>> _skipif("greeting == 'hello'", {"greeting": "hello"}) + True + """ + # eval is the option's contract, not an oversight: sphinx.ext.doctest + # defines :skipif: as a Python expression. The expression comes from a + # document the project already runs as tests, so it grants no reach the + # page's own examples do not have. It is evaluated while the document is + # collected, which means ``--collect-only`` runs it too. + return bool(eval(expression, {"sys": sys, **globs})) + + +def _gated(node: nodes.Element, filename: str, globs: dict[str, t.Any]) -> bool: + """Return whether a block's ``:skipif:`` asks for it to be passed over. + + Parameters + ---------- + node : docutils.nodes.Element + Node a block was collected from. + filename : str + Path a failed expression is reported against. + globs : dict[str, typing.Any] + Globals the document starts with. + + Returns + ------- + bool + Whether the block is gated. A block that wrote no ``:skipif:`` is not. + + Raises + ------ + SkipifExpressionError + If the expression cannot be evaluated. + + Examples + -------- + >>> from docutils import nodes + >>> _gated(nodes.literal_block("", ""), "page.rst", {}) + False + >>> _gated(nodes.literal_block("", "", skipif="True"), "page.rst", {}) + True + + A broken expression names the block it was written on: + + >>> try: + ... _gated(nodes.literal_block("", "", skipif="nope"), "page.rst", {}) + ... except SkipifExpressionError as exc: + ... print(exc) + page.rst:0: :skipif: 'nope' failed: name 'nope' is not defined + """ + skipif = node.get("skipif") + if skipif is None: + return False + try: + return _skipif(skipif, globs) + except Exception as exc: + raise SkipifExpressionError(skipif, filename, _node_line(node), exc) from exc + + +def _pair_testoutput( + block_nodes: list[nodes.Element], + filename: str, + globs: dict[str, t.Any], +) -> tuple[list[nodes.Element], dict[int, nodes.Element]]: + r"""Hand each ``{testoutput}`` to the ``{testcode}`` it follows. + + A ``{testoutput}`` is not a block of its own: it says what an earlier block + prints, as in :mod:`sphinx.ext.doctest`. Pairing is tracked per group, the + way :meth:`sphinx.ext.doctest.TestGroup.add_code` tracks it: the output + joins the last block its groups saw, and only when that block is a + ``{testcode}`` still waiting for one. Two groups can therefore run their + blocks interleaved and still each get their own output, while any other + block of the group in between — a ``{doctest}``, a second ``{testcode}`` — + closes the pairing. A stray is dropped with a warning rather than collected + as a test that checks nothing. + + A second ``{testoutput}`` for one ``{testcode}`` replaces the first, as + :mod:`sphinx.ext.doctest` does, so a page reads the same here as it builds + there. Unlike Sphinx it says so: the page kept two answers to one question + and only one of them ran. + + A gated ``{testoutput}`` is dropped as well, which leaves its + ``{testcode}`` expecting no output — what :mod:`sphinx.ext.doctest` does + when a ``:skipif:`` takes the node out of the doctree. + + Parameters + ---------- + block_nodes : list[docutils.nodes.Element] + Every block the page holds, in document order. + filename : str + Path warnings and failed expressions are reported against. + globs : dict[str, typing.Any] + Globals the document starts with. + + Returns + ------- + tuple[list[docutils.nodes.Element], dict[int, docutils.nodes.Element]] + Blocks left to collect, and the output node each ``{testcode}`` was + given, keyed by :func:`id`. + + Examples + -------- + >>> from docutils import nodes + >>> def block(kind, text, *groups): + ... return nodes.literal_block( + ... text, text, testnodetype=kind, groups=list(groups) or ["default"] + ... ) + >>> code, output = block("testcode", "print(1)"), block("testoutput", "1") + >>> blocks, wants = _pair_testoutput([code, output], "page.md", {}) + >>> [held["testnodetype"] for held in blocks] + ['testcode'] + >>> wants[id(code)].astext() + '1' + + A second one replaces the first, and the page hears about it: + + >>> blocks, wants = _pair_testoutput( + ... [code, output, block("testoutput", "2")], "page.md", {} + ... ) + >>> len(blocks), wants[id(code)].astext() + (1, '2') + + An intervening block closes the pairing, so a later output is a stray: + + >>> _, wants = _pair_testoutput( + ... [code, block("doctest", ">>> 1\n1"), block("testoutput", "2")], + ... "page.md", + ... {}, + ... ) + >>> wants + {} + + Two groups can run interleaved and still each be answered: + + >>> alpha = block("testcode", "print('a')", "alpha") + >>> beta = block("testcode", "print('b')", "beta") + >>> _, wants = _pair_testoutput( + ... [alpha, beta, block("testoutput", "a", "alpha")], "page.md", {} + ... ) + >>> wants[id(alpha)].astext(), id(beta) in wants + ('a', False) + """ + blocks: list[nodes.Element] = [] + wants: dict[int, nodes.Element] = {} + # Last block each group saw, and whether it is still open to an output. + # ``None`` records a group whose latest block cannot take one. + pending: dict[str, nodes.Element | None] = {} + for node in block_nodes: + groups = _node_groups(node) or [_DEFAULT_GROUP] + if node.get("testnodetype") != "testoutput": + blocks.append(node) + open_to_output = node.get("testnodetype") == "testcode" + for group in groups: + pending[group] = node if open_to_output else None + continue + if _gated(node, filename, globs): + continue + above = next( + (pending[group] for group in groups if pending.get(group) is not None), + None, + ) + if above is None: + logger.warning( + "testoutput block follows no testcode of its group", + extra={ + "doctest_source_file": filename, + "doctest_block_type": "testoutput", + }, + ) + continue + if id(above) in wants: + logger.warning( + "testoutput block replaces the one above it", + extra={ + "doctest_source_file": filename, + "doctest_block_type": "testoutput", + }, + ) + # Left open, so a third replaces the second, as sphinx.ext.doctest does. + wants[id(above)] = node + return blocks, wants + + +def _runs_as_exec(block_type: str, source: str) -> bool: + r"""Report whether a block's body runs as a module body rather than prompts. + + A ``{testcode}`` always does. A ``{testsetup}`` or ``{testcleanup}`` does + when it carries no ``>>>``, which is how :mod:`sphinx.ext.doctest` writes + one — Sphinx runs those bodies through the same ``exec`` its ``{testcode}`` + uses, and rejects a prompt outright. gp-libs has always read them with + :class:`doctest.DocTestParser` instead, so both spellings have to work: the + prompt decides which. + + Parameters + ---------- + block_type : str + Block type, from the node's ``testnodetype``. + source : str + Body of the block. + + Returns + ------- + bool + Whether to build the block as one ``exec``-mode example. + + Examples + -------- + >>> _runs_as_exec("testcode", "value = 41") + True + + A setup block written either way is read the way it was written: + + >>> _runs_as_exec("testsetup", "base = 40") + True + >>> _runs_as_exec("testsetup", ">>> base = 40") + False + + Nothing else changes mode, and an empty body has no statement to run: + + >>> _runs_as_exec("doctest", "base = 40"), _runs_as_exec("testsetup", " ") + (False, False) + """ + if block_type == "testcode": + return True + if block_type not in _PHASE_BLOCK_TYPES or not source.strip(): + return False + return _PROMPT_RE.search(source) is None + + +def _testcode_test( + source: str, + want: str, + options: dict[int, bool], + name: str, + filename: str, + lineno: int, + globs: dict[str, t.Any], +) -> doctest.DocTest: + r"""Return the test one ``{testcode}`` block runs as. + + The block is a single example: its body is the source, the + ``{testoutput}`` below it is the expected output, and the source is marked + :class:`_ExecSource` so the runner compiles it as a module body. + ```` is off unless the page turns it back on, as in + :mod:`sphinx.ext.doctest` — a blank line in a ``{testoutput}`` block is + just a blank line. + + Parameters + ---------- + source : str + Body of the ``{testcode}`` block. + want : str + Output the block is expected to print, empty when none was given. + options : dict[int, bool] + Doctest flags from the ``{testoutput}`` block's ``:options:``. + name : str + Name the test collects under. + filename : str + Path failures are reported against. + lineno : int + Line the block sits on. + globs : dict[str, typing.Any] + Globals the namespace starts with. + + Returns + ------- + doctest.DocTest + One example, ready to merge with the rest of its namespace. + + Examples + -------- + >>> test = _testcode_test("print(2 + 2)", "4", {}, "page.md", "page.md", 7, {}) + >>> test.name, test.lineno, len(test.examples) + ('page.md', 7, 1) + >>> example = test.examples[0] + >>> example.source, example.want + ('print(2 + 2)\n', '4\n') + >>> isinstance(example.source, _ExecSource) + True + + A ``{testoutput}`` spelling a traceback checks the exception instead: + + >>> raiser = _testcode_test( + ... "raise ValueError('boom')", + ... "Traceback (most recent call last):\n ...\nValueError: boom", + ... {}, + ... "page.md", + ... "page.md", + ... 0, + ... {}, + ... ) + >>> raiser.examples[0].exc_msg + 'ValueError: boom\n' + """ + # Normalized before the match so the captured message ends in the newline + # ``traceback.format_exception_only`` puts on the line it is checked against. + if want and not want.endswith("\n"): + want += "\n" + match = doctest.DocTestParser._EXCEPTION_RE.match(want) # type: ignore[attr-defined] + example = doctest.Example( + source, + want, + exc_msg=match.group("msg") if match else None, + options={doctest.DONT_ACCEPT_BLANKLINE: True, **options}, + ) + example.source = _ExecSource(example.source) + # A reader is shown ``lines[example.lineno - 9 : example.lineno + 1]`` of the + # block and sent to ``test.lineno + example.lineno + 1``. One example holding + # a whole block would show its first line only and send the reader there, so + # the example sits on the block's last line: the report then quotes the block + # entire and lands inside it rather than above the failure. + example.lineno = max(len(source.splitlines()) - 1, 0) + return doctest.DocTest([example], globs, name, filename, lineno, source) + + +def _merge_blocks( + blocks: list[doctest.DocTest], + name: str, + filename: str, + globs: dict[str, t.Any], + keep: list[doctest.DocTest] | None = None, +) -> doctest.DocTest: + r"""Merge one namespace's blocks into a single test. + + Each block keeps the line docutils reported for it, with blank lines + standing in for the prose between two blocks, so a merged example reports + the line it reports on its own and the ``%03d`` gutter of a failure still + counts up to the failing ``>>>``. + + A block the lines above already reach follows them instead. Two blocks can + claim overlapping lines: an ``.. include::`` numbers its nodes against the + included file, and a reStructuredText doctest block reports its *last* + line, so its examples already report lines further down the page than the + block occupies. An included block can therefore hold the lowest line number + of the namespace and anchor the merged test on the included file's + coordinates, which pads the page's own blocks out to their distance from + it. + + Parameters + ---------- + blocks : list[doctest.DocTest] + Blocks of one namespace, each parsed on its own, in the order they run. + Each kept block's ``example.lineno`` is shifted **in place**, so no + block may be merged twice while `keep` holds it — which is why + :meth:`DocutilsDocTestFinder._find` leaves a lifted block out of `keep` + before merging that block on its own. + name : str + Namespace name, which becomes the test name. + filename : str + Path failures are reported against. + globs : dict[str, typing.Any] + Globals the namespace starts with. + keep : list[doctest.DocTest] or None + Blocks whose examples the merged test runs, compared by identity. + `None`, the default, keeps every block. A block left out still + contributes its source and its spacing, so the blocks around it report + the lines they reported before and a failure's gutter still shows what + was passed over — only its examples are dropped. + + Returns + ------- + doctest.DocTest + One test, holding the examples of every block in `keep`, laid out + across the page the blocks came from. + + Examples + -------- + Two blocks six lines apart keep that distance, and each example reports the + line its prompt sits on: + + >>> parser = doctest.DocTestParser() + >>> def block(line, source): + ... return parser.get_doctest(source, {}, "page.md", "page.md", line) + >>> merged = _merge_blocks( + ... [block(3, ">>> greeting = 'hello'"), + ... block(9, ">>> greeting.upper()\n'HELLO'")], + ... "page.md", + ... "page.md", + ... {}, + ... ) + >>> merged.name, merged.lineno + ('page.md', 3) + >>> merged.docstring.splitlines() + [">>> greeting = 'hello'", '', '', '', '', '', '>>> greeting.upper()', "'HELLO'"] + >>> [merged.lineno + example.lineno + 1 for example in merged.examples] + [4, 10] + + A block whose line the one above already covers is appended after it: + + >>> merged = _merge_blocks( + ... [block(3, ">>> one = 1\n>>> two = 2\n>>> three = 3"), + ... block(4, ">>> one + two")], + ... "page.md", + ... "page.md", + ... {}, + ... ) + >>> merged.docstring.splitlines() + ['>>> one = 1', '>>> two = 2', '>>> three = 3', '>>> one + two'] + """ + # Laid out by where each block sits on the page, but run in the order + # given: a namespace hands its blocks over as setup, tests, cleanup, which + # is rarely the order a reader meets them. Anchoring the text on the caller + # ordering would report every example against whichever block happened to + # come first in that sequence. + in_page_order = sorted(blocks, key=lambda block: block.lineno or 0) + origin = in_page_order[0].lineno or 0 + lines: list[str] = [] + offsets: dict[int, int] = {} + for block in in_page_order: + offset = max((block.lineno or 0) - origin, len(lines)) + lines.extend([""] * (offset - len(lines))) + lines.extend((block.docstring or "").splitlines()) + offsets[id(block)] = offset + examples: list[doctest.Example] = [] + for block in blocks: + # A dropped block still pads and still shows its source, so the blocks + # after it keep the lines they reported before and a failure's gutter + # still shows what was passed over. Its examples are left out entirely: + # whoever takes them next positions them itself. + if keep is not None and not any(block is kept for kept in keep): + continue + for example in block.examples: + # Positioned on a copy, so merging reads its blocks rather than + # consuming them: a block may be merged again — into a second group + # it named, or on its own once lifted — and still report the line it + # sits on. ``options`` is copied too, since a shallow copy would + # hand both merges the same mutable mapping. + shifted = copy.copy(example) + shifted.options = dict(example.options) + shifted.lineno += offsets[id(block)] + examples.append(shifted) + return doctest.DocTest( + examples, + globs, + name, + filename, + origin, + "\n".join(lines), + ) + + +class _CollectedBlock(t.NamedTuple): + """One block of a page, parsed against one namespace. + + Attributes + ---------- + position : int + Where the block sits in the document, counted from zero. It is the + number a block's name carries at ``"block"`` scope, and the number a + block lifted out of its namespace is named for. Spelled ``position`` + rather than ``index`` because a :class:`tuple` already has an + ``index``. + block_type : str + ``doctest``, ``testsetup``, ``testcleanup``, or the node's tag name. + test : doctest.DocTest + The block's examples, with its directive options already merged in. + """ + + position: int + block_type: str + test: doctest.DocTest + + +def _all_examples_skipped(test: doctest.DocTest) -> bool: + r"""Return whether every example of `test` carries :data:`doctest.SKIP`. + + This is the question ``_pytest.doctest._check_all_skipped`` asks of an item + before running it, and the answer decides whether pytest reports the item + ``SKIPPED``. Asking it of a single block says whether that block would + report, were it an item of its own. + + A block holding no examples answers ``False``: there is nothing in it to + skip, and nothing for a reader to be told about. + + Parameters + ---------- + test : doctest.DocTest + Examples of one block. + + Returns + ------- + bool + Whether none of the block's examples is left to run. + + Examples + -------- + >>> parser = doctest.DocTestParser() + >>> def block(source): + ... return parser.get_doctest(source, {}, "page.rst", "page.rst", 0) + + >>> _all_examples_skipped(block(">>> 1 / 0 # doctest: +SKIP\n")) + True + >>> _all_examples_skipped(block(">>> 2 + 2\n4\n")) + False + + A block only half of whose examples are gated still has one to run: + + >>> _all_examples_skipped( + ... block(">>> 1 / 0 # doctest: +SKIP\n>>> 2 + 2\n4\n") + ... ) + False + + >>> _all_examples_skipped(block("Prose, and no prompts at all.\n")) + False + """ + return bool(test.examples) and all( + example.options.get(doctest.SKIP, False) for example in test.examples + ) + + +def _split_skipped_blocks( + blocks: list[_CollectedBlock], +) -> tuple[list[_CollectedBlock], list[_CollectedBlock]]: + r"""Split a namespace's blocks into the ones it keeps and the ones it lifts out. + + A block whose every example is skipped binds nothing, so the namespace + reaches the same state with it or without it. Merged in, though, it is + silent: pytest reports a namespace skipped only when *no* example in it is + left to run, so one gated block among running ones reports as a pass. + Lifting it back out gives it an item of its own, which reports. + + A namespace with nothing left to run keeps every block, so it reports + skipped once as a namespace rather than once per block. + + Parameters + ---------- + blocks : list[_CollectedBlock] + Every block of one namespace, in the order it runs. + + Returns + ------- + tuple[list[_CollectedBlock], list[_CollectedBlock]] + Blocks the namespace keeps, and blocks that become items of their own. + The first is never empty: the namespace lifts a block out only when + another one is left to run. + + Examples + -------- + >>> parser = doctest.DocTestParser() + >>> def block(index, source): + ... return _CollectedBlock( + ... index, + ... "doctest", + ... parser.get_doctest(source, {}, "page.rst", "page.rst", index), + ... ) + + The gated block of a namespace that still runs is lifted out: + + >>> kept, lifted = _split_skipped_blocks([ + ... block(0, ">>> value = 1\n"), + ... block(1, ">>> value = 999 # doctest: +SKIP\n"), + ... block(2, ">>> value\n1\n"), + ... ]) + >>> [held.position for held in kept], [held.position for held in lifted] + ([0, 2], [1]) + + A namespace with nothing left to run keeps its blocks, so the one item it + collects as reports skipped once: + + >>> kept, lifted = _split_skipped_blocks([ + ... block(0, ">>> 1 / 0 # doctest: +SKIP\n"), + ... block(1, ">>> 2 / 0 # doctest: +SKIP\n"), + ... ]) + >>> [held.position for held in kept], [held.position for held in lifted] + ([0, 1], []) + """ + runnable = any( + not example.options.get(doctest.SKIP, False) + for held in blocks + for example in held.test.examples + ) + if not runnable: + return list(blocks), [] + return ( + [held for held in blocks if not _all_examples_skipped(held.test)], + [held for held in blocks if _all_examples_skipped(held.test)], + ) + + +def _lifted_name(namespace: str, position: int) -> str: + """Return the name a block lifted out of `namespace` collects under. + + It is the namespace's own name with the block's document position, the + same ``name[n]`` shape a block that names no group already carries at + ``"block"`` scope. So a gated block reads the same in ``--collect-only`` + and answers to the same node id whether the page shares a namespace or + not, and it cannot collide with the namespace it came out of. + + Parameters + ---------- + namespace : str + Namespace the block was lifted out of. + position : int + Where the block sits in the document, counted from zero. + + Returns + ------- + str + Name for the block's own test. + + Examples + -------- + >>> _lifted_name("intro", 3) + 'intro[3]' + + A page sharing one namespace names its blocks as ``"block"`` scope would: + + >>> _lifted_name("page.md", 3) + 'page.md[3]' + """ + return f"{namespace}[{position}]" + + +def _block_name(namespace: str, document_name: str, position: int) -> str: + """Return the name one block collects under when its namespace keeps it apart. + + A namespace laid out ``"per-block"`` is many tests, so each needs a name. + It is the namespace's own name with the block's document position — the + same ``name[n]`` shape :func:`_lifted_name` gives a gated block — so the + node id a reader pastes back to pytest does not move with the layout. A + namespace already named for this one block adds nothing. + + Parameters + ---------- + namespace : str + Namespace the block runs in. + document_name : str + Base name of the document, without its directory. + position : int + Where the block sits in the document, counted from zero. + + Returns + ------- + str + Name for the block's own test. + + Examples + -------- + A group numbers its blocks by where they sit on the page: + + >>> _block_name("intro", "page.md", 1) + 'intro[1]' + + A page sharing one namespace numbers them the same way: + + >>> _block_name("page.md", "page.md", 1) + 'page.md[1]' + + A block that has a namespace to itself already carries the number: + + >>> _block_name("page.md[1]", "page.md", 1) + 'page.md[1]' + """ + if namespace == _namespace_name(None, "block", document_name, position): + return namespace + return _lifted_name(namespace, position) + + +class _CollectedTest(t.NamedTuple): + """One test a page collected, and the namespace it was collected into. + + Attributes + ---------- + namespace : str + Namespace the test's examples run against. Under + :data:`NAMESPACE_ITEMS` ``"per-block"`` a namespace's tests hold one + globals mapping between them, which makes the namespace the unit a + caller distributing tests across processes cannot split. + test : doctest.DocTest + Examples, ready to run. + """ + + namespace: str + test: doctest.DocTest + + class DocTestFinderNameDoesNotExist(ValueError): """Raised with doctest lookup name not provided.""" @@ -227,17 +1616,71 @@ def __init__(self, string: str) -> None: class DocutilsDocTestFinder: - """DocTestFinder for doctest-docutils. + r"""DocTestFinder for doctest-docutils. Class used to extract the DocTests relevant to a docutils file. Doctests are extracted from the following directive types: doctest_block (doctest), DocTestDirective. Myst-parser is also supported for parsing markdown files. + + Blocks that name the same group — ``.. doctest:: intro`` in + reStructuredText, ``{doctest} intro`` in Markdown — are one test, so a name + bound in the group's first block is still bound in its last. Blocks that + name no group get a namespace each unless `namespace_scope` widens them to + the page. + + A block whose every example is skipped is the exception: it binds nothing, + so it comes back as a test of its own, named for its namespace and for + where it sits on the page, and reports as the skip it is instead of + vanishing into a namespace that runs. + + Examples + -------- + Two blocks in group ``intro`` come back as one test named for the group: + + >>> page = "\n".join([ + ... "```{doctest} intro", + ... ">>> greeting = 'hello'", + ... "```", + ... "", + ... "Narrative prose between the blocks.", + ... "", + ... "```{doctest} intro", + ... ">>> greeting.upper()", + ... "'HELLO'", + ... "```", + ... ]) + >>> tests = DocutilsDocTestFinder().find(page, "page.md") + >>> [(test.name, len(test.examples)) for test in tests] + [('intro', 2)] + + A gated block between them is its own test, named for where it sits: + + >>> gated = page.replace( + ... "Narrative prose between the blocks.", + ... "```{doctest} intro\n>>> greeting = 'nope' # doctest: +SKIP\n```", + ... ) + >>> [(test.name, len(test.examples)) for test in + ... DocutilsDocTestFinder().find(gated, "page.md")] + [('intro', 2), ('intro[1]', 1)] + + `namespace_items` decides whether that sharing costs the blocks their own + tests. Under ``"per-block"`` the group is two tests again, holding one + globals mapping between them: + + >>> per_block = DocutilsDocTestFinder(namespace_items="per-block") + >>> tests = per_block.find(page, "page.md") + >>> [(test.name, len(test.examples)) for test in tests] + [('intro[0]', 1), ('intro[1]', 1)] + >>> tests[0].globs is tests[1].globs + True """ def __init__( self, verbose: bool = False, parser: doctest.DocTestParser = parser, + namespace_scope: NamespaceScope = DEFAULT_NAMESPACE_SCOPE, + namespace_items: NamespaceItems = DEFAULT_NAMESPACE_ITEMS, ) -> None: """Create a new doctest finder. @@ -245,10 +1688,33 @@ def __init__( to create new DocTest objects (or objects that implement the same interface as DocTest). The signature for this factory function should match the signature of the DocTest constructor. + + Parameters + ---------- + verbose : bool + Log each document as it is searched. + parser : doctest.DocTestParser + Parser that turns a block's source into a :class:`doctest.DocTest`. + namespace_scope : NamespaceScope + Namespace a block that names no group runs in: ``"block"`` gives it + one of its own, ``"document"`` shares one across the page. + namespace_items : NamespaceItems + What a namespace comes back as: ``"merged"`` gives one test holding + every block's examples, ``"per-block"`` gives one test per block, + each handed the namespace's globals mapping rather than a copy. + + Raises + ------ + NamespaceScopeError + If `namespace_scope` names no known scope. + NamespaceItemsError + If `namespace_items` names no known layout. """ _ensure_directives_registered() self._parser = parser self._verbose = verbose + self._namespace_scope = _parse_namespace_scope(namespace_scope) + self._namespace_items = _parse_namespace_items(namespace_items) def find( self, @@ -257,13 +1723,103 @@ def find( globs: dict[str, t.Any] | None = None, extraglobs: dict[str, t.Any] | None = None, ) -> list[doctest.DocTest]: - """Return list of the DocTests defined by given string (its parsed directives). - - The globals for each DocTest is formed by combining `globs` and `extraglobs` - (bindings in `extraglobs` override bindings in `globs`). A new copy of the - globals dictionary is created for each DocTest. If `globs` is not specified, + r"""Return list of the DocTests defined by given string (its parsed directives). + + One DocTest comes back per namespace, plus one for each fully skipped + block a running namespace lifted out: the blocks that share a namespace + are merged into one, and the rest stand alone. The globals for each + DocTest is formed by combining `globs` and `extraglobs` (bindings in + `extraglobs` override bindings in `globs`). A new copy of the globals + dictionary is created for each DocTest. If `globs` is not specified, then it defaults to the module's `__dict__`, if specified, or {} otherwise. If `extraglobs` is not specified, then it defaults to {}. + + A finder built with `namespace_items` ``"per-block"`` merges nothing: + one DocTest comes back per block, and the blocks of one namespace are + handed that namespace's globals rather than a copy each, so a caller + running them has to pass ``clear_globs=False``. + + Namespaces come back in the order a reader meets the first block of + each, and a namespace's own tests in setup, test, cleanup order — which + is document order exactly when no two namespaces interleave. + + Examples + -------- + >>> page = "\n".join(f"```python\n>>> {n}\n{n}\n```\n" for n in range(11)) + >>> [test.name for test in DocutilsDocTestFinder().find(page, "page.md")][-2:] + ['page.md[9]', 'page.md[10]'] + + A test is named for the page it came from, never for the path it was + collected under, so its pytest node id reads the same on every machine: + + >>> finder = DocutilsDocTestFinder() + >>> [test.name for test in finder.find(">>> 2 + 2\n4\n", "docs/page.rst")] + ['page.rst[0]'] + + A page sharing one namespace names a gated block the same way ``block`` + scope would, so the node id that selects it does not move with the + scope: + + >>> page = "\n".join([ + ... "```python", ">>> value = 1", "```", "", + ... "```python", ">>> value = 999 # doctest: +SKIP", "```", "", + ... "```python", ">>> value", "1", "```", + ... ]) + >>> shared = DocutilsDocTestFinder(namespace_scope="document") + >>> [test.name for test in shared.find(page, "page.md")] + ['page.md', 'page.md[1]'] + >>> [test.name for test in DocutilsDocTestFinder().find(page, "page.md")] + ['page.md[0]', 'page.md[1]', 'page.md[2]'] + """ + return [ + collected.test + for collected in self._collect(string, name, globs, extraglobs) + ] + + def _collect( + self, + string: str, + name: str | None = None, + globs: dict[str, t.Any] | None = None, + extraglobs: dict[str, t.Any] | None = None, + ) -> list[_CollectedTest]: + r"""Return every test a page holds, each beside the namespace it runs in. + + :meth:`find` is this without the namespaces. A caller that has to keep + one namespace's tests together needs the name they share: under + :data:`NAMESPACE_ITEMS` ``"per-block"`` they hold one globals mapping + between them, and a mapping is a Python object, so it does not cross + processes. + + Parameters + ---------- + string : str + Page source. + name : str or None + Path the page was read from, whose suffix picks the parser. + globs : dict[str, typing.Any] or None + Globals every namespace starts from. + extraglobs : dict[str, typing.Any] or None + Globals overriding `globs`. + + Returns + ------- + list[_CollectedTest] + Tests, each naming its namespace, namespaces in the order a reader + meets the first block of each and a namespace's own tests in setup, + test, cleanup order. + + Examples + -------- + >>> page = "\n".join([ + ... "```{doctest} intro", ">>> greeting = 'hello'", "```", "", + ... "```python", ">>> 2 + 2", "4", "```", + ... ]) + >>> finder = DocutilsDocTestFinder(namespace_items="per-block") + >>> [(held.namespace, held.test.name) for held in finder._collect( + ... page, "page.md" + ... )] + [('intro', 'intro[0]'), ('page.md[1]', 'page.md[1]')] """ # If name was not specified, then extract it from the string. if name is None: @@ -271,12 +1827,6 @@ def find( if name is None: raise DocTestFinderNameDoesNotExist(string=string) - # No access to a loader, so assume it's a normal - # filesystem path - source_lines = linecache.getlines(name) or None - if not source_lines: - source_lines = None - # Initialize globals, and merge in extraglobs. globs = {} if globs is None else globs.copy() if extraglobs is not None: @@ -284,54 +1834,32 @@ def find( if "__name__" not in globs: globs["__name__"] = "__main__" # provide a default module name - tests: list[doctest.DocTest] = [] + tests: list[_CollectedTest] = [] source_path: pathlib.Path | None = ( pathlib.Path(name) if name is not None else None ) - self._find(tests, string, name, source_lines, globs, {}, source_path) - # Sort the tests by alpha order of names, for consistency in - # verbose-mode output. This was a feature of doctest in Pythons - # <= 2.3 that got lost by accident in 2.4. It was repaired in - # 2.4.4 and 2.5. - tests.sort() + self._find(tests, string, name, globs, {}, source_path) + # ``_find`` appends in document-traversal order; leave it that way. + # ``DocTest.__lt__`` compares names, and a name carries its block index + # as text, so sorting runs ``page.md[10]`` ahead of ``page.md[1]``. return tests def _find( self, - tests: list[doctest.DocTest], + tests: list[_CollectedTest], string: str, name: str, - source_lines: list[str] | None, globs: dict[str, t.Any], seen: dict[int, int], source_path: pathlib.Path | None = None, ) -> None: """Find tests for the given string, and add them to `tests`.""" - if self._verbose: - logger.info(f"Finding tests in {name}") - # If we've already processed this string, then ignore it. if id(string) in seen: return seen[id(string)] = 1 - # Find a test for this string, and add it to the list of tests. - logger.debug( - "_find({})".format( - pprint.pformat( - { - "tests": tests, - "string": string, - "name": name, - "source_lines": source_lines, - "globs": globs, - "seen": seen, - }, - ), - ), - ) ext = pathlib.Path(name).suffix - logger.debug(f"parse, ext: {ext}") if ext == ".md": import myst_parser.parsers.docutils_ from myst_parser.config.main import MdParserConfig @@ -382,24 +1910,258 @@ def condition(node: Node) -> bool: or isinstance(node, nodes.doctest_block) ) - for idx, node in enumerate(findall(doc)(condition)): - logger.debug(f"() node: {node.astext()}") - assert isinstance(node, nodes.Element) - test_name = node.get("groups") - if isinstance(test_name, list): - test_name = test_name[0] - if test_name is None or test_name == "default": - test_name = f"{name}[{idx}]" - logger.debug(f"() node: {test_name}") - test = self._get_test( - string=node.astext(), - name=test_name, - filename=name, - globs=globs, - source_lines=[str(node.line)], + document_name = pathlib.Path(name).name + # Namespaces keep insertion order, so the merged tests come back in the + # order the reader meets each namespace's first block. Each holds its + # setup, test, and cleanup blocks apart: sphinx.ext.doctest runs a + # group's setup before its tests and its cleanup after, whatever order + # the page wrote them in, and a testsetup exists to be movable. + namespaces: dict[str, dict[str, list[_CollectedBlock]]] = {} + + found: list[nodes.Element] = [ + node for node in findall(doc)(condition) if isinstance(node, nodes.Element) + ] + # A page written in Sphinx's prompt-free style puts its setup in the + # same implicit group as its code. A page of prompt blocks keeps the + # namespaces it has always had, so the widening follows the testcode. + grouped_types = _GROUPED_BLOCK_TYPES + if any(node.get("testnodetype") == "testcode" for node in found): + grouped_types = _GROUPED_BLOCK_TYPES | _PHASE_BLOCK_TYPES + block_nodes, wants = _pair_testoutput(found, name, globs) + declared = [_node_groups(node) for node in block_nodes] + # A prompt-free block shares its page, so it is named for the page the + # way an ungrouped block already is at document scope. That is what + # lets the two forms meet there instead of on a name no author wrote. + scopes: list[NamespaceScope] = [ + "document" if _page_scoped(node, grouped_types) else self._namespace_scope + for node in block_nodes + ] + + def generated_name(idx: int) -> str: + return _namespace_name(None, scopes[idx], document_name, idx) + + # A block joins every group it names. ``*`` means every group the + # document declares, so it can only be resolved once the page has been + # read; a page whose only blocks are wildcards has no group to join, so + # each keeps its own namespace. + memberships: list[list[str]] = [ + [] if _WILDCARD_GROUP in groups else (groups or [generated_name(idx)]) + for idx, groups in enumerate(declared) + ] + ordered: list[str] = [] + for names in memberships: + for candidate in names: + if candidate not in ordered: + ordered.append(candidate) + for idx, groups in enumerate(declared): + if _WILDCARD_GROUP in groups: + memberships[idx] = list(ordered) or [generated_name(idx)] + + # A generated name and a declared one are the same string to everything + # downstream: the namespace mapping keys on it and the test is named + # for it. So a page that spells both has to say which it meant, and + # cannot. Checked against the names actually generated, not the shape + # they take, so a group named for a page whose blocks all declare one + # is left alone. A page of nothing but wildcards declares no name to + # collide with, which is why the fallback above needs no check. + generated = { + generated_name(idx) for idx, groups in enumerate(declared) if not groups + } + for group in sorted( + {name for groups in declared for name in groups} & generated, + ): + raise NamespaceNameCollisionError( + group, + document_name, + f"a block declaring none at {self._namespace_scope!r} scope", + ) + + for idx, node in enumerate(block_nodes): + block_type = str(node.get("testnodetype", node.tagname)) + lineno = _node_line(node) + # The block's own flags, before its examples get a say. A true + # ``:skipif:`` joins them as ``+SKIP``: one spelling of "do not run + # this" that a reader can predict from the other, and one path + # through the runner, which keeps the block collected, reported and + # selectable by node id instead of vanishing from the page. + options = dict(node.get("options") or {}) + gated = _gated(node, name, globs) + if gated: + logger.debug( + "doctest block skipped by skipif", + extra={ + "doctest_source_file": name, + "doctest_block_type": block_type, + }, + ) + # ``node["test"]`` is the source before the directive trimmed + # ``# doctest:`` flags out of the code a reader sees. Both + # spellings have the same line count, so either positions the + # block the same way. + source = str(node.get("test") or node.astext()) + output = wants.get(id(node)) + for namespace in memberships[idx]: + logger.debug( + "doctest block collected into namespace %s", + namespace, + extra={ + "doctest_source_file": name, + "doctest_block_type": block_type, + }, + ) + # Parsed once per namespace: _merge_blocks shifts + # ``example.lineno`` in place, so two namespaces sharing one + # block's examples would shift them twice. + test_name = ( + namespace + if self._namespace_items == "merged" + else _block_name(namespace, document_name, idx) + ) + test = ( + _testcode_test( + source=source, + want=output.astext() if output is not None else "", + options=dict(output.get("options") or {}) + if output is not None + else {}, + name=test_name, + filename=name, + lineno=lineno, + globs=globs, + ) + if _runs_as_exec(block_type, source) + else self._get_test( + string=source, + name=test_name, + filename=name, + globs=globs, + lineno=lineno, + ) + ) + if options or gated: + for example in test.examples: + # A directive's ``:options:`` set the block's defaults; + # an example's own inline flags win, as in + # sphinx.ext.doctest. + merged = dict(options) + merged.update(example.options) + if gated: + # A ``:skipif:`` is a gate, not a default. + # sphinx.ext.doctest drops the block before its + # source is ever read, so nothing written inside it + # can turn the gate off — and an example that did + # would run on exactly the interpreter or platform + # the condition was guarding against. + merged[doctest.SKIP] = True + example.options = merged + phases = namespaces.setdefault( + namespace, + {"testsetup": [], "test": [], "testcleanup": []}, + ) + phases[block_type if block_type in phases else "test"].append( + _CollectedBlock(idx, block_type, test), + ) + + # Anchored on the document position of the first block each test holds, + # so the tests come back in the order a reader meets them. A namespace + # anchors where it now starts, which is where it started before if it + # lifted nothing out. Ties — one block joining two groups — keep the + # order the page declared them in, which a stable sort preserves. + anchored: list[tuple[int, int, _CollectedTest]] = [] + for namespace, phases in namespaces.items(): + in_phase_order = [ + *phases["testsetup"], + *phases["test"], + *phases["testcleanup"], + ] + if self._namespace_items == "per-block": + # One mapping for the namespace, handed to every block of it. + # ``DocTest.__init__`` copies the globals it is given, so the + # mapping is assigned afterwards, as sphinx.ext.doctest does; + # whoever runs these tests has to leave it uncleared for the + # sharing to reach the block below. + shared = dict(globs) + anchor = min(held.position for held in in_phase_order) + for order, held in enumerate(in_phase_order): + held.test.globs = shared + anchored.append( + ( + # The namespace anchors as a whole, and its blocks + # keep phase order inside it: a testsetup written + # at the foot of the page still runs first. + anchor, + order, + _CollectedTest(namespace, held.test), + ), + ) + continue + kept, lifted = _split_skipped_blocks(in_phase_order) + anchored.append( + ( + # Every block anchors its namespace, lifted or not, so + # lifting the first one cannot let another namespace + # declared below it collect first. + min(held.position for held in in_phase_order), + # Ties with a block this namespace lifted break toward the + # block: it sits at that line, the namespace resumes later. + min(held.position for held in kept), + # Merged over every block, so the padding a lifted block + # contributed stays and the blocks after it keep the lines + # they reported before it was lifted. + _CollectedTest( + namespace, + _merge_blocks( + [held.test for held in in_phase_order], + namespace, + name, + globs, + keep=[held.test for held in kept], + ), + ), + ), + ) + for held in lifted: + lifted_name = _lifted_name(namespace, held.position) + # Lifting generates a name the same way declaring a group + # does, so it can land on one the page already declared. + if lifted_name in namespaces: + raise NamespaceNameCollisionError( + lifted_name, + document_name, + f"a block lifted out of {namespace!r}", + ) + logger.debug( + "skipped doctest block lifted out of namespace %s as %s", + namespace, + lifted_name, + extra={ + "doctest_source_file": name, + "doctest_block_type": held.block_type, + }, + ) + anchored.append( + ( + held.position, + held.position, + _CollectedTest( + namespace, + _merge_blocks([held.test], lifted_name, name, globs), + ), + ), + ) + anchored.sort(key=lambda entry: (entry[0], entry[1])) + tests.extend(collected for _, _, collected in anchored) + logger.debug( + "parsed document into %d test(s)", + len(anchored), + extra={"doctest_source_file": name}, + ) + if self._verbose: + logger.info( + "found %d test(s)", + len(anchored), + extra={"doctest_source_file": name}, ) - if test is not None: - tests.append(test) def _get_test( self, @@ -407,12 +2169,9 @@ def _get_test( name: str, filename: str, globs: dict[str, t.Any], - source_lines: list[str], + lineno: int, ) -> doctest.DocTest: - """Return a DocTest for given string, or return None.""" - lineno = int(source_lines[0]) - - # Return a DocTest for this string. + """Return a DocTest for one block's source.""" return self._parser.get_doctest(string, globs, name, filename, lineno) @@ -438,10 +2197,60 @@ def testdocutils( raise_on_error: bool = False, parser: doctest.DocTestParser = parser, encoding: str | None = None, + namespace_scope: NamespaceScope = DEFAULT_NAMESPACE_SCOPE, + namespace_items: NamespaceItems = DEFAULT_NAMESPACE_ITEMS, ) -> doctest.TestResults: - """Docutils-based test entrypoint. + r"""Docutils-based test entrypoint. Based on doctest.testfile at python 3.10 + + Parameters + ---------- + namespace_scope : NamespaceScope + Namespace the blocks that name no group run in. See + :class:`DocutilsDocTestFinder`; the other parameters follow + :func:`doctest.testfile`. + namespace_items : NamespaceItems + Whether a namespace runs as one test or as one test per block. Running + a file has no scheduler to split the blocks across, so ``"per-block"`` + shares state here as it does in a serial pytest run. + + Returns + ------- + doctest.TestResults + Failed examples, and examples attempted. + + Examples + -------- + A page whose second block reads a name the first one bound fails while each + block keeps its own namespace, and passes once the page shares one: + + >>> import contextlib, io, pathlib, tempfile + >>> directory = tempfile.TemporaryDirectory() + >>> page = pathlib.Path(directory.name) / "page.rst" + >>> _ = page.write_text( + ... ">>> greeting = 'hello'\n\n>>> greeting.upper()\n'HELLO'\n", + ... encoding="utf-8", + ... ) + + >>> def run(**kwargs): + ... with contextlib.redirect_stdout(io.StringIO()): + ... return testdocutils( + ... str(page), module_relative=False, report=False, **kwargs + ... ) + + >>> run() + TestResults(failed=1, attempted=2) + + >>> run(namespace_scope="document") + TestResults(failed=0, attempted=2) + + Keeping each block a test of its own shares the page just the same: + + >>> run(namespace_scope="document", namespace_items="per-block") + TestResults(failed=0, attempted=2) + + >>> directory.cleanup() """ global master @@ -470,17 +2279,23 @@ def testdocutils( globs["__name__"] = "__main__" # Find, parse, and run all tests in the given module. - finder = DocutilsDocTestFinder() + finder = DocutilsDocTestFinder( + namespace_scope=namespace_scope, + namespace_items=namespace_items, + ) runner: doctest.DebugRunner | doctest.DocTestRunner if raise_on_error: - runner = doctest.DebugRunner(verbose=verbose, optionflags=optionflags) + runner = _ExecModeDebugRunner(verbose=verbose, optionflags=optionflags) else: - runner = doctest.DocTestRunner(verbose=verbose, optionflags=optionflags) + runner = _ExecModeRunner(verbose=verbose, optionflags=optionflags) + # A namespace laid out per block hands its tests one mapping between them, + # which the runner would otherwise empty after running the first of them. + clear_globs = namespace_items != "per-block" for test in finder.find(text, filename, globs=globs, extraglobs=extraglobs): - runner.run(test) + runner.run(test, clear_globs=clear_globs) if report: runner.summarize() @@ -544,6 +2359,28 @@ def _test() -> int: action="store_true", help=("Force parsing using docutils (reStructuredText, markdown)"), ) + p.add_argument( + "--namespace-scope", + action="store", + choices=NAMESPACE_SCOPES, + default=DEFAULT_NAMESPACE_SCOPE, + help=( + "namespace the blocks that name no group run in: block (default," + " one each) or document (one for the page); blocks that name a" + " group always share that group's namespace" + ), + ) + p.add_argument( + "--namespace-items", + action="store", + choices=NAMESPACE_ITEMS, + default=DEFAULT_NAMESPACE_ITEMS, + help=( + "what a namespace runs as: merged (default, one item holding every" + " block of it) or per-block (one item per block, sharing the" + " namespace between them)" + ), + ) p.add_argument("file", nargs="+", help="file containing the tests to run") args = p.parse_args() @@ -569,6 +2406,8 @@ def _test() -> int: module_relative=False, verbose=verbose, optionflags=options, + namespace_scope=args.namespace_scope, + namespace_items=args.namespace_items, ) elif filename.endswith(".py"): # It is a module -- insert its dir into sys.path and try to diff --git a/src/pytest_doctest_docutils.py b/src/pytest_doctest_docutils.py index 13c2db0..1c13bf7 100644 --- a/src/pytest_doctest_docutils.py +++ b/src/pytest_doctest_docutils.py @@ -12,9 +12,11 @@ from __future__ import annotations import bdb +import collections import doctest import io import logging +import pathlib import sys import typing as t @@ -23,12 +25,26 @@ from _pytest import outcomes from _pytest.outcomes import OutcomeException -from doctest_docutils import DocutilsDocTestFinder, _ensure_directives_registered +from doctest_docutils import ( + _HIDE_FLAG, + DEFAULT_NAMESPACE_ITEMS, + DEFAULT_NAMESPACE_SCOPE, + NAMESPACE_ITEMS, + NAMESPACE_SCOPES, + DocutilsDocTestFinder, + NamespaceItems, + NamespaceItemsError, + NamespaceScope, + NamespaceScopeError, + _ensure_directives_registered, + _ExecModeRunnerMixin, + _parse_namespace_items, + _parse_namespace_scope, +) if t.TYPE_CHECKING: - import pathlib import types - from collections.abc import Iterable + from collections.abc import Generator, Iterable, Sequence from doctest import _Out from _pytest.config.argparsing import Parser @@ -43,6 +59,43 @@ # Lazy definition of runner class RUNNER_CLASS = None +#: Namespace scope resolved once at configure time, read back during collection. +_NAMESPACE_SCOPE_KEY = pytest.StashKey[NamespaceScope]() + +#: Namespace layout resolved once at configure time, read back during collection. +_NAMESPACE_ITEMS_KEY = pytest.StashKey[NamespaceItems]() + +#: Whether the run asked for a ``--dist`` scheduler by name, captured before +#: pytest-xdist rewrites the value ``-n`` alone leaves behind. +_DIST_NAMED_KEY = pytest.StashKey[bool]() + +_NAMESPACE_HELP = ( + "namespace the doctest blocks of one .rst/.md file run in when they name" + " no group: block (default, one each) or document (one for the page);" + " blocks naming a group always share that group's namespace" +) + +_ITEMS_HELP = ( + "what a namespace collects as: merged (default, one item holding every" + " block of it) or per-block (one item per block, keeping their node ids" + " and sharing the namespace between them)" +) + +#: The ``--dist`` values that keep every item of one file on one worker, which +#: is what a shared namespace needs: a globals mapping is a Python object, so +#: it does not cross processes. Named as an allowlist rather than a list of +#: splitting schedulers so that a scheduler pytest-xdist adds later is handled +#: before it is trusted, instead of silently splitting a namespace. +#: ``load`` and ``worksteal`` hand a file's items to whichever worker is free; +#: ``-n`` without ``--dist`` resolves to ``load``. +_WHOLE_NAMESPACE_SCHEDULERS = frozenset( + {"no", "each", "loadfile", "loadgroup", "loadscope"}, +) + +#: What a page is when nothing says otherwise, matching the collector: a +#: ``.rst`` or ``.md`` file is one whatever ``--doctest-glob`` says. +_PAGE_SUFFIXES = frozenset({".rst", ".md"}) + def pytest_addoption(parser: Parser) -> None: """Add options to py.test for doctest_docutils.""" @@ -60,6 +113,152 @@ def pytest_addoption(parser: Parser) -> None: help="disable doctest-doctests in .py modules (pass-through to pytest-doctest)", dest="doctestmodules", ) + group.addoption( + "--doctest-docutils-namespace-scope", + action="store", + choices=NAMESPACE_SCOPES, + default=None, + help=( + f"{_NAMESPACE_HELP}; overrides the doctest_docutils_namespace_scope" + " ini option" + ), + dest="doctest_docutils_namespace_scope", + ) + parser.addini( + "doctest_docutils_namespace_scope", + _NAMESPACE_HELP, + default=DEFAULT_NAMESPACE_SCOPE, + ) + group.addoption( + "--doctest-docutils-namespace-items", + action="store", + choices=NAMESPACE_ITEMS, + default=None, + help=( + f"{_ITEMS_HELP}; overrides the doctest_docutils_namespace_items ini option" + ), + dest="doctest_docutils_namespace_items", + ) + parser.addini( + "doctest_docutils_namespace_items", + _ITEMS_HELP, + default=DEFAULT_NAMESPACE_ITEMS, + ) + + +def _resolve_namespace_scope( + cli_value: str | None, + ini_value: str | None, +) -> NamespaceScope: + """Resolve the namespace scope: command line first, then ini, then default. + + Parameters + ---------- + cli_value : str | None + Value of ``--doctest-docutils-namespace-scope``, `None` when unset. + ini_value : str | None + Value of the ``doctest_docutils_namespace_scope`` ini option. + + Returns + ------- + doctest_docutils.NamespaceScope + Scope to build the finder with. + + Raises + ------ + pytest.UsageError + If either value names a scope that does not exist. + + Examples + -------- + >>> _resolve_namespace_scope(None, None) + 'block' + + >>> _resolve_namespace_scope(None, "document") + 'document' + + One run can narrow a project that shares each page, without editing the + configuration everyone else reads: + + >>> _resolve_namespace_scope("block", "document") + 'block' + + A name that no scope answers to stops the session once, rather than + failing every file it collects, and says where the name was written — + argparse already names the flag, so only the ini file needs saying: + + >>> try: + ... _resolve_namespace_scope(None, "per-file") + ... except pytest.UsageError as exc: + ... print(exc) + Unknown namespace scope: 'per-file'. Expected one of: block, document + Set by the doctest_docutils_namespace_scope ini option. + """ + value = cli_value or ini_value or DEFAULT_NAMESPACE_SCOPE + try: + return _parse_namespace_scope(value) + except NamespaceScopeError as exc: + message = str(exc) + if value == ini_value: + message += "\nSet by the doctest_docutils_namespace_scope ini option." + raise pytest.UsageError(message) from exc + + +def _resolve_namespace_items( + cli_value: str | None, + ini_value: str | None, +) -> NamespaceItems: + """Resolve the namespace layout: command line first, then ini, then default. + + Parameters + ---------- + cli_value : str | None + Value of ``--doctest-docutils-namespace-items``, `None` when unset. + ini_value : str | None + Value of the ``doctest_docutils_namespace_items`` ini option. + + Returns + ------- + doctest_docutils.NamespaceItems + Layout to build the finder with. + + Raises + ------ + pytest.UsageError + If either value names a layout that does not exist. + + Examples + -------- + >>> _resolve_namespace_items(None, None) + 'merged' + + >>> _resolve_namespace_items(None, "per-block") + 'per-block' + + One run can merge a project that keeps its blocks apart, without editing + the configuration everyone else reads: + + >>> _resolve_namespace_items("merged", "per-block") + 'merged' + + A name that no layout answers to stops the session once, and says where + the name was written: + + >>> try: + ... _resolve_namespace_items(None, "one-each") + ... except pytest.UsageError as exc: + ... print(exc) + Unknown namespace items: 'one-each'. Expected one of: merged, per-block + Set by the doctest_docutils_namespace_items ini option. + """ + value = cli_value or ini_value or DEFAULT_NAMESPACE_ITEMS + try: + return _parse_namespace_items(value) + except NamespaceItemsError as exc: + message = str(exc) + if value == ini_value: + message += "\nSet by the doctest_docutils_namespace_items ini option." + raise pytest.UsageError(message) from exc def pytest_configure(config: pytest.Config) -> None: @@ -67,15 +266,441 @@ def pytest_configure(config: pytest.Config) -> None: Todo: Find a way to make these plugins cooperate without collecting twice. """ - # Register HIDE eagerly, before collection parses any docstring. The .py - # path delegates to pytest's own DoctestModule (which never calls our - # _get_flag_lookup), so registering it here is what lets a docstring carry - # ``# doctest: +HIDE`` without raising ``invalid option`` at parse time. - _get_hide_flag() + # Resolved once, so a misspelled scope stops the session here instead of + # erroring on every file collected. + config.stash[_NAMESPACE_SCOPE_KEY] = _resolve_namespace_scope( + config.getoption("doctest_docutils_namespace_scope", None), + config.getini("doctest_docutils_namespace_scope"), + ) + config.stash[_NAMESPACE_ITEMS_KEY] = _resolve_namespace_items( + config.getoption("doctest_docutils_namespace_items", None), + config.getini("doctest_docutils_namespace_items"), + ) + # Registered whether or not anything will carry it, so that a project + # running --strict-markers passes without opting into the layout that + # emits the marker. Only when pytest-xdist is absent, though: xdist + # registers the same name itself, and registering it twice lists it twice + # in ``pytest --markers`` for every project, opted in or not. + if not config.pluginmanager.hasplugin("xdist"): + config.addinivalue_line( + "markers", + "xdist_group(name): keep a namespace's blocks on one pytest-xdist" + " worker under --dist loadgroup", + ) if config.pluginmanager.has_plugin("doctest"): config.pluginmanager.set_blocked("doctest") +def _worker_count(specs: Iterable[str]) -> int: + """Count the execution environments a run's ``--tx`` specifications ask for. + + A specification may stand for more than one environment: ``2*popen`` is + two. pytest-xdist expands the multiplier in ``parse_tx_spec_config`` and + sizes every scheduler on the result, so counting the specifications + themselves would undercount a run that used the shorthand and read a + two-worker session as a one-worker one. + + Reproduced rather than imported because the upstream helper raises when + a run names no environment at all, which is pytest-xdist's question to + answer. Reproduced exactly, quirks included: counting a specification + differently than the run does would size this guard against a session + pytest-xdist laid out another way. + + Parameters + ---------- + specs : Iterable[str] + ``--tx`` specifications, as argparse collected them. + + Returns + ------- + int + Environments the run has behind it. + + Examples + -------- + One specification is usually one environment: + + >>> _worker_count(["popen", "popen"]) + 2 + + A multiplier stands for as many as it says: + + >>> _worker_count(["2*popen"]) + 2 + + >>> _worker_count(["2*popen", "3*popen"]) + 5 + + A specification whose ``*`` is not a count keeps the whole of itself: + + >>> _worker_count(["popen//python=python3.13"]) + 1 + + >>> _worker_count(["popen//chdir=a*b"]) + 1 + + A count asking for no environment takes none away from the run: + + >>> _worker_count(["0*popen"]) + 0 + + >>> _worker_count(["-1*popen", "2*popen"]) + 2 + + >>> _worker_count([]) + 0 + """ + total = 0 + for spec in specs: + # ``find``, not ``partition``: no ``*`` answers -1, and upstream + # reads the count from ``spec[:-1]``. + marker = spec.find("*") + try: + count = int(spec[:marker]) + except ValueError: + total += 1 + else: + # ``[spec] * count`` is empty at or below zero. + total += max(count, 0) + return total + + +def _splitting_scheduler( + items: NamespaceItems, + scheduler: str, + workers: int, +) -> str | None: + """Name the scheduler that would hand one namespace to two workers. + + Parameters + ---------- + items : doctest_docutils.NamespaceItems + Resolved namespace layout. + scheduler : str + Resolved ``--dist`` value. + workers : int + Number of execution environments the run has behind it. + + Returns + ------- + str or None + The scheduler's name when it would split a namespace, else `None`. + + Examples + -------- + >>> _splitting_scheduler("per-block", "load", 2) + 'load' + + >>> _splitting_scheduler("per-block", "worksteal", 4) + 'worksteal' + + A merged namespace is one item, which no scheduler can cut in half: + + >>> _splitting_scheduler("merged", "load", 2) is None + True + + Neither can a scheduler that keeps a file, a group or a scope whole: + + >>> _splitting_scheduler("per-block", "loadfile", 2) is None + True + + >>> _splitting_scheduler("per-block", "loadgroup", 2) is None + True + + Nor a run with nothing to split a namespace between: + + >>> _splitting_scheduler("per-block", "load", 1) is None + True + """ + if items != "per-block": + return None + if workers < 2: + return None + if scheduler in _WHOLE_NAMESPACE_SCHEDULERS: + return None + return scheduler + + +def _shared_page(ids: Iterable[str], globs: Sequence[str]) -> str | None: + """Name the first page a run collected more than one item from. + + A namespace never reaches past the page it was read from, so a page + collecting one item holds its namespace whole and no scheduler can split + it. Two items from one page is the shape a shared mapping needs, and it + is the only shape a controller can see: it is handed node ids, not the + namespaces behind them. + + Parameters + ---------- + ids : Iterable[str] + Node ids a worker collected. + globs : Sequence[str] + ``--doctest-glob`` patterns, which decide what this plugin collects + as a page. + + Returns + ------- + str or None + Path of the first page holding several items, or `None` when a + namespace cannot be split however the run is scheduled. + + Examples + -------- + >>> globs = ["*.rst", "*.md"] + >>> _shared_page(["docs/page.md::page.md[0]", "docs/page.md::page.md[1]"], globs) + 'docs/page.md' + + A page collecting a single item has nothing to hand a second worker: + + >>> _shared_page(["docs/page.md::page.md"], globs) is None + True + + A suite of Python tests holds no page at all, however many items one + module collects — which is what keeps ``-n`` for a project that carries + the layout in its ini and no documentation in its suite: + + >>> _shared_page(["tests/t.py::test_one", "tests/t.py::test_two"], globs) is None + True + + A project that renamed what a page is says so through ``--doctest-glob``: + + >>> _shared_page( + ... ["docs/page.txt::page.txt[0]", "docs/page.txt::page.txt[1]"], + ... ["*.txt"], + ... ) + 'docs/page.txt' + """ + counts = collections.Counter( + page + for page in (node_id.split("::", 1)[0] for node_id in ids) + if _is_page(page, globs) + ) + return next((page for page, held in counts.items() if held > 1), None) + + +def _is_page(path: str, globs: Sequence[str]) -> bool: + """Say whether a node id's path is a file this plugin collects as a page. + + Mirrors what the collector accepts, so the two cannot drift into a run + scheduled as though a page were a Python module. + + Parameters + ---------- + path : str + Path part of a node id, always written with forward slashes. + globs : Sequence[str] + ``--doctest-glob`` patterns. + + Returns + ------- + bool + `True` when the path names a page. + + Examples + -------- + >>> _is_page("docs/page.md", ["*.rst", "*.md"]) + True + + >>> _is_page("tests/test_plugin.py", ["*.rst", "*.md"]) + False + + reStructuredText and Markdown stay pages whatever the patterns say, + because a file named on the command line is collected on its suffix + alone: + + >>> _is_page("docs/page.rst", ["*.txt"]) + True + + >>> _is_page("docs/page.txt", ["*.txt"]) + True + """ + page = pathlib.PurePosixPath(path) + if page.suffix in _PAGE_SUFFIXES: + return True + return any(page.match(glob) for glob in globs) + + +@pytest.hookimpl(hookwrapper=True) +def pytest_cmdline_main(config: pytest.Config) -> Generator[None, None, None]: + """Record whether the run named a ``--dist`` scheduler, before xdist rewrites it. + + pytest-xdist promotes ``-n`` to ``--dist load`` inside its own + ``pytest_cmdline_main``, after which a promoted ``load`` and a typed + ``--dist load`` are the same string and nothing downstream can tell them + apart. Reading the value first is what separates them, and reading it + from a wrapper is what makes that reliable: pluggy enters every wrapper + before it calls any implementation, so this does not race pytest-xdist + for the value. Marking it ``tryfirst`` would only tie — pytest-xdist + marks its own implementation ``tryfirst`` too, leaving plugin + registration order to break it. + + What it sees is argparse's own result, which is ``no`` unless the run + asked for a scheduler. That covers both places a run can ask from: + pytest splices ini ``addopts`` into the arguments before parsing them, + so a ``--dist`` written there reaches argparse exactly as one typed on + the command line does — unlike ``sys.argv``, which never shows it. + + ``-d`` is the same request spelled short, and is read as one. ``--dist + no`` alongside ``-n`` is not a choice pytest-xdist keeps, so it is not + one this keeps either. + + Parameters + ---------- + config : pytest.Config + Configuration whose options argparse has filled in. + + Yields + ------ + None + Once, to run the implementations this wraps. + """ + config.stash[_DIST_NAMED_KEY] = config.getoption("dist", "no") != "no" or bool( + config.getoption("distload", False), + ) + yield + + +@pytest.hookimpl(optionalhook=True) +def pytest_xdist_make_scheduler(config: pytest.Config, log: t.Any) -> t.Any: + """Keep a shared namespace whole when the run left the scheduler open. + + ``-n`` on its own is a request for workers, not for a way of filling + them, and pytest-xdist answers it with ``--dist load``, which hands a + file's items to whichever worker is free. Under ``per-block`` that can + put the block binding a name and the block reading it on different + workers. Where the run expressed no preference, this fills it in with + file-level scheduling rather than failing: a namespace never reaches + past its page, so keeping a page whole keeps every namespace in it + whole. + + Only a page is kept whole. Everything + else keeps a scope of its own, so a suite whose Python tests happen to + share a file still spreads across the workers it asked for. + + ``loadgroup`` is not the substitute to make: it reads a group off the node + id, and that suffix is written by the *worker*, from the worker's own + ``--dist`` value, so nothing the controller decides here reaches it. + File-level scheduling also leaves node ids untouched, which a group suffix + would not. + + A run that named its scheduler is left alone, whatever it named. + + Parameters + ---------- + config : pytest.Config + Configuration carrying the resolved layout and ``--dist`` value. + log : Any + pytest-xdist ``Producer`` the scheduler logs through. + + Returns + ------- + Any + A scheduler keeping each page whole when it is standing in, else + `None` to leave the choice to pytest-xdist. + """ + if config.stash.get(_DIST_NAMED_KEY, True): + return None + if not _splitting_scheduler( + config.stash[_NAMESPACE_ITEMS_KEY], + config.getoption("dist", "no"), + _worker_count(config.getoption("tx", None) or []), + ): + return None + from xdist.scheduler import ( # type: ignore[import-untyped,unused-ignore] + LoadScopeScheduling, + ) + + globs = config.getoption("doctestglob") or ["*.rst", "*.md"] + + class _PageScheduling(LoadScopeScheduling): # type: ignore[misc] + """Keep a page's items together, and spread everything else.""" + + def _split_scope(self, nodeid: str) -> str: + path = nodeid.split("::", 1)[0] + return path if _is_page(path, globs) else nodeid + + return _PageScheduling(config, log) + + +@pytest.hookimpl(optionalhook=True) +def pytest_xdist_node_collection_finished(node: t.Any, ids: Sequence[str]) -> None: + """Stop a run whose named scheduler would split a page this suite holds. + + Reached only when the run asked for ``--dist load`` or ``--dist + worksteal`` itself. Choosing a scheduler by name is not something a + plugin should quietly overrule, so the session stops and says why rather + than reporting a page that is only wrong because of how it was + scheduled — a shared globals mapping is a Python object, and half a + namespace on a worker reads as a ``NameError`` in the page. + + Read here because here is the first moment a controller knows what the + run actually holds: it never collects itself, and a worker's collection + arrives as node ids. A suite with no page among them has no namespace to + protect, so it keeps its workers. + + Parameters + ---------- + node : Any + pytest-xdist ``WorkerController`` that finished collecting. + ids : Sequence[str] + Node ids it collected. + + Raises + ------ + pytest.UsageError + If a page the run holds would be split between workers. + """ + config = node.config + if not config.stash.get(_DIST_NAMED_KEY, True): + # Left open, so a scheduler that keeps a page whole stood in. + return + scheduler = _splitting_scheduler( + config.stash[_NAMESPACE_ITEMS_KEY], + config.getoption("dist", "no"), + _worker_count(config.getoption("tx", None) or []), + ) + if scheduler is None: + return + page = _shared_page(ids, config.getoption("doctestglob") or ["*.rst", "*.md"]) + if page is None: + return + message = ( + "doctest_docutils_namespace_items = per-block can hand a namespace's" + " blocks one globals mapping between them — a page declaring a group" + " does, whatever the scope — and a mapping cannot cross processes." + f" --dist {scheduler} hands a file's items to whichever worker is" + f" free, so it can send {page}'s blocks to different workers. Run" + " with --dist loadgroup or --dist loadfile, or set" + " doctest_docutils_namespace_items = merged. Dropping --dist leaves" + " -n free to keep each page on one worker." + ) + raise pytest.UsageError(message) + + +def pytest_report_header(config: pytest.Config) -> str | None: + """Say how namespaces are laid out, when they are not laid out as usual. + + A run that changed nothing reports nothing, so the header of an + unconfigured project reads as it always has. + + Parameters + ---------- + config : pytest.Config + Configuration holding the resolved settings. + + Returns + ------- + str or None + One line naming the layout and the scope, or `None` under the default + layout. + """ + items = config.stash[_NAMESPACE_ITEMS_KEY] + if items == DEFAULT_NAMESPACE_ITEMS: + return None + scope = config.stash[_NAMESPACE_SCOPE_KEY] + return f"doctest-docutils: namespace items: {items}, namespace scope: {scope}" + + def _unblock_doctest(config: pytest.Config) -> bool: """Unblock doctest plugin (pytest 8.1+ only). @@ -160,7 +785,7 @@ def _is_doctest( def _init_runner_class() -> type[doctest.DocTestRunner]: import doctest - class PytestDoctestRunner(doctest.DebugRunner): + class PytestDoctestRunner(_ExecModeRunnerMixin, doctest.DebugRunner): """Runner to collect failures. Note that the out variable in this case is a list instead of a @@ -173,9 +798,55 @@ def __init__( verbose: bool | None = None, optionflags: int = 0, continue_on_failure: bool = True, + share_globs: bool = False, ) -> None: super().__init__(checker=checker, verbose=verbose, optionflags=optionflags) self.continue_on_failure = continue_on_failure + self.share_globs = share_globs + self._already_run: set[int] = set() + + def run( + self, + test: doctest.DocTest, + compileflags: int | None = None, + out: _Out | None = None, + clear_globs: bool = True, + ) -> doctest.TestResults: + """Run one test, keeping its globals when its namespace shares them. + + ``clear_globs`` empties ``test.globs`` once the test is done, which + is what stops one item's bindings reaching the next. A namespace + laid out per block wants exactly that reach: its items hold one + mapping between them, so the block below reads what this one bound. + + That reach is also why a block cannot be run twice. Anything that + repeats one item — a retry plugin, ``--count`` — would run it again + against the mapping it already changed, and an expectation that + comes true the second time would be reported as a pass. There is no + way to rebuild the namespace for one block alone, so the repeat is + refused instead. + """ + if self.share_globs: + if id(test) in self._already_run: + import pytest + + pytest.fail( + f"{test.name} was run twice against a namespace laid " + "out per block. A repeated block runs against the " + "globals it already changed, so its result cannot be " + "trusted. Drop --reruns (and anything else that " + "repeats an item), or set " + "doctest_docutils_namespace_items = merged, which " + "re-runs a namespace from its first block.", + pytrace=False, + ) + self._already_run.add(id(test)) + return super().run( + test, + compileflags, + out, + clear_globs and not self.share_globs, + ) def report_failure( self, @@ -238,17 +909,13 @@ def _get_number_flag() -> int: def _get_hide_flag() -> int: - """Register and return the HIDE flag. + """Return the HIDE flag, registered by importing :mod:`doctest_docutils`. ``HIDE`` is a no-op for execution: the output checker never consults it. It marks a doctest example that documentation tooling should drop from the - rendered output while still running it as a test. Registering it here means - ``# doctest: +HIDE`` parses instead of raising ``ValueError: invalid - option`` at collection time. + rendered output while still running it as a test. """ - import doctest - - return doctest.register_optionflag("HIDE") + return _HIDE_FLAG def _get_flag_lookup() -> dict[str, int]: @@ -300,6 +967,7 @@ def _get_runner( verbose: bool | None = None, optionflags: int = 0, continue_on_failure: bool = True, + share_globs: bool = False, ) -> doctest.DocTestRunner: # We need this in order to do a lazy import on doctest global RUNNER_CLASS @@ -312,10 +980,11 @@ def _get_runner( verbose=verbose, optionflags=optionflags, continue_on_failure=continue_on_failure, + share_globs=share_globs, ) -class DocutilsDocTestRunner(doctest.DocTestRunner): +class DocutilsDocTestRunner(_ExecModeRunnerMixin, doctest.DocTestRunner): """DocTestRunner for doctest_docutils.""" def summarize( # type: ignore @@ -354,6 +1023,58 @@ def _DocTestRunner__patched_linecache_getlines( return self.save_linecache_getlines(filename, module_globals) # type: ignore +def _wholly_skipped_reason(test: doctest.DocTest) -> str | None: + r"""Return why a test is skipped outright, or `None` when it runs something. + + Parameters + ---------- + test : doctest.DocTest + Collected test, one namespace or one block lifted out of it. + + Returns + ------- + str or None + Reason naming the page and the first line skipped, or `None`. + + Examples + -------- + >>> import doctest + >>> parser = doctest.DocTestParser() + >>> running = parser.get_doctest(">>> 2 + 2\n4\n", {}, "page", "page.rst", 3) + >>> _wholly_skipped_reason(running) is None + True + + >>> gated = parser.get_doctest( + ... ">>> 2 + 2 # doctest: +SKIP\n4\n", {}, "page", "page.rst", 3 + ... ) + >>> _wholly_skipped_reason(gated) + 'page.rst:4: every example skipped' + + The page is named, not the path it resolves, which pytest prints beside the + reason already: + + >>> nested = parser.get_doctest( + ... ">>> 2 + 2 # doctest: +SKIP\n4\n", {}, "page", "docs/a/page.rst", 3 + ... ) + >>> _wholly_skipped_reason(nested) + 'page.rst:4: every example skipped' + + A block holding no example at all skips nothing, which is not the same + answer as every example being skipped — ``all([])`` is `True`: + + >>> empty = parser.get_doctest("prose only\n", {}, "page", "page.rst", 3) + >>> _wholly_skipped_reason(empty) is None + True + """ + if not test.examples: + return None + if not all(example.options.get(doctest.SKIP, False) for example in test.examples): + return None + line = (test.lineno or 0) + test.examples[0].lineno + 1 + page = pathlib.Path(test.filename or "").name + return f"{page}:{line}: every example skipped" + + class DocTestDocutilsFile(pytest.Module): """Pytest module for doctest_docutils.""" @@ -366,8 +1087,14 @@ def collect(self) -> Iterable[DoctestItem]: encoding = self.config.getini("doctest_encoding") text = self.path.read_text(encoding) + namespace_items = self.config.stash[_NAMESPACE_ITEMS_KEY] + per_block = namespace_items == "per-block" + # Uses internal doctest module parsing mechanism. - finder = DocutilsDocTestFinder() + finder = DocutilsDocTestFinder( + namespace_scope=self.config.stash[_NAMESPACE_SCOPE_KEY], + namespace_items=namespace_items, + ) # While doctests in .rst/.md files don't support fixtures directly, # we still need to pick up autouse fixtures. @@ -382,17 +1109,39 @@ def collect(self) -> Iterable[DoctestItem]: optionflags=optionflags, checker=_pytest.doctest._get_checker(), continue_on_failure=_pytest.doctest._get_continue_on_failure(self.config), + share_globs=per_block, ) from _pytest.doctest import DoctestItem - for test in finder.find( + for collected in finder._collect( text, str(self.path), ): + test = collected.test if test.examples: # skip empty doctests - yield DoctestItem.from_parent( + item = DoctestItem.from_parent( self, # type: ignore name=test.name, runner=runner, dtest=test, ) + if per_block: + # pytest-xdist reads this on the worker and suffixes the + # node id with the group, so --dist loadgroup keeps a + # namespace whole. Only that scheduler reads it: the + # suffix is written from the worker's own --dist value, + # so a controller standing a scheduler in cannot rely on + # it and groups by file instead. + item.add_marker( + pytest.mark.xdist_group( + f"{self.nodeid}::{collected.namespace}", + ), + ) + reason = _wholly_skipped_reason(test) + if reason is not None: + # Marked rather than left to _check_all_skipped, which only + # fires once the item is running: by then its fixtures have + # set up for a test that executes nothing. A marker is read + # before setup, and it carries a reason naming the block. + item.add_marker(pytest.mark.skip(reason=reason)) + yield item diff --git a/tests/regressions/test_autouse_fixtures.py b/tests/regressions/test_autouse_fixtures.py index 1058619..d887e7d 100644 --- a/tests/regressions/test_autouse_fixtures.py +++ b/tests/regressions/test_autouse_fixtures.py @@ -134,3 +134,61 @@ def get_value(): result = pytester.runpytest(str(test_file)) result.assert_outcomes(passed=1) + + +def test_a_module_scoped_fixture_spans_a_shared_page( + pytester: _pytest.pytester.Pytester, +) -> None: + """A page is what ``scope="module"`` means, across every block of it. + + :class:`~pytest_doctest_docutils.DocTestDocutilsFile` collects a page as a + :class:`pytest.Module`, which is the node pytest resolves module scope + against. That is what lets a page carry an object its blocks derived from a + fixture: one setup for the page, so the object block one saved is still the + object block two reads. + + The case above runs a single block, which passes whether the fixture spans + the page or sets up per item. This one fails if the lifetime ever narrows + back to the block. + """ + pytester.plugins = ["pytest_doctest_docutils"] + pytester.makeconftest( + textwrap.dedent( + """ +import pytest + + +@pytest.fixture(scope="module") +def resource(): + yield object() + + +@pytest.fixture(autouse=True) +def seed(doctest_namespace, resource): + doctest_namespace["resource"] = resource + """, + ), + ) + (pytester.path / "page.md").write_text( + textwrap.dedent( + """ +``` +>>> saved = resource +``` + +``` +>>> saved is resource +True +``` + """, + ), + encoding="utf-8", + ) + + result = pytester.runpytest( + str(pytester.path / "page.md"), + "--doctest-docutils-namespace-scope=document", + "--doctest-docutils-namespace-items=per-block", + ) + + result.assert_outcomes(passed=2) diff --git a/tests/test_doctest_docutils.py b/tests/test_doctest_docutils.py index 42807e1..623de48 100644 --- a/tests/test_doctest_docutils.py +++ b/tests/test_doctest_docutils.py @@ -2,7 +2,10 @@ from __future__ import annotations +import contextlib import doctest +import io +import logging import textwrap import typing as t @@ -252,6 +255,63 @@ def test_DocutilsDocTestFinder( doctest.DebugRunner(verbose=False).run(test) +class DocumentOrderFixture(t.NamedTuple): + """Page of eleven numbered blocks, enough for name order to diverge. + + Attributes + ---------- + test_id : str + pytest parametrize id. + file_name : str + Page name, whose suffix picks the parser. + page : str + Page content: block ``n`` evaluates to ``n``. + """ + + test_id: str + file_name: str + page: str + + +DOCUMENT_ORDER_FIXTURES = [ + DocumentOrderFixture( + test_id="MyST-fences", + file_name="example.md", + page="\n".join(f"```python\n>>> {n}\n{n}\n```\n" for n in range(11)), + ), + DocumentOrderFixture( + test_id="reST-doctest_blocks", + file_name="example.rst", + page="\n".join(f">>> {n}\n{n}\n" for n in range(11)), + ), +] + + +@pytest.mark.parametrize( + DocumentOrderFixture._fields, + DOCUMENT_ORDER_FIXTURES, + ids=[f.test_id for f in DOCUMENT_ORDER_FIXTURES], +) +def test_finder_collects_in_document_order( + tmp_path: pathlib.Path, + test_id: str, + file_name: str, + page: str, +) -> None: + """Blocks come back in the order a reader meets them, not in name order. + + Sorting by name put ``page.md[10]`` ahead of ``page.md[1]``. + """ + page_path = tmp_path / file_name + page_path.write_text(page, encoding="utf-8") + + tests = doctest_docutils.DocutilsDocTestFinder().find(page, str(page_path)) + + assert [test.examples[0].source.strip() for test in tests] == [ + str(n) for n in range(11) + ] + + class DoctestOptReTestCase(t.NamedTuple): """Test fixture for doctestopt_re regex. @@ -336,3 +396,2663 @@ def test_docutils_package_relative_error_message() -> None: exc = doctest_docutils.TestDocutilsPackageRelativeError() assert str(exc) == "Package may only be specified for module-relative paths." + + +def test_inline_flags_survive_a_directive(tmp_path: pathlib.Path) -> None: + """A ``# doctest:`` flag applies even where the rendered code drops it. + + ``.. doctest::`` trims the flag out of the code a reader sees and keeps the + original on the node, so the finder has to read the original. + """ + page = textwrap.dedent( + """ +.. doctest:: + + >>> print("a b") # doctest: +NORMALIZE_WHITESPACE + a b + """, + ) + page_path = tmp_path / "page.rst" + page_path.write_text(page, encoding="utf-8") + + (test,) = doctest_docutils.DocutilsDocTestFinder().find(page, str(page_path)) + runner = doctest.DocTestRunner(verbose=False) + runner.run(test, out=lambda _: None) + + assert test.examples[0].options[doctest.NORMALIZE_WHITESPACE] is True + assert runner.failures == 0 + + +class DirectiveOptionFixture(t.NamedTuple): + """Directive whose options reach the examples it holds. + + Attributes + ---------- + test_id : str + pytest parametrize id. + page : str + reStructuredText page holding one ``.. doctest::`` directive. + flag : int + Option flag to read off the collected example. + enabled : bool + Whether the flag is expected on. + """ + + test_id: str + page: str + flag: int + enabled: bool + + +DIRECTIVE_OPTION_FIXTURES = [ + DirectiveOptionFixture( + test_id="directive-options-reach-the-example", + page=".. doctest::\n :options: +ELLIPSIS\n\n >>> 2 + 2\n 4\n", + flag=doctest.ELLIPSIS, + enabled=True, + ), + DirectiveOptionFixture( + test_id="an-inline-flag-beats-the-directive", + page=".. doctest::\n :options: +ELLIPSIS\n\n" + " >>> 2 + 2 # doctest: -ELLIPSIS\n 4\n", + flag=doctest.ELLIPSIS, + enabled=False, + ), +] + + +@pytest.mark.parametrize( + DirectiveOptionFixture._fields, + DIRECTIVE_OPTION_FIXTURES, + ids=[f.test_id for f in DIRECTIVE_OPTION_FIXTURES], +) +def test_directive_options_apply_per_example( + test_id: str, + page: str, + flag: int, + enabled: bool, +) -> None: + """``:options:`` sets a block's defaults; an example's own flags win.""" + (test,) = doctest_docutils.DocutilsDocTestFinder().find(page, "page.rst") + + assert test.examples[0].options[flag] is enabled + + +OUT_OF_ORDER_LINES_REST = [ + ( + "nested-in-a-directive", + textwrap.dedent( + """ +Title +===== + +>>> outer = 1 + +.. note:: + + >>> outer + 1 + 2 + """, + ), + ), + ( + "nested-in-list-items", + textwrap.dedent( + """ +Title +===== + +- First item: + + >>> counted = 1 + +- Second item: + + >>> counted + 1 + 2 + """, + ), + ), +] + + +@pytest.mark.parametrize( + ("test_id", "page"), + OUT_OF_ORDER_LINES_REST, + ids=[test_id for test_id, _ in OUT_OF_ORDER_LINES_REST], +) +def test_a_nested_block_collects( + tmp_path: pathlib.Path, + test_id: str, + page: str, +) -> None: + """A doctest block nested in another node is collected, not fatal. + + docutils leaves ``line`` unset on a block inside a directive, a list item, + or a block quote, and reading it as a number took the whole page down. + """ + page_path = tmp_path / "page.rst" + page_path.write_text(page, encoding="utf-8") + + tests = doctest_docutils.DocutilsDocTestFinder().find(page, str(page_path)) + + linenos = [test.lineno or 0 for test in tests] + + assert linenos == sorted(linenos) + assert all(lineno > 0 for lineno in linenos) + + +class SkipifFixture(t.NamedTuple): + """Directive whose ``:skipif:`` decides whether its block runs. + + Attributes + ---------- + test_id : str + pytest parametrize id. + expression : str + Expression written on the directive's ``:skipif:`` option. + skipped : bool + Whether the block is expected to carry ``SKIP``. + """ + + test_id: str + expression: str + skipped: bool + + +SKIPIF_FIXTURES = [ + SkipifFixture(test_id="true-skips-the-block", expression="True", skipped=True), + SkipifFixture(test_id="false-runs-the-block", expression="False", skipped=False), + SkipifFixture( + test_id="expression-sees-the-starting-globals", + expression="__name__ == 'nonesuch'", + skipped=False, + ), + SkipifFixture( + test_id="expression-sees-sys", + expression="sys.version_info < (3, 10)", + skipped=False, + ), +] + + +@pytest.mark.parametrize( + SkipifFixture._fields, + SKIPIF_FIXTURES, + ids=[f.test_id for f in SKIPIF_FIXTURES], +) +def test_skipif_marks_its_block_skip( + test_id: str, + expression: str, + skipped: bool, +) -> None: + """A true ``:skipif:`` marks its block ``SKIP`` rather than dropping it. + + Both spellings of "do not run this" land on the same flag, so the block + stays collectable, countable, and selectable by node id either way. + """ + page = f".. doctest::\n :skipif: {expression}\n\n >>> 2 + 2\n 4\n" + + (test,) = doctest_docutils.DocutilsDocTestFinder().find(page, "page.rst") + + assert test.examples[0].options.get(doctest.SKIP, False) is skipped + + +GATED_MIDDLE_BLOCK_REST = textwrap.dedent( + """ + .. doctest:: intro + + >>> greeting = "hello" + + .. doctest:: intro + :skipif: True + + >>> raise AssertionError("the skipped block ran") + + .. doctest:: intro + + >>> greeting.upper() + 'HELLO' + """, +) + + +def test_skipif_skips_only_its_own_block_of_a_group() -> None: + """A group's other blocks keep running when one of them is skipped. + + The gated block binds nothing its group could read, so it comes back on + its own rather than merged into a group that runs without it. What is + left of the group carries no flag, and the block that does still holds + the source it would have run. + """ + group, gated = doctest_docutils.DocutilsDocTestFinder().find( + GATED_MIDDLE_BLOCK_REST, + "page.rst", + ) + + assert [test.name for test in (group, gated)] == ["intro", "intro[1]"] + assert [example.options.get(doctest.SKIP, False) for example in group.examples] == [ + False, + False, + ] + assert [example.options[doctest.SKIP] for example in gated.examples] == [True] + + +def test_lifting_a_gated_block_moves_no_reported_line() -> None: + """Every example reports the line it reports with the gate turned off. + + The lifted block is positioned where docutils put it, so a reader told it + was skipped is pointed at the same place the group would have pointed. + """ + finder = doctest_docutils.DocutilsDocTestFinder() + + def reported(page: str) -> list[int]: + return sorted( + (test.lineno or 0) + example.lineno + 1 + for test in finder.find(page, "page.rst") + for example in test.examples + ) + + assert reported(GATED_MIDDLE_BLOCK_REST) == reported( + GATED_MIDDLE_BLOCK_REST.replace(":skipif: True", ":skipif: False"), + ) + + +class GateSpellingFixture(t.NamedTuple): + """One way of writing "do not run this block", and the block it writes. + + Attributes + ---------- + test_id : str + pytest parametrize id. + block : str + Middle block of a three-block group, gated its own way. + """ + + test_id: str + block: str + + +GATE_SPELLING_FIXTURES = [ + GateSpellingFixture( + test_id="skipif-condition", + block=".. doctest:: intro\n :skipif: True\n\n >>> 1 / 0\n", + ), + GateSpellingFixture( + test_id="directive-options-flag", + block=".. doctest:: intro\n :options: +SKIP\n\n >>> 1 / 0\n", + ), + GateSpellingFixture( + test_id="inline-flag", + block=".. doctest:: intro\n\n >>> 1 / 0 # doctest: +SKIP\n", + ), + GateSpellingFixture( + test_id="every-example-inline", + block=( + ".. doctest:: intro\n\n >>> 1 / 0 # doctest: +SKIP\n" + " >>> 2 / 0 # doctest: +SKIP\n" + ), + ), +] + + +@pytest.mark.parametrize( + GateSpellingFixture._fields, + GATE_SPELLING_FIXTURES, + ids=[f.test_id for f in GATE_SPELLING_FIXTURES], +) +def test_every_spelling_of_a_gate_lifts_its_block_out( + test_id: str, + block: str, +) -> None: + """A block is lifted out for what its examples carry, not how it says it. + + A condition, a directive flag, and an inline comment all land on + :data:`doctest.SKIP`, so a reader who knows one can predict the others. + """ + page = ( + ".. doctest:: intro\n\n >>> greeting = 'hello'\n\n" + f"{block}\n" + ".. doctest:: intro\n\n >>> greeting.upper()\n 'HELLO'\n" + ) + + group, gated = doctest_docutils.DocutilsDocTestFinder().find(page, "page.rst") + runner = doctest.DocTestRunner(verbose=False) + for test in (group, gated): + runner.run(test, out=lambda _: None) + + assert [test.name for test in (group, gated)] == ["intro", "intro[1]"] + assert runner.failures == 0 + + +def test_a_half_gated_block_stays_in_its_namespace() -> None: + """A block with one example left to run is not a skipped block. + + Its silence is the silence pytest keeps for any partly skipped item, and + the example that runs may bind a name the rest of the group reads. + """ + page = ( + ".. doctest:: intro\n\n" + " >>> greeting = 'hello' # doctest: +SKIP\n" + " >>> greeting = 'hi'\n\n" + ".. doctest:: intro\n\n >>> greeting\n 'hi'\n" + ) + + tests = doctest_docutils.DocutilsDocTestFinder().find(page, "page.rst") + runner = doctest.DocTestRunner(verbose=False) + for test in tests: + runner.run(test, out=lambda _: None) + + assert [test.name for test in tests] == ["intro"] + assert runner.failures == 0 + + +def test_a_namespace_gated_end_to_end_stays_whole() -> None: + """A namespace with nothing left to run keeps every block it holds. + + One test reports the skip once. Lifting each block out would report the + same page N times, which is noise, not information. + """ + page = ( + ".. doctest:: solo\n :skipif: True\n\n >>> 1 / 0\n\n" + ".. doctest:: solo\n :options: +SKIP\n\n >>> 2 / 0\n" + ) + + tests = doctest_docutils.DocutilsDocTestFinder().find(page, "page.rst") + + assert [test.name for test in tests] == ["solo"] + assert all( + example.options[doctest.SKIP] for test in tests for example in test.examples + ) + + +def test_a_shared_page_names_a_gated_block_as_block_scope_does() -> None: + """The node id that selects a gated block does not move with the scope. + + Under ``document`` the page is one namespace named for the page, so a + block lifted back out of it lands on the name it carries when every block + keeps its own namespace. + """ + page = textwrap.dedent( + """ + ```python + >>> value = 1 + ``` + + ```python + >>> value = 999 # doctest: +SKIP + ``` + + ```python + >>> value + 1 + ``` + """, + ) + + shared = doctest_docutils.DocutilsDocTestFinder(namespace_scope="document") + apart = doctest_docutils.DocutilsDocTestFinder(namespace_scope="block") + + assert [test.name for test in shared.find(page, "page.md")] == [ + "page.md", + "page.md[1]", + ] + assert [test.name for test in apart.find(page, "page.md")] == [ + "page.md[0]", + "page.md[1]", + "page.md[2]", + ] + + +def test_an_inline_flag_cannot_reopen_a_true_skipif() -> None: + """An example's own ``-SKIP`` loses to a condition, unlike to ``:options:``. + + ``sphinx.ext.doctest`` drops a gated block before its source is read, so + nothing written inside one can turn the gate off. An example that could + would run on exactly the interpreter or platform it was guarded against. + """ + page = ".. doctest::\n :skipif: True\n\n >>> 2 + 2 # doctest: -SKIP\n 4\n" + + (test,) = doctest_docutils.DocutilsDocTestFinder().find(page, "page.rst") + + assert test.examples[0].options[doctest.SKIP] is True + + +SKIPPED_SETUP_DIRECTIVES = [ + ("testsetup", "testsetup"), + ("testcleanup", "testcleanup"), +] + + +@pytest.mark.parametrize( + ("test_id", "directive"), + SKIPPED_SETUP_DIRECTIVES, + ids=[test_id for test_id, _ in SKIPPED_SETUP_DIRECTIVES], +) +def test_skipif_marks_setup_and_cleanup_blocks_skip( + test_id: str, + directive: str, +) -> None: + """``:skipif:`` reaches the setup and cleanup directives that declare it. + + Both list ``skipif`` in their ``option_spec``, so the option is not a + ``.. doctest::`` exclusive and has to behave the same on all three. A + gated one comes back on its own, as any gated block does, so a group whose + setup never ran says so. + """ + page = ( + f".. {directive}:: fixture\n :skipif: True\n\n" + " >>> raise AssertionError('the skipped block ran')\n\n" + ".. doctest:: fixture\n\n >>> 2 + 2\n 4\n" + ) + + gated, group = doctest_docutils.DocutilsDocTestFinder().find(page, "page.rst") + + assert [test.name for test in (gated, group)] == ["fixture[0]", "fixture"] + assert [example.options.get(doctest.SKIP, False) for example in group.examples] == [ + False, + ] + assert [example.options[doctest.SKIP] for example in gated.examples] == [True] + + +SKIPIF_STANDALONE_PAGE = textwrap.dedent( + """ + Standalone + ========== + + .. doctest:: + :skipif: True + + >>> 1 / 0 + + .. doctest:: + + >>> 2 + 2 + 4 + """, +) + + +def test_skipif_under_testdocutils(tmp_path: pathlib.Path) -> None: + """The standalone runner skips the block instead of never seeing it. + + :class:`doctest.DocTestRunner` honours ``SKIP`` itself, so the library + stays usable without pytest and the skipped example is never executed. + """ + page = tmp_path / "page.rst" + page.write_text(SKIPIF_STANDALONE_PAGE, encoding="utf-8") + + results = doctest_docutils.testdocutils( + str(page), + module_relative=False, + report=False, + ) + + assert results.failed == 0 + + +class StandaloneExitFixture(t.NamedTuple): + """Page run through the ``python -m doctest_docutils`` entry point. + + Attributes + ---------- + test_id : str + pytest parametrize id. + page : str + reStructuredText source written to the temporary page. + exit_code : int + Status ``doctest_docutils._test`` is expected to return. + """ + + test_id: str + page: str + exit_code: int + + +STANDALONE_EXIT_FIXTURES = [ + StandaloneExitFixture( + test_id="a-skipped-block-alone-passes", + page=SKIPIF_STANDALONE_PAGE, + exit_code=0, + ), + StandaloneExitFixture( + test_id="a-real-failure-beside-it-still-fails", + page=SKIPIF_STANDALONE_PAGE.replace( + " >>> 2 + 2\n 4\n", " >>> 2 + 2\n 5\n" + ), + exit_code=1, + ), +] + + +@pytest.mark.parametrize( + StandaloneExitFixture._fields, + STANDALONE_EXIT_FIXTURES, + ids=[f.test_id for f in STANDALONE_EXIT_FIXTURES], +) +def test_skipif_exit_code_from_the_command( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + test_id: str, + page: str, + exit_code: int, +) -> None: + """``python -m doctest_docutils`` exits non-zero only on a real failure.""" + page_path = tmp_path / "page.rst" + page_path.write_text(page, encoding="utf-8") + monkeypatch.setattr("sys.argv", ["doctest_docutils", str(page_path)]) + + assert doctest_docutils._test() == exit_code + + assert "ZeroDivisionError" not in capsys.readouterr().out + + +def test_skipif_that_cannot_be_evaluated_names_its_block() -> None: + """An expression naming something out of reach reports as that block. + + The namespace a ``:skipif:`` sees is small on purpose, so reaching outside + it is an ordinary mistake; the report has to say which block to go fix. + """ + page = ( + "Title\n=====\n\n.. doctest::\n" + ' :skipif: platform.system() == "Windows"\n\n >>> 2 + 2\n 4\n' + ) + + with pytest.raises(doctest_docutils.SkipifExpressionError) as excinfo: + doctest_docutils.DocutilsDocTestFinder().find(page, "page.rst") + + assert str(excinfo.value) == ( + "page.rst:4: :skipif: 'platform.system() == \"Windows\"' failed: " + "name 'platform' is not defined" + ) + + +def test_hide_optionflag_parses_without_pytest() -> None: + """``+HIDE`` parses wherever :mod:`doctest_docutils` is imported. + + The flag is gp-libs' own, and a page carrying an unregistered name fails to + parse, so registering it only as pytest configures left the standalone + ``python -m doctest_docutils`` command unable to read the repo's own pages. + """ + page = ( + ".. doctest::\n\n >>> base = 40 # doctest: +HIDE\n" + " >>> base + 2\n 42\n" + ) + + (test,) = doctest_docutils.DocutilsDocTestFinder().find(page, "page.rst") + + assert test.examples[0].options[doctest_docutils._HIDE_FLAG] is True + + +STATE_MD = textwrap.dedent( + """ +# Title + +```python +>>> greeting = "hello" +>>> greeting +'hello' +``` + +Narrative prose between the two blocks. + +```python +>>> greeting.upper() +'HELLO' +``` + """, +) + +SHARED_GROUP_REST = textwrap.dedent( + """ +Title +===== + +.. doctest:: intro + + >>> greeting = "hello" + +Narrative prose. + +.. doctest:: intro + + >>> greeting.upper() + 'HELLO' + """, +) + +DISTINCT_GROUPS_REST = textwrap.dedent( + """ +Title +===== + +.. doctest:: alpha + + >>> alpha_only = 1 + +.. doctest:: beta + + >>> alpha_only + Traceback (most recent call last): + NameError: name 'alpha_only' is not defined + """, +) + + +class NamespaceFixture(t.NamedTuple): + """Page whose blocks land in one namespace or in several. + + Attributes + ---------- + test_id : str + pytest parametrize id. + file_name : str + Page name, whose suffix picks the parser. + page : str + Page content. + namespace_scope : doctest_docutils.NamespaceScope + Scope the finder is built with. + test_names : list[str] + Test names ``find`` returns, in order. + example_sources : list[list[str]] + Example sources per returned test, in document order. + """ + + test_id: str + file_name: str + page: str + namespace_scope: doctest_docutils.NamespaceScope + test_names: list[str] + example_sources: list[list[str]] + + +NAMESPACE_FIXTURES = [ + NamespaceFixture( + test_id="ungrouped-fences-stay-apart-by-default", + file_name="page.md", + page=STATE_MD, + namespace_scope="block", + test_names=["page.md[0]", "page.md[1]"], + example_sources=[['greeting = "hello"', "greeting"], ["greeting.upper()"]], + ), + NamespaceFixture( + test_id="ungrouped-fences-share-the-page-under-document", + file_name="page.md", + page=STATE_MD, + namespace_scope="document", + test_names=["page.md"], + example_sources=[ + ['greeting = "hello"', "greeting", "greeting.upper()"], + ], + ), + NamespaceFixture( + test_id="group-shares-by-default", + file_name="page.rst", + page=SHARED_GROUP_REST, + namespace_scope="block", + test_names=["intro"], + example_sources=[['greeting = "hello"', "greeting.upper()"]], + ), + NamespaceFixture( + test_id="group-shares-under-document", + file_name="page.rst", + page=SHARED_GROUP_REST, + namespace_scope="document", + test_names=["intro"], + example_sources=[['greeting = "hello"', "greeting.upper()"]], + ), + NamespaceFixture( + test_id="distinct-groups-partition-the-page", + file_name="page.rst", + page=DISTINCT_GROUPS_REST, + namespace_scope="document", + test_names=["alpha", "beta"], + example_sources=[["alpha_only = 1"], ["alpha_only"]], + ), +] + + +@pytest.mark.parametrize( + NamespaceFixture._fields, + NAMESPACE_FIXTURES, + ids=[f.test_id for f in NAMESPACE_FIXTURES], +) +def test_finder_merges_a_namespace_into_one_test( + tmp_path: pathlib.Path, + test_id: str, + file_name: str, + page: str, + namespace_scope: doctest_docutils.NamespaceScope, + test_names: list[str], + example_sources: list[list[str]], +) -> None: + """A namespace is one test holding its blocks' examples in document order. + + Naming a group is the author asking two blocks to share, so a group shares + at every scope; blocks that name none follow the scope. + """ + page_path = tmp_path / file_name + page_path.write_text(page, encoding="utf-8") + + finder = doctest_docutils.DocutilsDocTestFinder(namespace_scope=namespace_scope) + tests = finder.find(page, str(page_path)) + + assert [test.name for test in tests] == test_names + assert [ + [example.source.strip() for example in test.examples] for test in tests + ] == example_sources + + +class NamespaceStateFixture(t.NamedTuple): + """Page run end to end, counting the examples that fail. + + Attributes + ---------- + test_id : str + pytest parametrize id. + file_name : str + Page name, whose suffix picks the parser. + page : str + Page content. + namespace_scope : doctest_docutils.NamespaceScope + Scope the finder is built with. + failures : int + Examples expected to fail once every test has run. + """ + + test_id: str + file_name: str + page: str + namespace_scope: doctest_docutils.NamespaceScope + failures: int + + +NAMESPACE_STATE_FIXTURES = [ + NamespaceStateFixture( + test_id="second-fence-cannot-read-the-first-by-default", + file_name="page.md", + page=STATE_MD, + namespace_scope="block", + failures=1, + ), + NamespaceStateFixture( + test_id="second-fence-reads-the-first-under-document", + file_name="page.md", + page=STATE_MD, + namespace_scope="document", + failures=0, + ), + NamespaceStateFixture( + test_id="group-reads-what-its-first-block-bound", + file_name="page.rst", + page=SHARED_GROUP_REST, + namespace_scope="block", + failures=0, + ), + NamespaceStateFixture( + test_id="groups-stay-isolated-from-each-other", + file_name="page.rst", + page=DISTINCT_GROUPS_REST, + namespace_scope="document", + failures=0, + ), +] + + +@pytest.mark.parametrize( + NamespaceStateFixture._fields, + NAMESPACE_STATE_FIXTURES, + ids=[f.test_id for f in NAMESPACE_STATE_FIXTURES], +) +def test_namespace_scope_decides_what_a_block_can_read( + tmp_path: pathlib.Path, + test_id: str, + file_name: str, + page: str, + namespace_scope: doctest_docutils.NamespaceScope, + failures: int, +) -> None: + """State reaches exactly as far as the namespace it was bound in. + + The isolated page proves it by expecting the ``NameError`` its own examples + document. + """ + page_path = tmp_path / file_name + page_path.write_text(page, encoding="utf-8") + + finder = doctest_docutils.DocutilsDocTestFinder(namespace_scope=namespace_scope) + runner = doctest.DocTestRunner(verbose=False) + for test in finder.find(page, str(page_path)): + runner.run(test, out=lambda _: None) + + assert runner.failures == failures + + +class MergedLineNumberFixture(t.NamedTuple): + """Page a namespace merges, in each block form docutils positions apart. + + Attributes + ---------- + test_id : str + pytest parametrize id. + file_name : str + Page name, whose suffix picks the parser. + page : str + Page content. + """ + + test_id: str + file_name: str + page: str + + +LONG_THEN_SHORT_REST = textwrap.dedent( + """ +Title +===== + +>>> first = 1 +>>> second = 2 +>>> third = 3 +>>> fourth = 4 + +Prose short enough that placing by ``node.line`` would overlap the blocks. + +>>> first + fourth +5 + """, +) + +MERGED_LINE_NUMBER_FIXTURES = [ + MergedLineNumberFixture( + test_id="MyST-fences", + file_name="page.md", + page=STATE_MD, + ), + MergedLineNumberFixture( + test_id="reST-doctest_directives", + file_name="page.rst", + page=SHARED_GROUP_REST, + ), + MergedLineNumberFixture( + test_id="reST-doctest_blocks", + file_name="page.rst", + page=LONG_THEN_SHORT_REST, + ), +] + + +@pytest.mark.parametrize( + MergedLineNumberFixture._fields, + MERGED_LINE_NUMBER_FIXTURES, + ids=[f.test_id for f in MERGED_LINE_NUMBER_FIXTURES], +) +def test_merged_examples_keep_their_gutter( + tmp_path: pathlib.Path, + test_id: str, + file_name: str, + page: str, +) -> None: + """The line a failure prints is the line the failing prompt sits on. + + pytest counts the ``%03d`` gutter from ``test.lineno + 1`` through the + merged source, so the blank lines standing in for prose have to match the + prose they replace, block after block. + """ + page_path = tmp_path / file_name + page_path.write_text(page, encoding="utf-8") + + finder = doctest_docutils.DocutilsDocTestFinder(namespace_scope="document") + (merged,) = finder.find(page, str(page_path)) + + gutter = (merged.docstring or "").splitlines() + assert [gutter[example.lineno] for example in merged.examples] == [ + f">>> {example.source.splitlines()[0]}" for example in merged.examples + ] + + +def _reported_lines( + page: str, + page_path: pathlib.Path, + scope: doctest_docutils.NamespaceScope, + items: doctest_docutils.NamespaceItems = "merged", +) -> list[int]: + """Return the file line every example on `page` reports, at `scope`.""" + finder = doctest_docutils.DocutilsDocTestFinder( + namespace_scope=scope, + namespace_items=items, + ) + return [ + (test.lineno or 0) + example.lineno + 1 + for test in finder.find(page, str(page_path)) + for example in test.examples + ] + + +@pytest.mark.parametrize( + ("test_id", "file_name", "page"), + [ + ("MyST-fences", "page.md", STATE_MD), + ("reST-doctest_directives", "page.rst", SHARED_GROUP_REST), + ("reST-doctest_blocks", "page.rst", LONG_THEN_SHORT_REST), + ], + ids=["MyST-fences", "reST-doctest_directives", "reST-doctest_blocks"], +) +def test_merging_moves_no_reported_line( + tmp_path: pathlib.Path, + test_id: str, + file_name: str, + page: str, +) -> None: + """A merged example reports the line it reports on its own. + + Every block form is placed at the line docutils gave it, so a page whose + blocks stand clear of each other reads the same merged as it does apart. + """ + page_path = tmp_path / file_name + page_path.write_text(page, encoding="utf-8") + + assert _reported_lines(page, page_path, "document") == _reported_lines( + page, + page_path, + "block", + ) + + +CROWDED_REST = textwrap.dedent( + """ +Title +===== + +>>> one = 1 +>>> two = 2 +>>> three = 3 +>>> four = 4 +>>> five = 5 +>>> six = 6 + +Prose. + +.. doctest:: + + >>> one + six + 7 + """, +) + + +def test_a_crowded_block_follows_the_one_above_it(tmp_path: pathlib.Path) -> None: + """A block the lines above already reach reports further down the page. + + docutils reports a reStructuredText doctest block's *last* line, so its own + examples already report lines below the block: a six-line block starting on + line 5 reports lines 10 to 15. A directive two lines further down has to + follow those, and moves by the overlap. The gutter still shows the failing + prompt, which is what a reader reads the report for. + """ + page_path = tmp_path / "page.rst" + page_path.write_text(CROWDED_REST, encoding="utf-8") + + apart = _reported_lines(CROWDED_REST, page_path, "block") + merged = _reported_lines(CROWDED_REST, page_path, "document") + + assert merged[:-1] == apart[:-1] + assert merged[-1] - apart[-1] == 2 + + finder = doctest_docutils.DocutilsDocTestFinder(namespace_scope="document") + (test,) = finder.find(CROWDED_REST, str(page_path)) + gutter = (test.docstring or "").splitlines() + assert gutter[test.examples[-1].lineno] == ">>> one + six" + + +@pytest.mark.parametrize( + ("test_id", "page"), + OUT_OF_ORDER_LINES_REST, + ids=[test_id for test_id, _ in OUT_OF_ORDER_LINES_REST], +) +def test_merging_survives_a_block_docutils_left_unpositioned( + tmp_path: pathlib.Path, + test_id: str, + page: str, +) -> None: + """A doctest block nested in another node still merges and runs. + + docutils leaves ``line`` unset on a block inside a directive, a list item, + or a block quote, so placing every block by that value alone would stack + them all at the top of the page. + """ + page_path = tmp_path / "page.rst" + page_path.write_text(page, encoding="utf-8") + + finder = doctest_docutils.DocutilsDocTestFinder(namespace_scope="document") + (merged,) = finder.find(page, str(page_path)) + runner = doctest.DocTestRunner(verbose=False) + runner.run(merged, out=lambda _: None) + + linenos = [example.lineno for example in merged.examples] + assert linenos == sorted(set(linenos)) + assert runner.failures == 0 + + +def test_a_group_survives_an_include(tmp_path: pathlib.Path) -> None: + """A group split across an ``.. include::`` merges and runs. + + docutils numbers the included page's nodes against that page, so the second + block claims a line the first one already covers. + """ + (tmp_path / "part.rst").write_text( + "Part\n----\n\nProse.\n\n.. doctest:: intro\n\n" + " >>> greeting.upper()\n 'HELLO'\n", + encoding="utf-8", + ) + page = ( + "Title\n=====\n\n.. doctest:: intro\n\n" + " >>> greeting = 'hello'\n\n.. include:: part.rst\n" + ) + page_path = tmp_path / "main.rst" + page_path.write_text(page, encoding="utf-8") + + finder = doctest_docutils.DocutilsDocTestFinder() + (merged,) = finder.find(page, str(page_path)) + runner = doctest.DocTestRunner(verbose=False) + runner.run(merged, out=lambda _: None) + + assert [example.source.strip() for example in merged.examples] == [ + "greeting = 'hello'", + "greeting.upper()", + ] + assert runner.failures == 0 + + +def test_markdown_failures_point_at_the_prompt(tmp_path: pathlib.Path) -> None: + """A merged Markdown page reports the file line each ``>>>`` sits on.""" + page_path = tmp_path / "page.md" + page_path.write_text(STATE_MD, encoding="utf-8") + + finder = doctest_docutils.DocutilsDocTestFinder(namespace_scope="document") + (test,) = finder.find(STATE_MD, str(page_path)) + + lines = STATE_MD.splitlines() + reported = [(test.lineno or 0) + example.lineno + 1 for example in test.examples] + assert [lines[lineno - 1] for lineno in reported] == [ + '>>> greeting = "hello"', + ">>> greeting", + ">>> greeting.upper()", + ] + + +def test_collection_logs_the_namespace_each_block_joined( + caplog: pytest.LogCaptureFixture, +) -> None: + """Collection records the namespace, source file, and block type. + + ``doctest_source_file`` and ``doctest_block_type`` are the structured keys + a log processor filters on, so assert the schema, not the message. + """ + finder = doctest_docutils.DocutilsDocTestFinder() + with caplog.at_level(logging.DEBUG, logger="doctest_docutils"): + finder.find(SHARED_GROUP_REST, "page.rst") + + collected = [ + record + for record in caplog.records + if record.msg == "doctest block collected into namespace %s" + ] + assert [record.args for record in collected] == [("intro",), ("intro",)] + assert {record.__dict__["doctest_block_type"] for record in collected} == { + "doctest", + } + assert {record.__dict__["doctest_source_file"] for record in collected} == { + "page.rst", + } + + +def test_verbose_finder_records_what_a_page_yielded( + caplog: pytest.LogCaptureFixture, +) -> None: + """``verbose=True`` reports how many tests a page produced, and from where. + + ``doctest_source_file`` is the structured key, so the page is filtered on + rather than read out of the message. + """ + page = ".. doctest::\n\n >>> 2 + 2\n 4\n" + + finder = doctest_docutils.DocutilsDocTestFinder(verbose=True) + with caplog.at_level(logging.INFO, logger="doctest_docutils"): + finder.find(page, "page.rst") + + found = [record for record in caplog.records if record.msg == "found %d test(s)"] + assert [record.args for record in found] == [(1,)] + assert [record.__dict__["doctest_source_file"] for record in found] == ["page.rst"] + + +def test_namespace_scope_rejects_an_unknown_name() -> None: + """An unknown scope names the values it could have been.""" + with pytest.raises(doctest_docutils.NamespaceScopeError) as excinfo: + doctest_docutils.DocutilsDocTestFinder( + namespace_scope=t.cast("doctest_docutils.NamespaceScope", "per-file"), + ) + + assert str(excinfo.value) == ( + "Unknown namespace scope: 'per-file'. Expected one of: block, document" + ) + + +TAKEN_DOCUMENT_NAME_REST = """ +Page +==== + +.. doctest:: page.rst + + >>> declared = "in the group the author named" + +A block declaring nothing, which document scope names for the page: + + >>> declared + Traceback (most recent call last): + NameError: name 'declared' is not defined +""" + +TAKEN_BLOCK_NAME_REST = """ +Page +==== + + >>> ungrouped = "in the block that declared nothing" + +.. doctest:: page.rst[0] + + >>> ungrouped + Traceback (most recent call last): + NameError: name 'ungrouped' is not defined +""" + +ALL_BLOCKS_GROUPED_REST = """ +Page +==== + +.. doctest:: page.rst + + >>> value = 1 + +.. doctest:: page.rst + + >>> value + 1 +""" + + +def test_a_group_may_not_take_the_name_a_page_generates() -> None: + """A page generating a name a group declared cannot say which it meant. + + At ``"document"`` scope a block declaring no group is named for the page, + so a group of the same name asks for a namespace already given away. The + two would share state and collect under one node id, which is a wrong + answer either way — so the page says so instead of picking one. + """ + with pytest.raises(doctest_docutils.NamespaceNameCollisionError) as excinfo: + doctest_docutils.DocutilsDocTestFinder(namespace_scope="document").find( + TAKEN_DOCUMENT_NAME_REST, + "page.rst", + ) + + assert "group 'page.rst' takes the name this page generates" in str(excinfo.value) + assert "a block declaring none at 'document' scope" in str(excinfo.value) + assert "Rename the group" in str(excinfo.value) + + +def test_a_group_may_not_take_a_generated_block_name() -> None: + """``block`` scope generates ``page[n]``, which a group can spell too. + + The default scope names an ungrouped block for its position, so the + collision reaches a page that configured nothing. + """ + with pytest.raises(doctest_docutils.NamespaceNameCollisionError) as excinfo: + doctest_docutils.DocutilsDocTestFinder().find( + TAKEN_BLOCK_NAME_REST, + "page.rst", + ) + + assert "group 'page.rst[0]' takes the name this page generates" in str( + excinfo.value, + ) + + +TAKEN_LIFTED_NAME_REST = """ +Page +==== + +.. doctest:: alpha + + >>> a = 1 + +.. doctest:: alpha + + >>> a # doctest: +SKIP + 1 + +.. doctest:: alpha[1] + + >>> b = 2 +""" + + +def test_a_group_may_not_take_a_lifted_block_name() -> None: + """Lifting names a block the same way declaring a group does. + + A block gated end to end is lifted out of its namespace as ``name[n]``, + which is a name a group can spell. Two tests answering to one node id is + the same wrong answer whichever half of the page generated it, so the + refusal covers the lifted name as well as the declared one. + """ + with pytest.raises(doctest_docutils.NamespaceNameCollisionError) as excinfo: + doctest_docutils.DocutilsDocTestFinder().find( + TAKEN_LIFTED_NAME_REST, + "page.rst", + ) + + assert "group 'alpha[1]' takes the name this page generates" in str(excinfo.value) + assert "a block lifted out of 'alpha'" in str(excinfo.value) + + +@pytest.mark.parametrize("scope", ["block", "document"]) +def test_a_group_named_for_its_page_is_left_alone( + scope: doctest_docutils.NamespaceScope, +) -> None: + """Only a name the page actually generates is taken. + + A page whose every block declares a group generates no name at all, so + naming a group after the file it sits in is a style choice and not a + collision. Checking the generated names rather than their shape is what + keeps this page collecting. + """ + tests = doctest_docutils.DocutilsDocTestFinder(namespace_scope=scope).find( + ALL_BLOCKS_GROUPED_REST, + "page.rst", + ) + runner = doctest.DocTestRunner(verbose=False) + for test in tests: + runner.run(test, out=lambda _: None) + + assert [test.name for test in tests] == ["page.rst"] + assert runner.failures == 0 + + +class PyversionFixture(t.NamedTuple): + """Directive whose ``:pyversion:`` decides whether its block runs. + + Attributes + ---------- + test_id : str + pytest parametrize id. + spec : str + PEP-440 specifier written on the ``:pyversion:`` option. + skipped : bool + Whether the block is expected to carry ``SKIP``. + """ + + test_id: str + spec: str + skipped: bool + + +PYVERSION_FIXTURES = [ + PyversionFixture(test_id="satisfied-runs", spec=">=3.10", skipped=False), + PyversionFixture(test_id="unsatisfied-skips", spec=">=99.0", skipped=True), + PyversionFixture(test_id="upper-bound-skips", spec="<3.0", skipped=True), +] + + +@pytest.mark.parametrize( + PyversionFixture._fields, + PYVERSION_FIXTURES, + ids=[f.test_id for f in PYVERSION_FIXTURES], +) +def test_pyversion_skips_the_block_it_excludes( + test_id: str, + spec: str, + skipped: bool, +) -> None: + """``:pyversion:`` compares the running interpreter against the specifier. + + The arguments were reversed, so every specifier was parsed as a version and + the page died on ``InvalidVersion`` before the option could decide anything. + """ + page = f".. doctest::\n :pyversion: {spec}\n\n >>> 2 + 2\n 4\n" + + (test,) = doctest_docutils.DocutilsDocTestFinder().find(page, "page.rst") + + assert test.examples[0].options.get(doctest.SKIP, False) is skipped + + +def test_pyversion_warns_on_a_malformed_specifier( + capsys: pytest.CaptureFixture[str], +) -> None: + """A ``:pyversion:`` that is no PEP-440 specifier warns and leaves the block. + + The option decides whether a block is for this interpreter. A value it + cannot parse answers neither way, so the block is left runnable and the + page reports the option rather than dying on it. + """ + page = ".. doctest::\n :pyversion: not a spec\n\n >>> 2 + 2\n 4\n" + + (test,) = doctest_docutils.DocutilsDocTestFinder().find(page, "page.rst") + + assert test.examples[0].options.get(doctest.SKIP, False) is False + assert "'not a spec' is not a valid pyversion option" in capsys.readouterr().err + + +BLANKLINE_REST = textwrap.dedent( + """ +Title +===== + +.. doctest:: + + >>> print("a\\n\\nb") + a + + b + """, +) + +SETUP_GROUP_REST = textwrap.dedent( + """ +Title +===== + +.. testsetup:: demo + + >>> import math + +.. doctest:: demo + + >>> math.floor(2.5) + 2 + +.. testcleanup:: demo + + >>> del math + """, +) + + +class DirectiveSourceFixture(t.NamedTuple): + """Page whose blocks only run once the directive's own source is read. + + Attributes + ---------- + test_id : str + pytest parametrize id. + page : str + reStructuredText page. + collected : int + Tests expected back from the finder. + """ + + test_id: str + page: str + collected: int + + +DIRECTIVE_SOURCE_FIXTURES = [ + DirectiveSourceFixture( + test_id="blankline-marker-inside-a-directive", + page=BLANKLINE_REST, + collected=1, + ), + DirectiveSourceFixture( + test_id="testsetup-and-testcleanup-share-a-group", + page=SETUP_GROUP_REST, + collected=1, + ), +] + + +@pytest.mark.parametrize( + DirectiveSourceFixture._fields, + DIRECTIVE_SOURCE_FIXTURES, + ids=[f.test_id for f in DIRECTIVE_SOURCE_FIXTURES], +) +def test_directive_blocks_run_from_their_own_source( + test_id: str, + page: str, + collected: int, +) -> None: + """Directives run the source they stored, not the code they render. + + ``.. doctest::`` rewrites a ```` marker into a real blank line + for the page and keeps the marker on the node, so reading the rendered + text instead compared against a blank line and failed. A ``testsetup`` + naming a group is only useful once that group is one namespace. + """ + tests = doctest_docutils.DocutilsDocTestFinder().find(page, "page.rst") + runner = doctest.DocTestRunner(verbose=False) + for test in tests: + runner.run(test, out=lambda _: None) + + assert len(tests) == collected + assert runner.failures == 0 + + +def test_a_failing_block_still_fails_beside_a_skipped_one() -> None: + """Skipping one block of a group does not excuse the rest of it. + + Lifting the gated block out must not lift the group's coverage out with + it: a skip that quietly took the whole namespace along would turn a broken + page green. + """ + page = textwrap.dedent( + """ +Title +===== + +.. doctest:: demo + :skipif: True + + >>> 1 / 0 + +.. doctest:: demo + + >>> 2 + 2 + 5 + """, + ) + + tests = doctest_docutils.DocutilsDocTestFinder().find(page, "page.rst") + runner = doctest.DocTestRunner(verbose=False) + for test in tests: + runner.run(test, out=lambda _: None) + + assert [test.name for test in tests] == ["demo[0]", "demo"] + assert runner.failures == 1 + + +def test_a_skipped_block_must_still_parse() -> None: + """A skipped block is parsed, so malformed doctest source still reports. + + Dropping the block hid its syntax; marking it ``SKIP`` does not. That + matches ``:options: +SKIP``, whose blocks have always had to parse. + """ + page = ".. doctest::\n :skipif: True\n\n >>>print(2)\n" + + with pytest.raises(ValueError, match="lacks blank after >>>"): + doctest_docutils.DocutilsDocTestFinder().find(page, "page.rst") + + +OUT_OF_ORDER_PHASES_REST = textwrap.dedent( + """ +Title +===== + +.. testcleanup:: demo + + >>> del value + +.. doctest:: demo + + >>> value + 1 + +.. testsetup:: demo + + >>> value = 1 + """, +) + +TWO_SETUPS_REST = textwrap.dedent( + """ +Title +===== + +.. testsetup:: demo + + >>> order = ["first"] + +.. testsetup:: demo + + >>> order.append("second") + +.. doctest:: demo + + >>> order + ['first', 'second'] + """, +) + + +def test_a_group_runs_setup_first_and_cleanup_last() -> None: + """Phase beats page order, so a hidden block can sit anywhere. + + ``testsetup`` and ``testcleanup`` render as comments, so an author moves + them out of the reader's way; running them where they sit bound names too + late and tore them down too early. + """ + (test,) = doctest_docutils.DocutilsDocTestFinder().find( + OUT_OF_ORDER_PHASES_REST, + "page.rst", + ) + runner = doctest.DocTestRunner(verbose=False) + runner.run(test, out=lambda _: None) + + assert [example.source.strip() for example in test.examples] == [ + "value = 1", + "value", + "del value", + ] + assert runner.failures == 0 + + +def test_two_setups_keep_their_page_order() -> None: + """Blocks of one phase run in the order the page wrote them.""" + (test,) = doctest_docutils.DocutilsDocTestFinder().find( + TWO_SETUPS_REST, + "page.rst", + ) + runner = doctest.DocTestRunner(verbose=False) + runner.run(test, out=lambda _: None) + + assert runner.failures == 0 + + +COMMA_GROUPS_REST = textwrap.dedent( + """ +Title +===== + +.. doctest:: alpha, beta + + >>> shared = 1 + +.. doctest:: beta + + >>> shared + 1 + """, +) + +WILDCARD_GROUP_REST = textwrap.dedent( + """ +Title +===== + +.. testsetup:: * + + >>> import math + +.. doctest:: alpha + + >>> math.floor(2.5) + 2 + +.. doctest:: beta + + >>> math.ceil(2.5) + 3 + """, +) + + +def test_a_block_joins_every_group_it_names() -> None: + """A comma list is every group the block belongs to, not just the first.""" + tests = doctest_docutils.DocutilsDocTestFinder().find( + COMMA_GROUPS_REST, + "page.rst", + ) + runner = doctest.DocTestRunner(verbose=False) + for test in tests: + runner.run(test, out=lambda _: None) + + assert [(test.name, len(test.examples)) for test in tests] == [ + ("alpha", 1), + ("beta", 2), + ] + assert runner.failures == 0 + + +def test_a_wildcard_joins_every_group_the_page_declares() -> None: + """``*`` is how a page writes one setup block for all of its groups.""" + tests = doctest_docutils.DocutilsDocTestFinder().find( + WILDCARD_GROUP_REST, + "page.rst", + ) + runner = doctest.DocTestRunner(verbose=False) + for test in tests: + runner.run(test, out=lambda _: None) + + assert [test.name for test in tests] == ["alpha", "beta"] + assert all(len(test.examples) == 2 for test in tests) + assert runner.failures == 0 + + +def test_a_shared_block_reports_one_line_in_every_group() -> None: + """Merging shifts example line numbers in place, so each copy is its own. + + A block joining two namespaces that shared its examples would have them + shifted twice, and the second group would report failures against a line + the block does not sit on. + """ + alpha, beta = doctest_docutils.DocutilsDocTestFinder().find( + COMMA_GROUPS_REST, + "page.rst", + ) + + def reported(test: doctest.DocTest, index: int) -> int: + return (test.lineno or 0) + test.examples[index].lineno + 1 + + assert reported(alpha, 0) == reported(beta, 0) + + +def test_out_of_order_phases_report_their_own_lines() -> None: + """A phase written away from its group still reports where it sits. + + A namespace hands its blocks over as setup, tests, cleanup, which is + rarely page order. Anchoring the merged text on that sequence reported + every example against whichever block came first in it, and could point + past the end of the file. + """ + finder = doctest_docutils.DocutilsDocTestFinder() + + def reported(page: str) -> list[tuple[str, int]]: + # Pairs rather than a dict keyed on the source: two examples can share + # a source, and one would then overwrite the other's line silently. + # Sorted because a merged namespace hands its blocks over in phase + # order and three separate ones come back in page order. + return sorted( + (example.source.strip(), (test.lineno or 0) + example.lineno + 1) + for test in finder.find(page, "page.rst") + for example in test.examples + ) + + merged = reported(OUT_OF_ORDER_PHASES_REST) + alone = reported(OUT_OF_ORDER_PHASES_REST.replace(":: demo", "::")) + + assert merged == alone + assert max(line for _, line in merged) <= len( + OUT_OF_ORDER_PHASES_REST.splitlines(), + ) + + +class NamespaceItemsFixture(t.NamedTuple): + """Page whose namespaces keep one test per block. + + Attributes + ---------- + test_id : str + pytest parametrize id. + file_name : str + Page name, whose suffix picks the parser. + page : str + Page content. + namespace_scope : doctest_docutils.NamespaceScope + Scope the finder is built with. + test_names : list[str] + Test names ``find`` returns, in order. + namespaces : list[str] + Namespace each returned test runs in, in the same order. + """ + + test_id: str + file_name: str + page: str + namespace_scope: doctest_docutils.NamespaceScope + test_names: list[str] + namespaces: list[str] + + +NAMESPACE_ITEMS_FIXTURES = [ + NamespaceItemsFixture( + test_id="ungrouped-fences-keep-the-names-block-scope-gives-them", + file_name="page.md", + page=STATE_MD, + namespace_scope="block", + test_names=["page.md[0]", "page.md[1]"], + namespaces=["page.md[0]", "page.md[1]"], + ), + NamespaceItemsFixture( + test_id="a-shared-page-keeps-those-names-too", + file_name="page.md", + page=STATE_MD, + namespace_scope="document", + test_names=["page.md[0]", "page.md[1]"], + namespaces=["page.md", "page.md"], + ), + NamespaceItemsFixture( + test_id="a-group-numbers-its-blocks-by-page-position", + file_name="page.rst", + page=SHARED_GROUP_REST, + namespace_scope="block", + test_names=["intro[0]", "intro[1]"], + namespaces=["intro", "intro"], + ), + NamespaceItemsFixture( + test_id="distinct-groups-stay-distinct", + file_name="page.rst", + page=DISTINCT_GROUPS_REST, + namespace_scope="document", + test_names=["alpha[0]", "beta[1]"], + namespaces=["alpha", "beta"], + ), +] + + +@pytest.mark.parametrize( + NamespaceItemsFixture._fields, + NAMESPACE_ITEMS_FIXTURES, + ids=[f.test_id for f in NAMESPACE_ITEMS_FIXTURES], +) +def test_per_block_keeps_a_test_per_block( + tmp_path: pathlib.Path, + test_id: str, + file_name: str, + page: str, + namespace_scope: doctest_docutils.NamespaceScope, + test_names: list[str], + namespaces: list[str], +) -> None: + """Every block comes back as its own test, named for where it sits. + + The name is the one a block already answers to at ``block`` scope, so a + node id does not move with the layout, and the tests of one namespace hold + one globals mapping rather than a copy each. + """ + page_path = tmp_path / file_name + page_path.write_text(page, encoding="utf-8") + + finder = doctest_docutils.DocutilsDocTestFinder( + namespace_scope=namespace_scope, + namespace_items="per-block", + ) + collected = finder._collect(page, str(page_path)) + + assert [held.test.name for held in collected] == test_names + assert [held.namespace for held in collected] == namespaces + by_namespace: dict[str, list[int]] = {} + for held in collected: + by_namespace.setdefault(held.namespace, []).append(id(held.test.globs)) + assert all(len(set(ids)) == 1 for ids in by_namespace.values()) + + +class NamespaceItemsStateFixture(t.NamedTuple): + """Page run block by block, counting the examples that fail. + + Attributes + ---------- + test_id : str + pytest parametrize id. + page : str + Page content. + namespace_scope : doctest_docutils.NamespaceScope + Scope the finder is built with. + failures : int + Examples expected to fail once every test has run. + """ + + test_id: str + page: str + namespace_scope: doctest_docutils.NamespaceScope + failures: int + + +NAMESPACE_ITEMS_STATE_FIXTURES = [ + NamespaceItemsStateFixture( + test_id="a-block-still-reads-nothing-by-default", + page=STATE_MD, + namespace_scope="block", + failures=1, + ), + NamespaceItemsStateFixture( + test_id="a-shared-page-reaches-the-block-below", + page=STATE_MD, + namespace_scope="document", + failures=0, + ), +] + + +@pytest.mark.parametrize( + NamespaceItemsStateFixture._fields, + NAMESPACE_ITEMS_STATE_FIXTURES, + ids=[f.test_id for f in NAMESPACE_ITEMS_STATE_FIXTURES], +) +def test_per_block_state_reaches_as_far_as_its_namespace( + tmp_path: pathlib.Path, + test_id: str, + page: str, + namespace_scope: doctest_docutils.NamespaceScope, + failures: int, +) -> None: + """A shared mapping carries names between tests; a scope still bounds it. + + ``DocTestRunner.run`` empties ``test.globs`` when it is done, so a caller + running these tests has to pass ``clear_globs=False`` for the sharing to + outlive the first block. That is the contract the pytest plugin's runner + holds up for it. + """ + page_path = tmp_path / "page.md" + page_path.write_text(page, encoding="utf-8") + + finder = doctest_docutils.DocutilsDocTestFinder( + namespace_scope=namespace_scope, + namespace_items="per-block", + ) + runner = doctest.DocTestRunner(verbose=False) + for test in finder.find(page, str(page_path)): + runner.run(test, out=lambda _: None, clear_globs=False) + + assert runner.failures == failures + + +def test_a_cleared_namespace_forgets_between_blocks(tmp_path: pathlib.Path) -> None: + """Sharing needs the runner's cooperation, and the shape says so. + + The mapping is handed over whole, but the stdlib empties it after each + test unless told otherwise, so a caller that forgets gets isolated blocks + back rather than a silently half-shared page. + """ + page_path = tmp_path / "page.md" + page_path.write_text(STATE_MD, encoding="utf-8") + + finder = doctest_docutils.DocutilsDocTestFinder( + namespace_scope="document", + namespace_items="per-block", + ) + runner = doctest.DocTestRunner(verbose=False) + for test in finder.find(STATE_MD, str(page_path)): + runner.run(test, out=lambda _: None) + + assert runner.failures == 1 + + +def test_per_block_reports_the_lines_block_scope_reports( + tmp_path: pathlib.Path, +) -> None: + """A block never merges, so it reports where docutils put it. + + Merging pads a namespace's text so its examples keep the lines they report + alone; keeping the blocks apart has nothing to pad, which is the same + answer by a shorter route. + """ + page_path = tmp_path / "page.rst" + page_path.write_text(CROWDED_REST, encoding="utf-8") + + assert _reported_lines( + CROWDED_REST, + page_path, + "document", + items="per-block", + ) == _reported_lines(CROWDED_REST, page_path, "block") + + +def test_per_block_runs_setup_first_and_cleanup_last() -> None: + """Phase still beats page order when a namespace is many tests. + + Collection order is run order for a caller that walks the list, so the + tests of one namespace come back setup first and cleanup last however the + page arranged them. + """ + finder = doctest_docutils.DocutilsDocTestFinder(namespace_items="per-block") + tests = finder.find(OUT_OF_ORDER_PHASES_REST, "page.rst") + runner = doctest.DocTestRunner(verbose=False) + for test in tests: + runner.run(test, out=lambda _: None, clear_globs=False) + + assert [test.name for test in tests] == ["demo[2]", "demo[1]", "demo[0]"] + assert [example.source.strip() for test in tests for example in test.examples] == [ + "value = 1", + "value", + "del value", + ] + assert runner.failures == 0 + + +def test_per_block_gates_a_block_and_leaves_the_rest_running() -> None: + """A gate is read the same way whatever a namespace collects as. + + Merged, a gated block is lifted into a test of its own so it still + reports; per block it already is one, and the blocks either side of it run + and can still fail. + """ + finder = doctest_docutils.DocutilsDocTestFinder(namespace_items="per-block") + tests = finder.find(GATED_MIDDLE_BLOCK_REST, "page.rst") + + gated = [test for test in tests if doctest_docutils._all_examples_skipped(test)] + assert [test.name for test in gated] == ["intro[1]"] + + runner = doctest.DocTestRunner(verbose=False) + for test in tests: + runner.run(test, out=lambda _: None, clear_globs=False) + + assert runner.failures == 0 + + +PER_BLOCK_MEMBERSHIP_FIXTURES = [ + ( + "comma-groups", + COMMA_GROUPS_REST, + [("alpha", "alpha[0]"), ("beta", "beta[0]"), ("beta", "beta[1]")], + ), + ( + "wildcard-group", + WILDCARD_GROUP_REST, + [ + ("alpha", "alpha[0]"), + ("beta", "beta[0]"), + ("alpha", "alpha[1]"), + ("beta", "beta[2]"), + ], + ), +] + + +@pytest.mark.parametrize( + ("test_id", "page", "collected"), + PER_BLOCK_MEMBERSHIP_FIXTURES, + ids=[test_id for test_id, _, _ in PER_BLOCK_MEMBERSHIP_FIXTURES], +) +def test_per_block_gives_a_shared_block_a_test_in_each_group( + test_id: str, + page: str, + collected: list[tuple[str, str]], +) -> None: + """A block joining two groups runs once per group, against that namespace. + + Its test is named for the group it is running in, so two groups holding + one block do not collide, and each group still gets its setup before its + tests. + """ + finder = doctest_docutils.DocutilsDocTestFinder(namespace_items="per-block") + held = finder._collect(page, "page.rst") + runner = doctest.DocTestRunner(verbose=False) + for one in held: + runner.run(one.test, out=lambda _: None, clear_globs=False) + + assert [(one.namespace, one.test.name) for one in held] == collected + assert runner.failures == 0 + + +def test_per_block_keeps_a_directive_option() -> None: + """``:options:`` reach a block's examples whatever it collects as.""" + finder = doctest_docutils.DocutilsDocTestFinder(namespace_items="per-block") + (test,) = finder.find( + ".. doctest::\n :options: +ELLIPSIS\n\n" + ' >>> print("hello world")\n hello ...\n', + "page.rst", + ) + runner = doctest.DocTestRunner(verbose=False) + runner.run(test, out=lambda _: None, clear_globs=False) + + assert test.examples[0].options[doctest.ELLIPSIS] is True + assert runner.failures == 0 + + +def test_namespace_items_rejects_an_unknown_name() -> None: + """An unknown layout names the values it could have been.""" + with pytest.raises(doctest_docutils.NamespaceItemsError) as excinfo: + doctest_docutils.DocutilsDocTestFinder( + namespace_items=t.cast("doctest_docutils.NamespaceItems", "one-each"), + ) + + assert str(excinfo.value) == ( + "Unknown namespace items: 'one-each'. Expected one of: merged, per-block" + ) + + +def test_merged_collection_names_the_namespace_of_every_test() -> None: + """A merged page names its namespaces too, lifted blocks included. + + The namespace is what a caller distributing tests has to keep together, + and a block lifted out of one belongs to it as much as the merged test + does. + """ + finder = doctest_docutils.DocutilsDocTestFinder() + collected = finder._collect(GATED_MIDDLE_BLOCK_REST, "page.rst") + + assert [(held.namespace, held.test.name) for held in collected] == [ + ("intro", "intro"), + ("intro", "intro[1]"), + ] + + +def test_per_block_under_testdocutils(tmp_path: pathlib.Path) -> None: + """Running a file has no scheduler, so per block simply shares. + + ``testdocutils`` owns its runner, so it is the one that has to leave the + namespace uncleared between blocks. + """ + page = tmp_path / "page.md" + page.write_text(STATE_MD, encoding="utf-8") + + with contextlib.redirect_stdout(io.StringIO()): + shared = doctest_docutils.testdocutils( + str(page), + module_relative=False, + report=False, + namespace_scope="document", + namespace_items="per-block", + ) + apart = doctest_docutils.testdocutils( + str(page), + module_relative=False, + report=False, + namespace_items="per-block", + ) + + assert shared == doctest.TestResults(failed=0, attempted=3) + assert apart == doctest.TestResults(failed=1, attempted=3) + + +def test_merging_reads_its_blocks_rather_than_consuming_them() -> None: + """Merging a block twice positions it the same way both times. + + A block is merged more than once whenever it names two groups, and again + when a gated block is lifted out of a namespace and merged on its own. + Shifting the block's own examples would move them further every time. + """ + parser = doctest.DocTestParser() + blocks = [ + parser.get_doctest(">>> 1 + 1\n2\n", {}, "n", "page.rst", 3), + parser.get_doctest(">>> 2 + 2\n4\n", {}, "n", "page.rst", 9), + ] + originals = [example.lineno for block in blocks for example in block.examples] + + first = doctest_docutils._merge_blocks(blocks, "n", "page.rst", {}) + second = doctest_docutils._merge_blocks(blocks, "n", "page.rst", {}) + + assert [example.lineno for example in first.examples] == [ + example.lineno for example in second.examples + ] + assert [ + example.lineno for block in blocks for example in block.examples + ] == originals + + +TESTCODE_PAGE_MD = textwrap.dedent( + """ + # Page + + Visible, pasteable, no prompt: + + ```{testcode} + value = 41 + ``` + + Hidden assertion the reader never sees: + + ```{testcode} + :hide: + + assert value == 41 + ``` + + Visible with expected output: + + ```{testcode} + print(value + 1) + ``` + + ```{testoutput} + 42 + ``` + """, +) + + +def _run_page( + tmp_path: pathlib.Path, source: str, **kwargs: t.Any +) -> doctest.TestResults: + """Run one page through ``testdocutils`` with its report swallowed.""" + page = tmp_path / kwargs.pop("filename", "page.md") + page.write_text(source, encoding="utf-8") + with contextlib.redirect_stdout(io.StringIO()): + return doctest_docutils.testdocutils( + str(page), + module_relative=False, + report=False, + **kwargs, + ) + + +def test_a_page_without_a_prompt_collects() -> None: + """A page a reader can paste out of is one namespace of three examples. + + A ``{testcode}`` carries no ``>>>``, which is the whole reason the pages + it is written for were invisible to the finder. + """ + tests = doctest_docutils.DocutilsDocTestFinder().find(TESTCODE_PAGE_MD, "page.md") + + assert [(test.name, len(test.examples)) for test in tests] == [("page.md", 3)] + + +def test_a_page_without_a_prompt_passes(tmp_path: pathlib.Path) -> None: + """The hidden block reads what the visible one bound, and asserts on it.""" + assert _run_page(tmp_path, TESTCODE_PAGE_MD) == doctest.TestResults( + failed=0, + attempted=3, + ) + + +def test_a_hidden_testcode_asserts_for_real(tmp_path: pathlib.Path) -> None: + """The hidden block is a test, not decoration: a false one fails the page.""" + broken = TESTCODE_PAGE_MD.replace("assert value == 41", "assert value == 999") + + assert _run_page(tmp_path, broken).failed == 1 + + +def test_a_hidden_testcode_leaves_the_rendered_page() -> None: + """``:hide:`` turns the block into a comment, as in :mod:`sphinx.ext.doctest`. + + Every builder drops a comment, so the reader meets only the block written + to be pasted. + """ + import docutils.core + from docutils import nodes + + doctest_docutils._ensure_directives_registered() + page = ( + ".. testcode::\n\n value = 41\n\n" + ".. testcode::\n :hide:\n\n assert value == 41\n" + ) + + doctree = docutils.core.publish_doctree(page) + + assert [ + (node.tagname, node.astext()) + for node in doctree.findall(nodes.Element) + if node.get("testnodetype") + ] == [ + ("literal_block", "value = 41"), + ("comment", "assert value == 41"), + ] + + +def test_a_bare_expression_in_testcode_reports_no_output( + tmp_path: pathlib.Path, +) -> None: + """``exec`` mode echoes nothing, so a bare expression is not a failure. + + ``single`` mode would print the value and report it as output the block + never said to expect. + """ + page = "```{testcode}\nvalue = 41\nvalue\n```\n" + + assert _run_page(tmp_path, page) == doctest.TestResults(failed=0, attempted=1) + + +def test_a_multi_statement_testcode_body_runs(tmp_path: pathlib.Path) -> None: + """``single`` mode takes one statement; a pasteable block takes many.""" + page = ( + "```{testcode}\nfirst = 1\nsecond = first + 1\nprint(second + 1)\n```\n" + "\n```{testoutput}\n3\n```\n" + ) + + assert _run_page(tmp_path, page) == doctest.TestResults(failed=0, attempted=1) + + +ECHO_FIXTURES = [ + ("a-block-that-expects-the-echo", "```python\n>>> 2 + 2\n4\n```\n", 0), + ("a-block-that-expects-nothing", "```python\n>>> 2 + 2\n```\n", 1), +] + + +@pytest.mark.parametrize( + ("test_id", "page", "failed"), + ECHO_FIXTURES, + ids=[fixture[0] for fixture in ECHO_FIXTURES], +) +def test_a_prompt_keeps_its_echo( + tmp_path: pathlib.Path, + test_id: str, + page: str, + failed: int, +) -> None: + """A ``>>>`` example still compiles in ``single`` mode. + + The second page proves the echo is real rather than merely tolerated: a + bare expression that printed nothing would pass it. + """ + assert _run_page(tmp_path, page).failed == failed + + +def test_testoutput_checks_the_block_above_it(tmp_path: pathlib.Path) -> None: + """The output a ``{testcode}`` prints is compared against the block below.""" + page = "```{testcode}\nprint(41 + 1)\n```\n\n```{testoutput}\n99\n```\n" + + assert _run_page(tmp_path, page).failed == 1 + + +def test_testoutput_options_reach_the_example(tmp_path: pathlib.Path) -> None: + """``:options:`` on the output block set the flags the check runs under.""" + page = ( + "```{testcode}\nprint('a long line of output')\n```\n\n" + "```{testoutput}\n:options: +ELLIPSIS\n\na long ... output\n```\n" + ) + + assert _run_page(tmp_path, page) == doctest.TestResults(failed=0, attempted=1) + + +def test_testoutput_can_expect_an_exception(tmp_path: pathlib.Path) -> None: + """A traceback in the output block is checked as an exception, not as text.""" + page = ( + "```{testcode}\nraise ValueError('boom')\n```\n\n" + "```{testoutput}\nTraceback (most recent call last):\n" + " ...\nValueError: boom\n```\n" + ) + + assert _run_page(tmp_path, page) == doctest.TestResults(failed=0, attempted=1) + + +def test_a_stray_testoutput_is_dropped(caplog: pytest.LogCaptureFixture) -> None: + """Output with no block above it checks nothing, and says so. + + Collecting it as a test of its own would report a pass for an expectation + nothing ever produced. + """ + page = "```{testoutput}\n42\n```\n" + + with caplog.at_level(logging.WARNING, logger="doctest_docutils"): + tests = doctest_docutils.DocutilsDocTestFinder().find(page, "page.md") + + assert tests == [] + assert [ + record.doctest_block_type + for record in caplog.records + if hasattr(record, "doctest_block_type") + ] == ["testoutput"] + + +TESTCODE_NAMESPACE_FIXTURES = [ + ("block-merged", "block", "merged", ["page.md"]), + ("document-merged", "document", "merged", ["page.md"]), + ("block-per-block", "block", "per-block", ["page.md[0]", "page.md[1]"]), + ("document-per-block", "document", "per-block", ["page.md[0]", "page.md[1]"]), +] + + +@pytest.mark.parametrize( + ("test_id", "scope", "items", "names"), + TESTCODE_NAMESPACE_FIXTURES, + ids=[fixture[0] for fixture in TESTCODE_NAMESPACE_FIXTURES], +) +def test_testcode_shares_its_page_at_every_setting( + test_id: str, + scope: str, + items: str, + names: list[str], +) -> None: + """A ``{testcode}`` shares its page whatever the scope says. + + The scope names the namespace of a block that declared no group; a + ``{testcode}`` is written so the visible block and the hidden one + asserting on it stay together, which is the page. It is named for the + page rather than for Sphinx's ``default``, so the id reads like every + other one this finder hands out. + """ + page = "```{testcode}\nvalue = 41\n```\n\n```{testcode}\nassert value == 41\n```\n" + + finder = doctest_docutils.DocutilsDocTestFinder( + namespace_scope=t.cast("t.Any", scope), + namespace_items=t.cast("t.Any", items), + ) + + assert [test.name for test in finder.find(page, "page.md")] == names + + +MIXED_FORMS_MD = textwrap.dedent( + """ + ``` + >>> base = 40 + ``` + + ```{testcode} + print(base + 2) + ``` + + ```{testoutput} + 42 + ``` + """, +) + + +def test_document_scope_joins_both_forms(tmp_path: pathlib.Path) -> None: + """At document scope a ``{testcode}`` reads what a prompt block bound. + + Both forms are named for the page there, which is the one namespace + :mod:`sphinx.ext.doctest` gives every block that declares no group. + """ + finder = doctest_docutils.DocutilsDocTestFinder(namespace_scope="document") + + assert [test.name for test in finder.find(MIXED_FORMS_MD, "page.md")] == ["page.md"] + assert _run_page( + tmp_path, + MIXED_FORMS_MD, + namespace_scope="document", + ) == doctest.TestResults(0, 2) + + +def test_block_scope_keeps_both_forms_apart() -> None: + """At block scope a prompt block is its own namespace, as it always was. + + Only the prompt-free blocks share, so the page collects two tests and the + ``{testcode}`` cannot read the prompt block's name. + """ + finder = doctest_docutils.DocutilsDocTestFinder() + + assert [test.name for test in finder.find(MIXED_FORMS_MD, "page.md")] == [ + "page.md[0]", + "page.md", + ] + + +def test_a_second_testoutput_replaces_the_first( + tmp_path: pathlib.Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """The last answer wins, as in Sphinx, and the page hears about it. + + :meth:`sphinx.ext.doctest.TestGroup.add_code` replaces the output a + ``{testcode}`` already had, so a page reads the same here as it builds + there. Saying so is the part Sphinx leaves out. + """ + page = textwrap.dedent( + """ + ```{testcode} + print("second") + ``` + + ```{testoutput} + first + ``` + + ```{testoutput} + second + ``` + """, + ) + + with caplog.at_level(logging.WARNING, logger="doctest_docutils"): + results = _run_page(tmp_path, page) + + assert results == doctest.TestResults(0, 1) + assert [ + record.message + for record in caplog.records + if hasattr(record, "doctest_block_type") + ] == ["testoutput block replaces the one above it"] + + +def test_skipif_gates_a_testcode(tmp_path: pathlib.Path) -> None: + """A gated ``{testcode}`` is skipped rather than run and failed.""" + page = "```{testcode}\n:skipif: True\n\nraise AssertionError('never run')\n```\n" + + assert _run_page(tmp_path, page) == doctest.TestResults(failed=0, attempted=1) + + +def test_a_testsetup_of_the_group_runs_before_a_testcode( + tmp_path: pathlib.Path, +) -> None: + """Phase order holds with a ``{testcode}`` in the namespace. + + The setup block is written below the code it sets up, and still runs + first. It is written with prompts, which a page may keep doing. + """ + page = ( + "```{testcode} demo\nassert base == 40\nprint(base + 2)\n```\n\n" + "```{testoutput} demo\n42\n```\n\n" + "```{testsetup} demo\n>>> base = 40\n```\n" + ) + + assert _run_page(tmp_path, page) == doctest.TestResults(failed=0, attempted=2) + + +def test_testcode_reaches_the_command( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """``python -m doctest_docutils`` runs a prompt-free page too. + + The command never loads pytest, so the mode a ``{testcode}`` runs under + cannot come from the plugin. + """ + page = tmp_path / "page.md" + page.write_text(TESTCODE_PAGE_MD, encoding="utf-8") + monkeypatch.setattr("sys.argv", ["doctest_docutils", str(page)]) + + assert doctest_docutils._test() == 0 + + broken = tmp_path / "broken.md" + broken.write_text( + TESTCODE_PAGE_MD.replace("assert value == 41", "assert value == 999"), + encoding="utf-8", + ) + monkeypatch.setattr("sys.argv", ["doctest_docutils", str(broken)]) + + assert doctest_docutils._test() == 1 + assert "AssertionError" in capsys.readouterr().out + + +def test_the_exec_mode_seam_leaves_the_doctest_module_alone() -> None: + """The mode rides on one function object, not on :mod:`doctest`. + + :mod:`sphinx.ext.doctest` rebinds ``doctest.compile`` for the process and + never puts it back. gp-libs loads into every pytest session through its + ``pytest11`` entry point, so the rebinding stays inside the runner it was + made for — and the seam it needs is pinned here, because a CPython that + stopped resolving ``compile`` as a global would break it silently. + """ + stock = doctest.DocTestRunner._DocTestRunner__run # type: ignore[attr-defined] + + assert "compile" not in vars(doctest) + assert "compile" in stock.__code__.co_names + # A closure would need its cells rebuilt, and rebuilding a function without + # them raises nothing — it just misbehaves. + assert stock.__code__.co_freevars == () + assert ( + doctest_docutils._ExecModeRunner._DocTestRunner__run.__globals__["compile"] + is doctest_docutils._compile_source + ) + + +def test_two_groups_running_interleaved_each_get_their_output() -> None: + """A ``{testoutput}`` answers its own group, not whichever block sits above. + + :meth:`sphinx.ext.doctest.TestGroup.add_code` keeps a list per group and + pairs an output with that group's latest block, so a page may run two + groups' blocks alternately. + """ + page = textwrap.dedent( + """ + ```{testcode} alpha + print("A") + ``` + + ```{testcode} beta + print("B") + ``` + + ```{testoutput} alpha + A + ``` + + ```{testoutput} beta + B + ``` + """, + ) + finder = doctest_docutils.DocutilsDocTestFinder() + + tests = finder.find(page, "page.md") + + assert {test.name: test.examples[0].want for test in tests} == { + "alpha": "A\n", + "beta": "B\n", + } + + +def test_a_block_between_a_testcode_and_its_output_closes_the_pairing() -> None: + """Only the group's latest block takes an output, as under Sphinx. + + ``sphinx-build -b doctest`` fails this page: the ``{doctest}`` block lands + in the same group and leaves the ``{testcode}`` expecting nothing. + """ + page = textwrap.dedent( + """ + ```{testcode} + print("A") + ``` + + ```{doctest} + >>> 1 + 1 + 2 + ``` + + ```{testoutput} + A + ``` + """, + ) + finder = doctest_docutils.DocutilsDocTestFinder() + + tests = finder.find(page, "page.md") + + assert [example.want for test in tests for example in test.examples] == [ + "", + "2\n", + ] + + +def test_a_prompt_free_testsetup_runs(tmp_path: pathlib.Path) -> None: + """A page copied out of the Sphinx docs works, prompts and all absent. + + :mod:`sphinx.ext.doctest` runs a ``{testsetup}`` body through the same + ``exec`` its ``{testcode}`` uses and rejects a ``>>>`` outright, so the + canonical page carries no prompt anywhere. + """ + page = textwrap.dedent( + """ + ```{testsetup} + base = 40 + ``` + + ```{testcode} + print(base + 2) + ``` + + ```{testoutput} + 42 + ``` + """, + ) + + assert _run_page(tmp_path, page) == doctest.TestResults(failed=0, attempted=2) + + +def test_a_prompt_style_testsetup_reaches_an_unnamed_testcode( + tmp_path: pathlib.Path, +) -> None: + """Both spellings of a setup body feed the same page. + + gp-libs has always written a ``{testsetup}`` with prompts, so the prompt + decides how the body is read rather than the directive. + """ + page = textwrap.dedent( + """ + ```{testsetup} + >>> base = 40 + ``` + + ```{testcode} + print(base + 2) + ``` + + ```{testoutput} + 42 + ``` + """, + ) + + assert _run_page(tmp_path, page) == doctest.TestResults(failed=0, attempted=2) + + +def test_a_page_of_prompt_blocks_keeps_the_setup_it_had() -> None: + """An unnamed ``{testsetup}`` follows the prompt blocks when no testcode does. + + The implicit ``default`` group widens to the setup phase only for the pages + that need it, so a page written before ``{testcode}`` existed collects + exactly the namespaces it always did. + """ + page = textwrap.dedent( + """ + ```{testsetup} + >>> base = 40 + ``` + + ```python + >>> base + 2 + 42 + ``` + """, + ) + finder = doctest_docutils.DocutilsDocTestFinder(namespace_scope="document") + + (test,) = finder.find(page, "page.md") + + assert test.name == "page.md" + assert len(test.examples) == 2 + + +def test_a_failing_testcode_quotes_the_whole_block() -> None: + """The report shows the block and lands inside it, not on its opening line. + + pytest quotes ``lines[example.lineno - 9 : example.lineno + 1]`` and sends + the reader to ``test.lineno + example.lineno + 1``. + """ + page = ( + "# Page\n\n```{testcode}\na = 1\nb = 2\nc = 3\nraise ValueError('boom')\n```\n" + ) + finder = doctest_docutils.DocutilsDocTestFinder() + + (test,) = finder.find(page, "page.md") + (example,) = test.examples + + assert example.lineno == 3 + assert (test.lineno or 0) + example.lineno + 1 == 7 + + +def test_pyversion_on_a_testcode_says_it_does_nothing( + capsys: pytest.CaptureFixture[str], +) -> None: + """A declared option that is ignored has to say so. + + :mod:`sphinx.ext.doctest` declares ``:pyversion:`` on ``{testcode}`` and + acts on it only for ``{doctest}``. Honouring it here would pass a page + Sphinx fails; refusing it would fail a page Sphinx renders. + """ + page = ".. testcode::\n :pyversion: < 3.0\n\n ran = True\n" + + (test,) = doctest_docutils.DocutilsDocTestFinder().find(page, "page.rst") + + assert test.examples[0].options.get(doctest.SKIP, False) is False + assert "'pyversion' has no effect on 'testcode'" in capsys.readouterr().err + + +def test_the_exec_mode_seam_degrades_instead_of_failing_to_import() -> None: + """A moved private method must not break unrelated sessions. + + gp-libs loads through its ``pytest11`` entry point into every session that + has it installed, so an interpreter without the seam leaves CPython's loop + in place. ``{testcode}`` is what stops working, and + ``test_the_exec_mode_seam_leaves_the_doctest_module_alone`` is where that + is caught loudly. + """ + stock = doctest.DocTestRunner._DocTestRunner__run # type: ignore[attr-defined] + try: + del doctest.DocTestRunner._DocTestRunner__run # type: ignore[attr-defined] + + assert doctest_docutils._exec_mode_run() is None + finally: + doctest.DocTestRunner._DocTestRunner__run = stock # type: ignore[attr-defined] + + assert doctest_docutils._exec_mode_run() is not None diff --git a/tests/test_doctest_options.py b/tests/test_doctest_options.py index aa9cd68..eda994a 100644 --- a/tests/test_doctest_options.py +++ b/tests/test_doctest_options.py @@ -144,6 +144,80 @@ class DoctestOptionCase(t.NamedTuple): expected_outcome="skipped", description="Inline +SKIP directive works in .md files", ), + DoctestOptionCase( + test_id="skipif-false-runs-the-block-rst", + file_ext=".rst", + ini_options="", + doctest_content=textwrap.dedent( + """ + Example + ======= + + .. doctest:: + :skipif: False + + >>> 2 + 2 + 4 + """, + ), + expected_outcome="passed", + description=":skipif: False leaves the block collected", + ), + DoctestOptionCase( + test_id="skipif-true-reports-as-skipped-rst", + file_ext=".rst", + ini_options="", + doctest_content=textwrap.dedent( + """ + Example + ======= + + .. doctest:: + :skipif: True + + >>> 1 / 0 + """, + ), + expected_outcome="skipped", + description=":skipif: True reports the same way as :options: +SKIP", + ), + DoctestOptionCase( + test_id="skipif-true-reports-as-skipped-md", + file_ext=".md", + ini_options="", + doctest_content=textwrap.dedent( + """ + # Example + + ```{doctest} + :skipif: True + + >>> 1 / 0 + ``` + """, + ), + expected_outcome="skipped", + description=":skipif: True reports as skipped in a Markdown fence too", + ), + DoctestOptionCase( + test_id="inline-flag-cannot-reopen-a-true-skipif-rst", + file_ext=".rst", + ini_options="", + doctest_content=textwrap.dedent( + """ + Example + ======= + + .. doctest:: + :skipif: True + + >>> 2 + 2 # doctest: -SKIP + 4 + """, + ), + expected_outcome="skipped", + description="An example's own flag cannot reopen a true :skipif:", + ), # Inline ELLIPSIS directive DoctestOptionCase( test_id="inline-ellipsis-directive-rst", @@ -178,6 +252,80 @@ class DoctestOptionCase(t.NamedTuple): expected_outcome="passed", description="Inline +ELLIPSIS directive works in .md files", ), + DoctestOptionCase( + test_id="directive-options-normalize-whitespace-rst", + file_ext=".rst", + ini_options="", + doctest_content=textwrap.dedent( + """ + Example + ======= + + .. doctest:: + :options: +NORMALIZE_WHITESPACE + + >>> print("a b") + a b + """, + ), + expected_outcome="passed", + description=":options: applies to the block's examples", + ), + DoctestOptionCase( + test_id="directive-options-skip-rst", + file_ext=".rst", + ini_options="", + doctest_content=textwrap.dedent( + """ + Example + ======= + + .. doctest:: + :options: +SKIP + + >>> 1 / 0 + """, + ), + expected_outcome="skipped", + description=":options: +SKIP skips the block's examples", + ), + DoctestOptionCase( + test_id="inline-flag-beats-directive-options-rst", + file_ext=".rst", + ini_options="", + doctest_content=textwrap.dedent( + """ + Example + ======= + + .. doctest:: + :options: +SKIP + + >>> 2 + 2 # doctest: -SKIP + 4 + """, + ), + expected_outcome="passed", + description="An example's own flag overrides the directive's options", + ), + DoctestOptionCase( + test_id="inline-flag-inside-directive-rst", + file_ext=".rst", + ini_options="", + doctest_content=textwrap.dedent( + """ + Example + ======= + + .. doctest:: + + >>> print("a b") # doctest: +NORMALIZE_WHITESPACE + a b + """, + ), + expected_outcome="passed", + description="An inline flag applies although the directive trims it", + ), ] @@ -499,3 +647,278 @@ def test_edge_cases( assert "0 items" in stdout or "no tests ran" in stdout or expected_tests == 0 elif expected_outcome == "passed": result.assert_outcomes(passed=expected_tests) + + +THREE_BLOCK_REST = textwrap.dedent( + """ + Example + ======= + + .. doctest:: + :skipif: True + + >>> 1 / 0 + + .. doctest:: + :options: +SKIP + + >>> 1 / 0 + + .. doctest:: + + >>> 2 + 2 + 4 + """, +) + + +def test_skipif_true_reports_like_the_skip_flag( + pytester: _pytest.pytester.Pytester, +) -> None: + """A ``:skipif:`` block collects, counts, and reports as ``+SKIP`` does. + + A page holding all three spellings — a true ``:skipif:``, an + ``:options: +SKIP``, and an ordinary block — collects three items. The two + skipped ones report under ``-rs`` with the same reason, so a reader who + knows either spelling can predict the other. + """ + pytester.plugins = ["pytest_doctest_docutils"] + pytester.makefile(".ini", pytest="[pytest]\naddopts=-p no:doctest") + page = pytester.path / "test_doc.rst" + page.write_text(THREE_BLOCK_REST, encoding="utf-8") + + collected = pytester.runpytest(str(page), "--collect-only", "-q") + + collected.stdout.fnmatch_lines( + [ + "test_doc.rst::test_doc.rst[[]0[]]", + "test_doc.rst::test_doc.rst[[]1[]]", + "test_doc.rst::test_doc.rst[[]2[]]", + ], + consecutive=True, + ) + + result = pytester.runpytest(str(page), "-rs") + + result.assert_outcomes(passed=1, skipped=2) + result.stdout.fnmatch_lines( + [ + "SKIPPED [[]1[]] *: test_doc.rst:6: every example skipped", + "SKIPPED [[]1[]] *: test_doc.rst:11: every example skipped", + ], + ) + + +def test_skipif_block_is_selectable_by_node_id( + pytester: _pytest.pytester.Pytester, +) -> None: + """The skipped block keeps a node id a reader can run on its own. + + Dropping it left nothing to select; marking it ``SKIP`` leaves the item + addressable, which is what makes ``-rs`` and IDE test discovery agree. + """ + pytester.plugins = ["pytest_doctest_docutils"] + pytester.makefile(".ini", pytest="[pytest]\naddopts=-p no:doctest") + page = pytester.path / "test_doc.rst" + page.write_text(THREE_BLOCK_REST, encoding="utf-8") + + result = pytester.runpytest(f"{page}::test_doc.rst[0]") + + result.assert_outcomes(skipped=1) + + +GATED_GROUP_REST = textwrap.dedent( + """ + Example + ======= + + .. doctest:: intro + + >>> greeting = "hello" + + .. doctest:: intro + :skipif: True + + >>> raise AssertionError("the skipped block ran") + + .. doctest:: intro + + >>> greeting.upper() + 'HELLO' + """, +) + + +def test_skipif_leaves_the_rest_of_its_group_running( + pytester: _pytest.pytester.Pytester, +) -> None: + """Skipping one block of a group is not skipping the group's item. + + The group's item passes on the strength of the blocks that did run, and + the gated block is an item of its own that reports skipped with a node id + and a reason. The skipped block would raise if it ran, and the last block + needs a name the first one bound, which pins both halves of that claim. + """ + pytester.plugins = ["pytest_doctest_docutils"] + pytester.makefile(".ini", pytest="[pytest]\naddopts=-p no:doctest") + page = pytester.path / "test_doc.rst" + page.write_text(GATED_GROUP_REST, encoding="utf-8") + + result = pytester.runpytest(str(page), "-rs", "-v") + + result.assert_outcomes(passed=1, skipped=1) + result.stdout.fnmatch_lines( + [ + "test_doc.rst::intro PASSED*", + "test_doc.rst::intro[[]1[]] SKIPPED*", + ], + consecutive=True, + ) + result.stdout.fnmatch_lines( + ["SKIPPED [[]1[]] *: test_doc.rst:*: every example skipped"], + ) + + +def test_a_gated_block_of_a_group_is_selectable( + pytester: _pytest.pytester.Pytester, +) -> None: + """The item a gated block collects as answers to its own node id. + + A reader who sees the skip in ``-rs`` can paste the id back to pytest and + get the same one line, which is what makes the report actionable. + """ + pytester.plugins = ["pytest_doctest_docutils"] + pytester.makefile(".ini", pytest="[pytest]\naddopts=-p no:doctest") + page = pytester.path / "test_doc.rst" + page.write_text(GATED_GROUP_REST, encoding="utf-8") + + result = pytester.runpytest(f"{page}::intro[1]", "-rs") + + result.assert_outcomes(skipped=1) + + +def test_a_group_skipped_end_to_end_reports_skipped( + pytester: _pytest.pytester.Pytester, +) -> None: + """A group whose every block is skipped reports as one skipped item. + + The two spellings mix inside a single namespace, and pytest reports the + item skipped exactly when no example in it is left to run. + """ + pytester.plugins = ["pytest_doctest_docutils"] + pytester.makefile(".ini", pytest="[pytest]\naddopts=-p no:doctest") + page = pytester.path / "test_doc.rst" + page.write_text( + textwrap.dedent( + """ + Example + ======= + + .. doctest:: solo + :skipif: True + + >>> 1 / 0 + + .. doctest:: solo + :options: +SKIP + + >>> 1 / 0 + """, + ), + encoding="utf-8", + ) + + result = pytester.runpytest(str(page), "-rs") + + result.assert_outcomes(skipped=1) + + +def test_skipif_reaches_setup_and_cleanup_under_pytest( + pytester: _pytest.pytester.Pytester, +) -> None: + """A skipped ``testsetup`` or ``testcleanup`` does not run its examples. + + Both directives declare ``skipif``, and both would fail the group's item + if their examples ran. Each reports as its own skipped item, so a group + running without the setup it was written with is visible in the report. + """ + pytester.plugins = ["pytest_doctest_docutils"] + pytester.makefile(".ini", pytest="[pytest]\naddopts=-p no:doctest") + page = pytester.path / "test_doc.rst" + page.write_text( + textwrap.dedent( + """ + Example + ======= + + .. testsetup:: fixture + :skipif: True + + >>> raise AssertionError("the skipped testsetup ran") + + .. doctest:: fixture + + >>> 2 + 2 + 4 + + .. testcleanup:: fixture + :skipif: True + + >>> raise AssertionError("the skipped testcleanup ran") + """, + ), + encoding="utf-8", + ) + + result = pytester.runpytest(str(page), "-v") + + result.assert_outcomes(passed=1, skipped=2) + result.stdout.fnmatch_lines( + [ + "test_doc.rst::fixture[[]0[]] SKIPPED*", + "test_doc.rst::fixture PASSED*", + "test_doc.rst::fixture[[]2[]] SKIPPED*", + ], + consecutive=True, + ) + + +def test_collect_only_evaluates_the_skipif_expression( + pytester: _pytest.pytester.Pytester, +) -> None: + """Listing a page's items runs its ``:skipif:`` expressions. + + The option's contract is a Python expression evaluated while the page is + read, and ``--collect-only`` reads the page. Marking the block ``SKIP`` + instead of dropping it changes what the reader sees, not when the + expression is answered — so a page whose expression touches the world + still touches it during discovery. + """ + pytester.plugins = ["pytest_doctest_docutils"] + pytester.makefile(".ini", pytest="[pytest]\naddopts=-p no:doctest") + witness = pytester.path / "collect-only-ran.txt" + # Writes the witness file, then evaluates false, so the block still runs. + expression = ( + f'__import__("pathlib").Path({str(witness)!r}).write_text("ran") and False' + ) + page = pytester.path / "test_doc.rst" + page.write_text( + textwrap.dedent( + f""" + Example + ======= + + .. doctest:: + :skipif: {expression} + + >>> 2 + 2 + 4 + """, + ), + encoding="utf-8", + ) + + result = pytester.runpytest(str(page), "--collect-only", "-q") + + result.stdout.fnmatch_lines(["test_doc.rst::test_doc.rst[[]0[]]"]) + assert witness.read_text(encoding="utf-8") == "ran" diff --git a/tests/test_pytest_doctest_docutils.py b/tests/test_pytest_doctest_docutils.py index 70fbd5a..382e4ee 100644 --- a/tests/test_pytest_doctest_docutils.py +++ b/tests/test_pytest_doctest_docutils.py @@ -520,3 +520,1465 @@ def demo() -> int: result = pytester.runpytest(str(example), "--doctest-docutils-modules") result.assert_outcomes(passed=1) + + +STATE_MD = textwrap.dedent( + """ +# Title + +```python +>>> greeting = "hello" +>>> greeting +'hello' +``` + +Narrative prose between the two blocks. + +```python +>>> greeting.upper() +'HELLO' +``` + """, +) + +SHARED_GROUP_REST = textwrap.dedent( + """ +Title +===== + +.. doctest:: intro + + >>> greeting = "hello" + +Narrative prose. + +.. doctest:: intro + + >>> greeting.upper() + 'HELLO' + """, +) + + +def _write_ini( + pytester: _pytest.pytester.Pytester, + *lines: str, + addopts: str = "", +) -> None: + """Write a pytest.ini that keeps the built-in doctest plugin out. + + ``addopts`` appends to that, for a run whose configuration is the thing + under test. + """ + pytester.makefile( + ".ini", + pytest="\n".join( + ["[pytest]", f"addopts=-p no:doctest {addopts}".rstrip(), *lines], + ), + ) + + +class NamespaceCollectionCase(t.NamedTuple): + """Page and the items it collects. + + Attributes + ---------- + test_id : str + pytest parametrize id. + file_name : str + Page written into the pytester directory. + page : str + Page content. + node_ids : list[str] + Node ids expected, in collection order. + """ + + test_id: str + file_name: str + page: str + node_ids: list[str] + + +NAMESPACE_COLLECTION_CASES = [ + NamespaceCollectionCase( + test_id="group-collects-as-one-item", + file_name="page.rst", + page=SHARED_GROUP_REST, + node_ids=["page.rst::intro"], + ), + NamespaceCollectionCase( + test_id="markdown-group-collects-as-one-item", + file_name="page.md", + page=textwrap.dedent( + """ +# Title + +```{doctest} intro +>>> greeting = "hello" +``` + +Narrative prose. + +```{doctest} intro +>>> greeting.upper() +'HELLO' +``` + """, + ), + node_ids=["page.md::intro"], + ), + NamespaceCollectionCase( + test_id="distinct-groups-collect-separately", + file_name="page.rst", + page=textwrap.dedent( + """ +Title +===== + +.. doctest:: alpha + + >>> alpha_only = 1 + +.. doctest:: beta + + >>> alpha_only + Traceback (most recent call last): + NameError: name 'alpha_only' is not defined + """, + ), + node_ids=["page.rst::alpha", "page.rst::beta"], + ), + NamespaceCollectionCase( + test_id="ungrouped-blocks-collect-one-item-each", + file_name="page.md", + page="\n".join(f"```python\n>>> {n}\n{n}\n```\n" for n in range(12)), + node_ids=[f"page.md::page.md[{n}]" for n in range(12)], + ), +] + + +@pytest.mark.parametrize( + NamespaceCollectionCase._fields, + NAMESPACE_COLLECTION_CASES, + ids=[case.test_id for case in NAMESPACE_COLLECTION_CASES], +) +def test_namespace_collection( + pytester: _pytest.pytester.Pytester, + test_id: str, + file_name: str, + page: str, + node_ids: list[str], +) -> None: + """A page collects one item per namespace, in the order it reads. + + The node id carries the namespace and nothing machine-specific, so it can + be written into a ``--deselect`` and survive the trip to another checkout. + """ + pytester.plugins = ["pytest_doctest_docutils"] + _write_ini(pytester) + (pytester.path / file_name).write_text(page, encoding="utf-8") + + items, _ = pytester.inline_genitems(file_name) + + assert [item.nodeid for item in items] == node_ids + + result = pytester.runpytest(file_name) + result.assert_outcomes(passed=len(node_ids)) + + +def test_node_id_selects_one_namespace( + pytester: _pytest.pytester.Pytester, +) -> None: + """Running a node id runs exactly the namespace it names.""" + pytester.plugins = ["pytest_doctest_docutils"] + _write_ini(pytester) + (pytester.path / "page.rst").write_text(SHARED_GROUP_REST, encoding="utf-8") + + result = pytester.runpytest("page.rst::intro", "-v") + + result.assert_outcomes(passed=1) + result.stdout.fnmatch_lines(["page.rst::intro *"]) + + +def test_group_stops_at_the_document( + pytester: _pytest.pytester.Pytester, +) -> None: + """The same group name on two pages is two namespaces. + + Groups are read per document, as they are in :mod:`sphinx.ext.doctest`, so + one page cannot reach into the state another built. + """ + pytester.plugins = ["pytest_doctest_docutils"] + _write_ini(pytester) + (pytester.path / "first.rst").write_text( + ".. doctest:: intro\n\n >>> only_in_first = 1\n", + encoding="utf-8", + ) + (pytester.path / "second.rst").write_text( + textwrap.dedent( + """ +.. doctest:: intro + + >>> only_in_first + Traceback (most recent call last): + NameError: name 'only_in_first' is not defined + """, + ), + encoding="utf-8", + ) + + result = pytester.runpytest(str(pytester.path)) + + result.assert_outcomes(passed=2) + + +class NamespaceScopeOptionCase(t.NamedTuple): + """Namespace scope driven through the plugin's configuration. + + Attributes + ---------- + test_id : str + pytest parametrize id. + file_name : str + Page written into the pytester directory. + page : str + Page content. + ini_scope : str + Value for the ``doctest_docutils_namespace_scope`` ini option, empty to + leave it unset. + cli_args : list[str] + Extra command-line arguments for the run. + passed : int + Items expected to pass. + failed : int + Items expected to fail. + """ + + test_id: str + file_name: str + page: str + ini_scope: str + cli_args: list[str] + passed: int + failed: int + + +NAMESPACE_SCOPE_OPTION_CASES = [ + NamespaceScopeOptionCase( + test_id="unconfigured-keeps-blocks-apart", + file_name="page.md", + page=STATE_MD, + ini_scope="", + cli_args=[], + passed=1, + failed=1, + ), + NamespaceScopeOptionCase( + test_id="ini-document-shares-the-page", + file_name="page.md", + page=STATE_MD, + ini_scope="document", + cli_args=[], + passed=1, + failed=0, + ), + NamespaceScopeOptionCase( + test_id="cli-document-shares-the-page", + file_name="page.md", + page=STATE_MD, + ini_scope="", + cli_args=["--doctest-docutils-namespace-scope=document"], + passed=1, + failed=0, + ), + NamespaceScopeOptionCase( + test_id="cli-block-overrides-ini-document", + file_name="page.md", + page=STATE_MD, + ini_scope="document", + cli_args=["--doctest-docutils-namespace-scope=block"], + passed=1, + failed=1, + ), + NamespaceScopeOptionCase( + test_id="a-group-shares-whatever-the-scope-says", + file_name="page.rst", + page=SHARED_GROUP_REST, + ini_scope="block", + cli_args=[], + passed=1, + failed=0, + ), +] + + +@pytest.mark.parametrize( + NamespaceScopeOptionCase._fields, + NAMESPACE_SCOPE_OPTION_CASES, + ids=[case.test_id for case in NAMESPACE_SCOPE_OPTION_CASES], +) +def test_namespace_scope_option( + pytester: _pytest.pytester.Pytester, + test_id: str, + file_name: str, + page: str, + ini_scope: str, + cli_args: list[str], + passed: int, + failed: int, +) -> None: + """The scope reaches the finder from the ini file or the flag, flag first.""" + pytester.plugins = ["pytest_doctest_docutils"] + _write_ini( + pytester, + *([f"doctest_docutils_namespace_scope = {ini_scope}"] if ini_scope else []), + ) + (pytester.path / file_name).write_text(page, encoding="utf-8") + + result = pytester.runpytest(file_name, *cli_args) + + result.assert_outcomes(passed=passed, failed=failed) + + +def test_namespace_scope_rejects_an_unknown_ini_value( + pytester: _pytest.pytester.Pytester, +) -> None: + """A misspelled scope stops the session once, naming the values it knows.""" + pytester.plugins = ["pytest_doctest_docutils"] + _write_ini(pytester, "doctest_docutils_namespace_scope = per-file") + (pytester.path / "first.md").write_text(STATE_MD, encoding="utf-8") + (pytester.path / "second.md").write_text(STATE_MD, encoding="utf-8") + + result = pytester.runpytest(str(pytester.path)) + + assert result.ret == pytest.ExitCode.USAGE_ERROR + result.stderr.fnmatch_lines( + ["*Unknown namespace scope: 'per-file'*block, document*"], + ) + assert ( + len( + [line for line in result.stderr.lines if "Unknown namespace scope" in line], + ) + == 1 + ) + + +def test_document_scope_survives_xdist( + pytester: _pytest.pytester.Pytester, +) -> None: + """A shared page passes when pytest splits the session across workers. + + A namespace is one item, so no worker can be handed half of one. This is + the property that decided the design, which is why ``pytest-xdist`` is a + development dependency rather than something to skip around when absent. + """ + pytester.plugins = ["pytest_doctest_docutils"] + _write_ini(pytester, "doctest_docutils_namespace_scope = document") + (pytester.path / "page.md").write_text(STATE_MD, encoding="utf-8") + (pytester.path / "other.md").write_text(STATE_MD, encoding="utf-8") + + result = pytester.runpytest(str(pytester.path), "-n", "2") + + result.assert_outcomes(passed=2) + + +GATED_STATE_MD = textwrap.dedent( + """ +# Title + +```python +>>> greeting = "hello" +``` + +```python +>>> greeting = "nope" # doctest: +SKIP +``` + +```python +>>> greeting.upper() +'HELLO' +``` + """, +) + + +def test_a_shared_page_still_reports_its_gated_block( + pytester: _pytest.pytester.Pytester, +) -> None: + """A page merged end to end still says which of its blocks did not run. + + Under ``document`` a page with no groups is one namespace, which is where + a gated block would otherwise disappear: the item passes on the strength + of the blocks that ran and nothing names the one that did not. + """ + pytester.plugins = ["pytest_doctest_docutils"] + _write_ini(pytester, "doctest_docutils_namespace_scope = document") + (pytester.path / "page.md").write_text(GATED_STATE_MD, encoding="utf-8") + + result = pytester.runpytest("page.md", "-rs", "-v") + + result.assert_outcomes(passed=1, skipped=1) + result.stdout.fnmatch_lines( + ["page.md::page.md PASSED*", "page.md::page.md[[]1[]] SKIPPED*"], + consecutive=True, + ) + result.stdout.fnmatch_lines( + ["SKIPPED [[]1[]] *: page.md:*: every example skipped"], + ) + + +def test_a_gated_block_survives_xdist( + pytester: _pytest.pytester.Pytester, +) -> None: + """The item a gated block collects as distributes like any other. + + It is an ordinary item holding one block's examples, so a worker gets all + of it or none of it, the same property the merged namespace has. + """ + pytester.plugins = ["pytest_doctest_docutils"] + _write_ini(pytester, "doctest_docutils_namespace_scope = document") + (pytester.path / "page.md").write_text(GATED_STATE_MD, encoding="utf-8") + (pytester.path / "other.md").write_text(GATED_STATE_MD, encoding="utf-8") + + result = pytester.runpytest(str(pytester.path), "-n", "2") + + result.assert_outcomes(passed=2, skipped=2) + + +class NamespaceItemsOptionCase(t.NamedTuple): + """Namespace layout driven through the plugin's configuration. + + Attributes + ---------- + test_id : str + pytest parametrize id. + page : str + Page content, written as ``page.md``. + ini_lines : list[str] + Extra lines for the generated ``pytest.ini``. + cli_args : list[str] + Extra command-line arguments for the run. + node_ids : list[str] + Node ids expected, in collection order. + passed : int + Items expected to pass. + failed : int + Items expected to fail. + """ + + test_id: str + page: str + ini_lines: list[str] + cli_args: list[str] + node_ids: list[str] + passed: int + failed: int + + +NAMESPACE_ITEMS_OPTION_CASES = [ + NamespaceItemsOptionCase( + test_id="unconfigured-merges-a-shared-page", + page=STATE_MD, + ini_lines=["doctest_docutils_namespace_scope = document"], + cli_args=[], + node_ids=["page.md::page.md"], + passed=1, + failed=0, + ), + NamespaceItemsOptionCase( + test_id="ini-per-block-keeps-both-node-ids", + page=STATE_MD, + ini_lines=[ + "doctest_docutils_namespace_scope = document", + "doctest_docutils_namespace_items = per-block", + ], + cli_args=[], + node_ids=["page.md::page.md[0]", "page.md::page.md[1]"], + passed=2, + failed=0, + ), + NamespaceItemsOptionCase( + test_id="cli-per-block-keeps-both-node-ids", + page=STATE_MD, + ini_lines=["doctest_docutils_namespace_scope = document"], + cli_args=["--doctest-docutils-namespace-items=per-block"], + node_ids=["page.md::page.md[0]", "page.md::page.md[1]"], + passed=2, + failed=0, + ), + NamespaceItemsOptionCase( + test_id="cli-merged-overrides-ini-per-block", + page=STATE_MD, + ini_lines=[ + "doctest_docutils_namespace_scope = document", + "doctest_docutils_namespace_items = per-block", + ], + cli_args=["--doctest-docutils-namespace-items=merged"], + node_ids=["page.md::page.md"], + passed=1, + failed=0, + ), + NamespaceItemsOptionCase( + test_id="per-block-alone-shares-nothing", + page=STATE_MD, + ini_lines=["doctest_docutils_namespace_items = per-block"], + cli_args=[], + node_ids=["page.md::page.md[0]", "page.md::page.md[1]"], + passed=1, + failed=1, + ), +] + + +@pytest.mark.parametrize( + NamespaceItemsOptionCase._fields, + NAMESPACE_ITEMS_OPTION_CASES, + ids=[case.test_id for case in NAMESPACE_ITEMS_OPTION_CASES], +) +def test_namespace_items_option( + pytester: _pytest.pytester.Pytester, + test_id: str, + page: str, + ini_lines: list[str], + cli_args: list[str], + node_ids: list[str], + passed: int, + failed: int, +) -> None: + """The layout reaches the finder from the ini file or the flag, flag first. + + Scope and layout are separate questions: the scope says what shares a + namespace, the layout says whether sharing costs the blocks their node + ids. Setting only the layout shares nothing, because the default scope + still gives each block a namespace of its own. + """ + pytester.plugins = ["pytest_doctest_docutils"] + _write_ini(pytester, *ini_lines) + (pytester.path / "page.md").write_text(page, encoding="utf-8") + + items, _ = pytester.inline_genitems("page.md", *cli_args) + assert [item.nodeid for item in items] == node_ids + + result = pytester.runpytest("page.md", *cli_args) + result.assert_outcomes(passed=passed, failed=failed) + + +def test_namespace_items_rejects_an_unknown_ini_value( + pytester: _pytest.pytester.Pytester, +) -> None: + """A misspelled layout stops the session once, naming the values it knows.""" + pytester.plugins = ["pytest_doctest_docutils"] + _write_ini(pytester, "doctest_docutils_namespace_items = one-each") + (pytester.path / "first.md").write_text(STATE_MD, encoding="utf-8") + (pytester.path / "second.md").write_text(STATE_MD, encoding="utf-8") + + result = pytester.runpytest(str(pytester.path)) + + assert result.ret == pytest.ExitCode.USAGE_ERROR + result.stderr.fnmatch_lines( + ["*Unknown namespace items: 'one-each'*merged, per-block*"], + ) + + +def test_a_per_block_node_id_runs_one_block( + pytester: _pytest.pytester.Pytester, +) -> None: + """A node id reaches one block, and says plainly what running it alone costs. + + Reaching a block is the whole point of the layout — ``--lf``, ``-k``, a + JUnit report and a re-run all work through the id. A block that reads what + the block above it bound cannot run alone, because nothing bound it: that + limitation is inherent to running a fragment of a session, so it reports as + the ``NameError`` it is. + """ + pytester.plugins = ["pytest_doctest_docutils"] + _write_ini( + pytester, + "doctest_docutils_namespace_scope = document", + "doctest_docutils_namespace_items = per-block", + ) + (pytester.path / "page.md").write_text(STATE_MD, encoding="utf-8") + + first = pytester.runpytest("page.md::page.md[0]", "-v") + first.assert_outcomes(passed=1) + first.stdout.fnmatch_lines(["page.md::page.md[[]0[]] *"]) + + second = pytester.runpytest("page.md::page.md[1]") + + second.assert_outcomes(failed=1) + second.stdout.fnmatch_lines(["*NameError: name 'greeting' is not defined*"]) + + +def test_per_block_marks_each_namespace_for_loadgroup( + pytester: _pytest.pytester.Pytester, +) -> None: + """Every block carries the group its namespace distributes under. + + The plugin can emit the marker but cannot pick the scheduler, so the + marker is what makes ``--dist loadgroup`` usable. The group is the file + plus the namespace, because a namespace never reaches past its page. + """ + pytester.plugins = ["pytest_doctest_docutils"] + _write_ini( + pytester, + "doctest_docutils_namespace_scope = document", + "doctest_docutils_namespace_items = per-block", + ) + (pytester.path / "page.md").write_text(STATE_MD, encoding="utf-8") + + items, _ = pytester.inline_genitems("page.md") + + markers = [item.get_closest_marker("xdist_group") for item in items] + assert [marker.args[0] for marker in markers if marker is not None] == [ + "page.md::page.md", + "page.md::page.md", + ] + + +def test_merged_marks_nothing_for_loadgroup( + pytester: _pytest.pytester.Pytester, +) -> None: + """A merged namespace is one item, which no scheduler can split.""" + pytester.plugins = ["pytest_doctest_docutils"] + _write_ini(pytester, "doctest_docutils_namespace_scope = document") + (pytester.path / "page.md").write_text(STATE_MD, encoding="utf-8") + + items, _ = pytester.inline_genitems("page.md") + + assert [item.get_closest_marker("xdist_group") for item in items] == [None] + + +class SplittingSchedulerCase(t.NamedTuple): + """Invocation naming a scheduler that distributes a namespace by item. + + Attributes + ---------- + test_id : str + pytest parametrize id. + args : list[str] + Arguments appended to the run. + addopts : str + Arguments the ini file carries instead. + named : str + Scheduler the refusal is expected to name. + """ + + test_id: str + args: list[str] + addopts: str + named: str + + +SPLITTING_SCHEDULER_CASES = [ + SplittingSchedulerCase( + test_id="load-distributes-by-item", + args=["-n", "2", "--dist", "load"], + addopts="", + named="load", + ), + SplittingSchedulerCase( + test_id="worksteal-distributes-then-rebalances", + args=["-n", "2", "--dist", "worksteal"], + addopts="", + named="worksteal", + ), + SplittingSchedulerCase( + test_id="addopts-names-the-scheduler-too", + args=["-n", "2"], + addopts="--dist load", + named="load", + ), + SplittingSchedulerCase( + test_id="multiplied-tx-spells-more-than-one-worker", + args=["--tx", "2*popen", "--dist", "load"], + addopts="", + named="load", + ), + SplittingSchedulerCase( + test_id="multiplied-tx-adds-up-across-specifications", + args=["--tx", "1*popen", "--tx", "1*popen", "--dist", "load"], + addopts="", + named="load", + ), + SplittingSchedulerCase( + test_id="a-count-asking-for-none-takes-none-away", + args=["--tx", "-1*popen", "--tx", "2*popen", "--dist", "load"], + addopts="", + named="load", + ), +] + + +@pytest.mark.parametrize( + SplittingSchedulerCase._fields, + SPLITTING_SCHEDULER_CASES, + ids=[case.test_id for case in SPLITTING_SCHEDULER_CASES], +) +def test_per_block_refuses_a_named_splitting_scheduler( + pytester: _pytest.pytester.Pytester, + test_id: str, + args: list[str], + addopts: str, + named: str, +) -> None: + """Naming a scheduler that distributes by item stops the run. + + ``load`` hands a file's items to whichever worker is free and + ``worksteal`` does the same, then re-balances. A shared globals mapping + is a Python object and does not cross processes, so the session stops + rather than reporting a page that is only wrong because of how it was + scheduled. Asking for one by name is a choice to answer, not to overrule. + + A scheduler asked for through ini ``addopts`` is asked for just as much + as one typed on the command line — pytest folds ``addopts`` into the + arguments before parsing them, which is why reading the parsed value + finds both. It is also why ``sys.argv`` cannot be read instead. + """ + pytester.plugins = ["pytest_doctest_docutils"] + _write_ini( + pytester, + "doctest_docutils_namespace_scope = document", + "doctest_docutils_namespace_items = per-block", + addopts=addopts, + ) + (pytester.path / "page.md").write_text(STATE_MD, encoding="utf-8") + + result = pytester.runpytest(str(pytester.path), *args) + + assert result.ret == pytest.ExitCode.USAGE_ERROR + result.stderr.fnmatch_lines([f"*--dist {named} hands a file's items*"]) + result.stderr.fnmatch_lines(["*--dist loadgroup or --dist loadfile*"]) + + +def test_per_block_keeps_a_single_multiplied_worker( + pytester: _pytest.pytester.Pytester, +) -> None: + """One worker cannot split a namespace, however the run spelled it. + + ``--tx 1*popen`` asks for the same single environment ``--tx popen`` + does. Counting the multiplier has to leave that run alone, or reading + the shorthand correctly would cost every one-worker run its scheduler. + """ + pytester.plugins = ["pytest_doctest_docutils"] + _write_ini( + pytester, + "doctest_docutils_namespace_scope = document", + "doctest_docutils_namespace_items = per-block", + ) + (pytester.path / "page.md").write_text(STATE_MD, encoding="utf-8") + + result = pytester.runpytest( + str(pytester.path), + "--tx", + "1*popen", + "--dist", + "load", + ) + + result.assert_outcomes(passed=2) + + +def test_per_block_keeps_workers_for_a_suite_holding_no_page( + pytester: _pytest.pytester.Pytester, +) -> None: + """A suite with no page keeps its workers, whatever scheduler it named. + + The layout is a project-wide setting, so a project can carry it in its + ini while a given run collects only Python tests. Nothing there holds a + namespace, so there is nothing a scheduler could split and no reason to + take ``-n`` away — which is why the refusal reads the run's collection + rather than the setting. + """ + pytester.plugins = ["pytest_doctest_docutils"] + _write_ini( + pytester, + "doctest_docutils_namespace_scope = document", + "doctest_docutils_namespace_items = per-block", + ) + pytester.makepyfile( + test_python=""" + def test_one() -> None: + assert True + + + def test_two() -> None: + assert True + """, + ) + + left_open = pytester.runpytest(str(pytester.path), "-n", "2") + left_open.assert_outcomes(passed=2) + + named = pytester.runpytest(str(pytester.path), "-n", "2", "--dist", "worksteal") + + named.assert_outcomes(passed=2) + + +def test_per_block_fills_in_a_scheduler_the_run_left_open( + pytester: _pytest.pytester.Pytester, +) -> None: + """``-n`` alone asks for workers, not for a way of filling them. + + pytest-xdist answers it with ``--dist load``, which splits a page. The + run said nothing about distribution, so file-level scheduling is filled + in and the page comes through whole beside the Python tests that share + the session. + + The node ids stay the ones the layout collects. ``loadgroup`` would suit + the marker the plugin emits, but the group is appended to a node id by + the worker, from the worker's own ``--dist`` value, so a controller + cannot reach it — and substituting that scheduler would leave every item + in a scope of its own and split the page after all. + """ + pytester.plugins = ["pytest_doctest_docutils"] + _write_ini( + pytester, + "doctest_docutils_namespace_scope = document", + "doctest_docutils_namespace_items = per-block", + ) + (pytester.path / "page.md").write_text(STATE_MD, encoding="utf-8") + (pytester.path / "other.md").write_text(STATE_MD, encoding="utf-8") + pytester.makepyfile( + test_python=""" + def test_one() -> None: + assert True + """, + ) + + result = pytester.runpytest(str(pytester.path), "-n", "2", "-v") + + result.assert_outcomes(passed=5) + result.stdout.fnmatch_lines(["*scheduling tests via _PageScheduling*"]) + assert not [line for line in result.stdout.lines if "@page.md" in line] + + +def test_merged_survives_the_splitting_scheduler( + pytester: _pytest.pytester.Pytester, +) -> None: + """The refusal reaches only the layout that needs it.""" + pytester.plugins = ["pytest_doctest_docutils"] + _write_ini(pytester, "doctest_docutils_namespace_scope = document") + (pytester.path / "page.md").write_text(STATE_MD, encoding="utf-8") + (pytester.path / "other.md").write_text(STATE_MD, encoding="utf-8") + + result = pytester.runpytest(str(pytester.path), "-n", "2") + + result.assert_outcomes(passed=2) + + +class PerBlockSchedulerCase(t.NamedTuple): + """Distributed run a per-block page comes through whole. + + Attributes + ---------- + test_id : str + pytest parametrize id. + args : list[str] + Arguments the run is made with. + passed : int + Examples expected to pass across every worker. + """ + + test_id: str + args: list[str] + passed: int + + +PER_BLOCK_SCHEDULER_CASES = [ + PerBlockSchedulerCase( + test_id="loadfile-keeps-a-file-whole", + args=["-n", "2", "--dist", "loadfile"], + passed=4, + ), + PerBlockSchedulerCase( + test_id="loadgroup-keeps-a-namespace-whole", + args=["-n", "2", "--dist", "loadgroup"], + passed=4, + ), + PerBlockSchedulerCase( + test_id="loadscope-keeps-a-file-whole", + args=["-n", "2", "--dist", "loadscope"], + passed=4, + ), + PerBlockSchedulerCase( + test_id="each-repeats-the-suite-per-worker", + args=["-n", "2", "--dist", "each"], + passed=8, + ), + PerBlockSchedulerCase( + test_id="one-worker-has-nothing-to-split-against", + args=["-n", "1"], + passed=4, + ), + PerBlockSchedulerCase( + test_id="n-alone-leaves-the-scheduler-to-fill-in", + args=["-n", "2"], + passed=4, + ), + PerBlockSchedulerCase( + test_id="dist-without-workers-never-distributes", + args=["--dist", "load"], + passed=4, + ), +] + + +@pytest.mark.parametrize( + PerBlockSchedulerCase._fields, + PER_BLOCK_SCHEDULER_CASES, + ids=[case.test_id for case in PER_BLOCK_SCHEDULER_CASES], +) +def test_per_block_survives_a_scheduler_that_keeps_it_together( + pytester: _pytest.pytester.Pytester, + test_id: str, + args: list[str], + passed: int, +) -> None: + """A state-building page passes wherever its namespace stays on one worker. + + ``loadfile`` and ``loadscope`` split on the node id's path; ``loadgroup`` + reads the ``xdist_group`` marker the plugin emits; ``each`` gives every + worker the whole suite. A run xdist would not distribute at all — one + worker, or a ``--dist`` value with no workers behind it — is not refused + either, because there is nothing for it to split a namespace between. + ``-n`` on its own names no scheduler, so one that keeps a page whole is + filled in. + """ + pytester.plugins = ["pytest_doctest_docutils"] + _write_ini( + pytester, + "doctest_docutils_namespace_scope = document", + "doctest_docutils_namespace_items = per-block", + ) + (pytester.path / "page.md").write_text(STATE_MD, encoding="utf-8") + (pytester.path / "other.md").write_text(STATE_MD, encoding="utf-8") + + result = pytester.runpytest(str(pytester.path), *args) + + result.assert_outcomes(passed=passed) + + +def test_strict_markers_passes_whether_or_not_you_opt_in( + pytester: _pytest.pytester.Pytester, +) -> None: + """``xdist_group`` is registered whatever the layout, and pytest-xdist absent. + + A project that never asks for the layout never meets the marker at all. + One that does, on a machine without pytest-xdist to register the marker + itself, would otherwise have every item rejected as carrying an unknown + marker. + + Run out of process because pytest caches known marker names on the global + ``MarkGenerator``, so an in-process run inherits whatever this suite's own + session registered and could not tell the two cases apart. + """ + _write_ini(pytester) + (pytester.path / "page.md").write_text(STATE_MD, encoding="utf-8") + + unconfigured = pytester.runpytest_subprocess( + "page.md", + "--strict-markers", + "-p", + "no:xdist", + ) + unconfigured.assert_outcomes(passed=1, failed=1) + + result = pytester.runpytest_subprocess( + "page.md", + "--strict-markers", + "-p", + "no:xdist", + "--doctest-docutils-namespace-items=per-block", + ) + + result.assert_outcomes(passed=1, failed=1) + + +def test_xdist_group_is_listed_once(pytester: _pytest.pytester.Pytester) -> None: + """``pytest --markers`` describes the marker once, xdist installed or not. + + pytest-xdist registers ``xdist_group`` itself, so this plugin only fills + the gap it leaves. Registering unconditionally would list the marker twice + for every project that has xdist, opted in or not. + + Run out of process for the same reason as the ``--strict-markers`` case: + marker registration is read back off configuration this suite's own + session has already populated. + """ + _write_ini(pytester) + + with_xdist = pytester.runpytest_subprocess("--markers") + without_xdist = pytester.runpytest_subprocess("--markers", "-p", "no:xdist") + + def listed(result: _pytest.pytester.RunResult) -> list[str]: + return [ + line + for line in result.stdout.lines + if line.startswith("@pytest.mark.xdist_group") + ] + + assert len(listed(with_xdist)) == 1 + assert listed(without_xdist) == [ + ( + "@pytest.mark.xdist_group(name): keep a namespace's blocks on one" + " pytest-xdist worker under --dist loadgroup" + ), + ] + + +def test_per_block_collects_under_collect_only( + pytester: _pytest.pytester.Pytester, +) -> None: + """``--collect-only`` is never refused: xdist skips itself when only collecting. + + A project carrying ``-n`` in its ``addopts`` has to be able to enumerate + its own suite, and no example runs, so no namespace is ever shared. + """ + pytester.plugins = ["pytest_doctest_docutils"] + _write_ini( + pytester, + "doctest_docutils_namespace_scope = document", + "doctest_docutils_namespace_items = per-block", + ) + (pytester.path / "page.md").write_text(STATE_MD, encoding="utf-8") + + result = pytester.runpytest(str(pytester.path), "--collect-only", "-q", "-n", "2") + + assert result.ret == pytest.ExitCode.OK + result.stdout.fnmatch_lines(["page.md::page.md[[]0[]]", "page.md::page.md[[]1[]]"]) + + +def test_per_block_reports_the_layout_it_resolved( + pytester: _pytest.pytester.Pytester, +) -> None: + """The header says which layout ran, and says nothing when it is the usual one.""" + pytester.plugins = ["pytest_doctest_docutils"] + _write_ini(pytester, "doctest_docutils_namespace_scope = document") + (pytester.path / "page.md").write_text(STATE_MD, encoding="utf-8") + + quiet = pytester.runpytest("page.md") + assert not [ + line for line in quiet.stdout.lines if line.startswith("doctest-docutils:") + ] + + result = pytester.runpytest( + "page.md", + "--doctest-docutils-namespace-items=per-block", + ) + + result.stdout.fnmatch_lines( + ["doctest-docutils: namespace items: per-block, namespace scope: document"], + ) + + +DOCTEST_NAMESPACE_CONFTEST = textwrap.dedent( + """ +from typing import Any, Dict +import pytest + +@pytest.fixture(autouse=True) +def add_doctest_fixtures(doctest_namespace: Dict[str, Any]): + doctest_namespace["add"] = lambda a, b: a + b + """, +) + +FIXTURE_USING_MD = textwrap.dedent( + """ +# Title + +```python +>>> add(1, 2) +3 +``` + +Prose between the blocks. + +```python +>>> add(3, 4) +7 +``` + """, +) + + +@pytest.mark.parametrize( + ("test_id", "items", "passed"), + [ + ("merged", "merged", 1), + ("per-block", "per-block", 2), + ], + ids=["merged", "per-block"], +) +def test_doctest_namespace_reaches_every_block( + pytester: _pytest.pytester.Pytester, + test_id: str, + items: str, + passed: int, +) -> None: + """A fixture seeded into the namespace is in scope for every block of it. + + pytest merges the fixture into ``dtest.globs`` at item setup. Merged, that + happens once for the namespace; per block it happens once per block, into + the one mapping they share. + """ + pytester.plugins = ["pytest_doctest_docutils"] + _write_ini( + pytester, + "doctest_docutils_namespace_scope = document", + f"doctest_docutils_namespace_items = {items}", + ) + pytester.makeconftest(DOCTEST_NAMESPACE_CONFTEST) + (pytester.path / "page.md").write_text(FIXTURE_USING_MD, encoding="utf-8") + + result = pytester.runpytest("page.md") + + result.assert_outcomes(passed=passed) + + +TORN_DOWN_FIXTURE_CONFTEST = textwrap.dedent( + """ +from typing import Any, Dict +import pytest + + +class Server: + def __init__(self) -> None: + self.alive = True + + +@pytest.fixture(autouse=True) +def server(doctest_namespace: Dict[str, Any]): + running = Server() + doctest_namespace["server"] = running + yield running + running.alive = False + """, +) + +CARRIED_FIXTURE_MD = textwrap.dedent( + """ +# Title + +```python +>>> kept = server +>>> kept.alive +True +``` + +Prose between the blocks. + +```python +>>> kept.alive +True +``` + """, +) + + +@pytest.mark.parametrize( + ("test_id", "items", "passed", "failed"), + [ + ("merged", "merged", 1, 0), + ("per-block", "per-block", 1, 1), + ], + ids=["merged", "per-block"], +) +def test_per_block_finalizes_a_fixture_between_blocks( + pytester: _pytest.pytester.Pytester, + test_id: str, + items: str, + passed: int, + failed: int, +) -> None: + """A namespace shares the mapping, not the lifetime of what a fixture made. + + Per block, each block is its own item, so a function-scoped fixture tears + down between them. An object one block bound out of that fixture is + finalized before the next block reads it, which merged is a single item + and so never happens. + """ + pytester.plugins = ["pytest_doctest_docutils"] + _write_ini( + pytester, + "doctest_docutils_namespace_scope = document", + f"doctest_docutils_namespace_items = {items}", + ) + pytester.makeconftest(TORN_DOWN_FIXTURE_CONFTEST) + (pytester.path / "page.md").write_text(CARRIED_FIXTURE_MD, encoding="utf-8") + + result = pytester.runpytest("page.md") + + result.assert_outcomes(passed=passed, failed=failed) + + +def test_per_block_still_reports_a_gated_block( + pytester: _pytest.pytester.Pytester, +) -> None: + """A gated block reports skipped, and the block after it still runs. + + Merged, a gated block has to be lifted out of its namespace to report at + all; per block it is already an item, and it is marked before setup either + way, so its fixtures never run for a block that executes nothing. + """ + pytester.plugins = ["pytest_doctest_docutils"] + _write_ini( + pytester, + "doctest_docutils_namespace_scope = document", + "doctest_docutils_namespace_items = per-block", + ) + (pytester.path / "page.md").write_text(GATED_STATE_MD, encoding="utf-8") + + result = pytester.runpytest("page.md", "-rs", "-v") + + result.assert_outcomes(passed=2, skipped=1) + result.stdout.fnmatch_lines( + [ + "page.md::page.md[[]0[]] PASSED*", + "page.md::page.md[[]1[]] SKIPPED*", + "page.md::page.md[[]2[]] PASSED*", + ], + consecutive=True, + ) + result.stdout.fnmatch_lines( + ["SKIPPED [[]1[]] *: page.md:*: every example skipped"], + ) + + +def test_merged_keeps_a_failure_through_a_retry( + pytester: _pytest.pytester.Pytester, +) -> None: + """A retry re-runs a merged namespace whole, so a real failure stands. + + The retry rebuilds the namespace from its first block, which is what makes + the default layout safe to combine with a test-retry plugin. Under + ``per-block`` a retry re-runs only the block that failed, against the + mapping that block already changed. + """ + pytester.plugins = ["pytest_doctest_docutils"] + _write_ini(pytester) + (pytester.path / "page.rst").write_text( + textwrap.dedent( + """ + Title + ===== + + .. doctest:: demo + + >>> seen = [] + + .. doctest:: demo + + >>> seen.append(1) + >>> len(seen) + 2 + """, + ), + encoding="utf-8", + ) + + result = pytester.runpytest("page.rst", "--reruns", "2") + + result.assert_outcomes(failed=1) + result.stdout.fnmatch_lines(["*1 failed*2 rerun*"]) + + +def test_per_block_refuses_a_repeated_block( + pytester: _pytest.pytester.Pytester, +) -> None: + """A block run twice is refused rather than trusted. + + A retry re-runs one block against the globals it already changed, so an + expectation that comes true on the second attempt would report as a pass. + The namespace cannot be rebuilt for one block alone, so the repeat fails + with a message naming the way out. + """ + pytester.plugins = ["pytest_doctest_docutils"] + _write_ini(pytester, "doctest_docutils_namespace_items = per-block") + (pytester.path / "page.rst").write_text( + textwrap.dedent( + """ + Title + ===== + + .. doctest:: demo + + >>> seen = [] + + .. doctest:: demo + + >>> seen.append(1) + >>> len(seen) + 2 + """, + ), + encoding="utf-8", + ) + + result = pytester.runpytest("page.rst", "--reruns", "2") + + result.assert_outcomes(passed=1, failed=1) + result.stdout.fnmatch_lines( + ["*was run twice against a namespace laid out per block*"] + ) + + +TESTCODE_PAGE_MD = textwrap.dedent( + """ + # Page + + Visible, pasteable, no prompt: + + ```{testcode} + value = 41 + ``` + + Hidden assertion the reader never sees: + + ```{testcode} + :hide: + + assert value == 41 + ``` + + Visible with expected output: + + ```{testcode} + print(value + 1) + ``` + + ```{testoutput} + 42 + ``` + """, +) + + +TESTCODE_NAMESPACE_CASES = [ + ("block-merged", "block", "merged", 1), + ("document-merged", "document", "merged", 1), + ("block-per-block", "block", "per-block", 3), + ("document-per-block", "document", "per-block", 3), +] + + +@pytest.mark.parametrize( + ("test_id", "scope", "items", "passed"), + TESTCODE_NAMESPACE_CASES, + ids=[case[0] for case in TESTCODE_NAMESPACE_CASES], +) +def test_a_prompt_free_page_passes_at_every_namespace_setting( + pytester: _pytest.pytester.Pytester, + test_id: str, + scope: str, + items: str, + passed: int, +) -> None: + """The page a reader pastes out of runs under every namespace setting. + + The blocks share the ``default`` group whatever the scope, so the layout + decides only how many items they collect as. + """ + pytester.plugins = ["pytest_doctest_docutils"] + _write_ini( + pytester, + f"doctest_docutils_namespace_scope = {scope}", + f"doctest_docutils_namespace_items = {items}", + ) + (pytester.path / "page.md").write_text(TESTCODE_PAGE_MD, encoding="utf-8") + + result = pytester.runpytest("page.md") + + result.assert_outcomes(passed=passed) + + +def test_a_prompt_free_page_collects_as_its_group( + pytester: _pytest.pytester.Pytester, +) -> None: + """The node id a reader pastes back names the page, not a block index.""" + pytester.plugins = ["pytest_doctest_docutils"] + _write_ini(pytester) + (pytester.path / "page.md").write_text(TESTCODE_PAGE_MD, encoding="utf-8") + + items, _ = pytester.inline_genitems("page.md") + + assert [item.name for item in items] == ["page.md"] + + +def test_a_failing_testoutput_reports_against_its_page( + pytester: _pytest.pytester.Pytester, +) -> None: + """A mismatch reports as an ordinary doctest failure on the page's line.""" + pytester.plugins = ["pytest_doctest_docutils"] + _write_ini(pytester) + (pytester.path / "page.md").write_text( + "```{testcode}\nprint(41 + 1)\n```\n\n```{testoutput}\n99\n```\n", + encoding="utf-8", + ) + + result = pytester.runpytest("page.md") + + result.assert_outcomes(failed=1) + result.stdout.fnmatch_lines(["*Expected:*", "*99*", "*Got:*", "*42*"]) + + +def test_the_canonical_sphinx_page_passes( + pytester: _pytest.pytester.Pytester, +) -> None: + """A page copied out of the Sphinx docs collects and passes as one item. + + Nothing on it carries a prompt: :mod:`sphinx.ext.doctest` runs a + ``{testsetup}`` body through ``exec`` and rejects ``>>>`` outright, so the + setup, the code and the hidden assertion are all plain Python. + """ + pytester.plugins = ["pytest_doctest_docutils"] + _write_ini(pytester) + (pytester.path / "page.md").write_text( + textwrap.dedent( + """ + # Page + + ```{testsetup} + base = 40 + ``` + + ```{testcode} + print(base + 2) + ``` + + ```{testoutput} + 42 + ``` + + ```{testcode} + :hide: + + assert base == 40 + ``` + """, + ), + encoding="utf-8", + ) + + result = pytester.runpytest("page.md") + + result.assert_outcomes(passed=1) + + +def test_a_failing_testcode_reports_the_whole_block( + pytester: _pytest.pytester.Pytester, +) -> None: + """The report quotes every line of the block and lands inside it.""" + pytester.plugins = ["pytest_doctest_docutils"] + _write_ini(pytester) + (pytester.path / "page.md").write_text( + "# Page\n\n```{testcode}\na = 1\nb = 2\nraise ValueError('boom')\n```\n", + encoding="utf-8", + ) + + result = pytester.runpytest("page.md") + + result.assert_outcomes(failed=1) + result.stdout.fnmatch_lines( + ["004 a = 1", "005 b = 2", "006 raise ValueError('boom')"], + ) + result.stdout.fnmatch_lines(["*page.md:6: UnexpectedException*"]) diff --git a/uv.lock b/uv.lock index f5b27ae..4ae1b09 100644 --- a/uv.lock +++ b/uv.lock @@ -260,100 +260,100 @@ wheels = [ [[package]] name = "coverage" -version = "7.15.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f4/45/78dbf9604ee5b3db24efbf26bed1cb58862fb40480cba821963c69348751/coverage-7.15.3.tar.gz", hash = "sha256:ae7ea5a4614acf399ef0483c4cb34f8f8f01df848d8fcbe7d3ce0865733f1c4d", size = 935592, upload-time = "2026-08-02T18:50:17.006Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/90/d9/01d8e19b2c0e55903bfb540c9f6bd32326f1d5b2fcb5a7dd8648ae2dd9c5/coverage-7.15.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3a82b2ceee91ba353e59fe2436d8a9eae799ff9825e5385423ea205d693e2949", size = 222202, upload-time = "2026-08-02T18:47:25.951Z" }, - { url = "https://files.pythonhosted.org/packages/1e/92/1c23aeb83c7239af07061abc6e96f00f9b62deec8fae022cab1b353e6d46/coverage-7.15.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3088cce65e54c2eefc08e7e1ca0b0acec1e95e8cf084ac848599103ed0367f74", size = 222723, upload-time = "2026-08-02T18:47:28.359Z" }, - { url = "https://files.pythonhosted.org/packages/b9/76/186f60bae815941553b70877d814c45994db8198bb76933bd062c18ee437/coverage-7.15.3-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a65e09efb0b5ab21fc54a8a65c5b2e533c0a4c0d064af0259a005dc656dc1b13", size = 249461, upload-time = "2026-08-02T18:47:29.802Z" }, - { url = "https://files.pythonhosted.org/packages/88/70/53e010accfea3340905c5bb9207a2e461bba9b372621f1b88c1bd0e1392a/coverage-7.15.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b51f279a2477b0e1f288b98f141fd227acfdd1d3f0370400e473788879b47871", size = 251290, upload-time = "2026-08-02T18:47:31.445Z" }, - { url = "https://files.pythonhosted.org/packages/5b/13/d916056137fb6969e9d9f58ee11d1ef56778673843828315030733a3a0b6/coverage-7.15.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7835176988cbcf1f014db683bc33aa15e0558e412bf08deaa99757335b88df15", size = 253156, upload-time = "2026-08-02T18:47:33.033Z" }, - { url = "https://files.pythonhosted.org/packages/cf/72/0a4198d82e765f3351a91714d42526eb765e5c97776f7674489acbe7d062/coverage-7.15.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f24896dc8863167f6732f4142f5d37e6195eccc8fe5fe528d35d49597d29fdb3", size = 255068, upload-time = "2026-08-02T18:47:34.852Z" }, - { url = "https://files.pythonhosted.org/packages/00/d9/ebe4e0751e3637d87162887d0d3cdf4716f96782ab6face09a295e74cba4/coverage-7.15.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9490d43e5d041fdf376770a886a29722adb05f6b9c21a65c48c81fc8f1c33fd7", size = 250142, upload-time = "2026-08-02T18:47:36.588Z" }, - { url = "https://files.pythonhosted.org/packages/a2/a2/12977c74fcf92f9b1da45fb9576c5f593a2c47bde04e937a8bb32dd56bfa/coverage-7.15.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:225e359bd5dedaff6d68e36091af20555866c557d968167308b677379bf575c3", size = 251195, upload-time = "2026-08-02T18:47:38.168Z" }, - { url = "https://files.pythonhosted.org/packages/2f/c8/42bd9aa40386c0fbcc7af221ab4737dad10d27bb4971ca2783676619a79b/coverage-7.15.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:22119e2e3b2ac5ac024d50131fdd4b22ab4c6cf8aa2fc792cce73c0d94c5812d", size = 249200, upload-time = "2026-08-02T18:47:39.773Z" }, - { url = "https://files.pythonhosted.org/packages/0a/52/f1ce0dd8a2ec5c3911f1bc98b859be09cc4bbd705ed74ceb905729837c79/coverage-7.15.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:12d555badc462b0f6037ce8bec8b4af8d71f90eb55b57d0a358731f7ee7883e2", size = 253013, upload-time = "2026-08-02T18:47:41.372Z" }, - { url = "https://files.pythonhosted.org/packages/fd/2c/9a642c4cf7b6992b2eba75359b6cb548bd437001d6083fd0ffe492b80d38/coverage-7.15.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c4e2cf9cf774939b3dc581c6e31dfe7e8d7608b24f0f17524d6161f8235c3d2c", size = 249470, upload-time = "2026-08-02T18:47:42.997Z" }, - { url = "https://files.pythonhosted.org/packages/89/32/271d85639ac5de099046f7418e850047b1e964f893535128997b5cddde8d/coverage-7.15.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cea1b3e19d710f67e2ba9ce0b0b51032c2a9b4808a65ced48ddf336ef7e58058", size = 250073, upload-time = "2026-08-02T18:47:44.576Z" }, - { url = "https://files.pythonhosted.org/packages/15/71/6216430095c5437f83d7bfa7c1adb0965e26ada88d9fff49bf55e2cab154/coverage-7.15.3-cp310-cp310-win32.whl", hash = "sha256:25c77560309f157e7b7ee8fe0bf78d047ba900b7ae42f0e50e559305b366fea2", size = 224263, upload-time = "2026-08-02T18:47:46.078Z" }, - { url = "https://files.pythonhosted.org/packages/53/8a/f1032fb2714c28fedf00d73562c4bb9f713fa8a90593ed577bbb708a7de1/coverage-7.15.3-cp310-cp310-win_amd64.whl", hash = "sha256:179fbf847e6c3d90ea71bfd570fe57f1ddb1c51474754894871c1e11099efaa0", size = 224886, upload-time = "2026-08-02T18:47:47.668Z" }, - { url = "https://files.pythonhosted.org/packages/b3/9c/c8a3a923c24f631695cea2d5e2f02e776bc0af6e03800626e13a6c05a615/coverage-7.15.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5f3f854ab4599d98f7799ac9b91e34e8ec9ebc9a6372ee8c1f3413a68cc8b5e9", size = 222328, upload-time = "2026-08-02T18:47:49.228Z" }, - { url = "https://files.pythonhosted.org/packages/92/51/dda77f34cbd2513d6ffb898c901d19e9ca55f48c0cbc4a1eb173a97d157a/coverage-7.15.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75268348fee1f199653b8a846262aec5581c6bb008c4f58824959fb708cc688f", size = 222832, upload-time = "2026-08-02T18:47:51.219Z" }, - { url = "https://files.pythonhosted.org/packages/78/59/e0faafc4c6e23bd76c76148875ee9ec5781b8f1cd62cea2bc4ca0f0f0e5d/coverage-7.15.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:21081739f6264cc594cad2d42b62befbd17633824022866c68720eb0c4b8d6b4", size = 253250, upload-time = "2026-08-02T18:47:52.737Z" }, - { url = "https://files.pythonhosted.org/packages/14/e2/4b1e0eeb727ffb471e411c1bd3402184b5dd54a77a762b0e55e87cdf9ae3/coverage-7.15.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:718d366251b060c10731c7dd359de6caea72250036eb94576aa56dacbf830a11", size = 255160, upload-time = "2026-08-02T18:47:54.404Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9e/a602d2d48f9db9f795e578a86aa914f7b20008e9330902defcfb73d17b3a/coverage-7.15.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fa1bbaa502a6e877f3ee67cbac3eba2bb637f623e454e6c37b81b38896dbd48f", size = 257269, upload-time = "2026-08-02T18:47:56.157Z" }, - { url = "https://files.pythonhosted.org/packages/22/fa/bf6db13df2fcee00d2671849fe58c99232ee79a01fec7478c2bf7839b9e1/coverage-7.15.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:494880c9e60782610683f4eb9b65cce4f886673596b8f3cb2dfa079fc551c743", size = 259231, upload-time = "2026-08-02T18:47:57.76Z" }, - { url = "https://files.pythonhosted.org/packages/89/37/8118f13b17fa7d9a3aa2c301d93f2d5ffeef70fa7e27e639a74bdacd3fea/coverage-7.15.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3db264ea689f9e8f9fa4fb9005fee4048c3bff4a547f4cfa27f5086cb0804ec0", size = 253357, upload-time = "2026-08-02T18:47:59.261Z" }, - { url = "https://files.pythonhosted.org/packages/97/6d/c7b94fb03962f4d6f0fe13d01c4eb9c4c6e2e714a20d074516ec7582b110/coverage-7.15.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4e869d4799674d67778e76ddbe2e26cf1673369262e231a8ec259421b1015fea", size = 254961, upload-time = "2026-08-02T18:48:00.901Z" }, - { url = "https://files.pythonhosted.org/packages/87/f9/fe0bd415fa56e36b62b649017c8fc98330858be4c7593789efb78cd24178/coverage-7.15.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:696fc7a28bbf717aba8d2c6963d26702945c7832cb313ba3b323aa5b1afb3156", size = 253024, upload-time = "2026-08-02T18:48:02.745Z" }, - { url = "https://files.pythonhosted.org/packages/c1/7c/ffa53506d63ba8a77f5b9557dd6f5a5a5ad85adc680d7857410138f82bd9/coverage-7.15.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:3fe9be1c527497d047f770d88a0110189714c36383bb88384508f750c302bffa", size = 256792, upload-time = "2026-08-02T18:48:04.377Z" }, - { url = "https://files.pythonhosted.org/packages/1f/c6/df42458e72c18a49fe87e40ccd3fb0314210915256cf4a5593e1b3250e04/coverage-7.15.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2400591f4b2e33746c70846388f8bb4c7e33b820e31cb8c6cb2f25305310438b", size = 252744, upload-time = "2026-08-02T18:48:06.154Z" }, - { url = "https://files.pythonhosted.org/packages/f1/14/8bf18a4b10a44f8ba5f604b00e102f37daf49d581d66a37dc33fa267e1a6/coverage-7.15.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2e557178799282269412a672e5753f2179edfe1b3f0f19b0c98f8e72d482326a", size = 253652, upload-time = "2026-08-02T18:48:07.955Z" }, - { url = "https://files.pythonhosted.org/packages/27/e6/e530c9bb94e4155817cbd149034105b062a6913bc356ae08f454d155de53/coverage-7.15.3-cp311-cp311-win32.whl", hash = "sha256:68ea6c947375982ae907e19e9d2ef156bd6e68e11f3566dd568d7f4ec974e715", size = 224428, upload-time = "2026-08-02T18:48:09.845Z" }, - { url = "https://files.pythonhosted.org/packages/b4/98/0050c692d120988f1973a15196f52dee4ae221848b760281461a2005b613/coverage-7.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:28743dad31622e8c474b17446118037361f5b1f4f2ecdf72d4f6fde246d64446", size = 224906, upload-time = "2026-08-02T18:48:11.611Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ae/c0ef3e2ba3f35fc1c6985811a40edd9331e5b8978c9ecf84699de3edacbe/coverage-7.15.3-cp311-cp311-win_arm64.whl", hash = "sha256:c4398918c4fda32718191239e451fd86ac5ad1e8979b592f1921ee2d1f038965", size = 224448, upload-time = "2026-08-02T18:48:13.304Z" }, - { url = "https://files.pythonhosted.org/packages/d1/6c/bac99d9d4c6abe856e93bf3f5212982ac0bfac126dd4a042753bd53bc5af/coverage-7.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:79a3e32e83227d83d9684459ed579769b56c369ac2d7313099b2d9e031d2e10f", size = 222499, upload-time = "2026-08-02T18:48:15.018Z" }, - { url = "https://files.pythonhosted.org/packages/aa/bc/cb9a39b083bc1aa70586482dab25c9be20bab0ec6c155340e50d9066bb1e/coverage-7.15.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:767feb87c5886d781d0a69fafd450a20826ddab7b79bce1665deb64d21441b60", size = 222866, upload-time = "2026-08-02T18:48:16.884Z" }, - { url = "https://files.pythonhosted.org/packages/58/fb/beaa453d62000a0a5b39838bee2a137afe609a50a71f55e83c73461e513b/coverage-7.15.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:50951e37033c40548d777b8a8454a2cd622dba1136780065678dccaec307c47f", size = 254367, upload-time = "2026-08-02T18:48:18.507Z" }, - { url = "https://files.pythonhosted.org/packages/66/64/43e72500ed6815cef189f9193f29d7af4b078830337c95ea976cd0c0d427/coverage-7.15.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:63a4ff67364afb2cac826b8bbd78a5c50ce656a7b7137436b44d7b96a9271088", size = 257103, upload-time = "2026-08-02T18:48:20.172Z" }, - { url = "https://files.pythonhosted.org/packages/66/3a/2893e2937adfe02f45fd38e4a8a0a0d8b7a02ff9e012ac3d009bee3c4f16/coverage-7.15.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e95e42856509675fe26560310313a6117640e96f9a1e19bb3d220116a27c94c", size = 258220, upload-time = "2026-08-02T18:48:21.963Z" }, - { url = "https://files.pythonhosted.org/packages/30/b4/d5e6e2eb1a62961083734291304b1f85df72e2abe95c76eb88a7f472afd0/coverage-7.15.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:abad631cba27094b4631993f4c72e89ac0ca1b3a0236c7abaf8ca79aea619851", size = 260481, upload-time = "2026-08-02T18:48:23.682Z" }, - { url = "https://files.pythonhosted.org/packages/dc/c9/9b72c5c6a9798a9a12cf65f66e077cc1fdd396e61915c862688f9afe1cae/coverage-7.15.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2b0807f1f051dd82a234ad6acdb6f1425baede60be1e84e862496c8cc9262ab9", size = 254749, upload-time = "2026-08-02T18:48:25.32Z" }, - { url = "https://files.pythonhosted.org/packages/92/20/e1c2f759e2dbce559ba85c40c0e4acfecc6cff4b740c294c88e41ccc6111/coverage-7.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8d6df7aeb5bc464040bbc9ae173d875785d3677ebc4307817997d622d74225e", size = 256138, upload-time = "2026-08-02T18:48:27.064Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ab/48cc7e760f769e86ae290a125ea6e7209dfbdbbbb7ff4f5d9d1ee7a45d57/coverage-7.15.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:974471c506c9f5758808b47c1ebf7949ecd0848f5c1020e78675fefe5ff46866", size = 254283, upload-time = "2026-08-02T18:48:29.082Z" }, - { url = "https://files.pythonhosted.org/packages/15/26/39529a68154f99b3a1829debd8b25eac384effeec890a293b5bbdcb49186/coverage-7.15.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5cba0c9c13e35c86df7998f1afaf6b1da224a3a39e4da59bdabf60c148046dcb", size = 258352, upload-time = "2026-08-02T18:48:30.892Z" }, - { url = "https://files.pythonhosted.org/packages/91/2f/55b82aa3d8d7dd8023a56e7c5c2a70e39a3c44b3353c6cf3faec9ad51566/coverage-7.15.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4d608dc36a364dce33acbf4fc3a50f9d2054c945f233bb0a2cdb4b90bfa17646", size = 253852, upload-time = "2026-08-02T18:48:32.934Z" }, - { url = "https://files.pythonhosted.org/packages/6a/6d/839f4045124cd3518ecf2c58967e58a911202834e7c5a03cfdf2ab0b29f6/coverage-7.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2395869280554a1941da904423c12660c39f721315e1c02d076a7fe0971382f0", size = 255725, upload-time = "2026-08-02T18:48:34.848Z" }, - { url = "https://files.pythonhosted.org/packages/75/21/d25e3e2a9e327798078c877f469dfb6def860bf6e25036529046227d3e15/coverage-7.15.3-cp312-cp312-win32.whl", hash = "sha256:24f3b21840c3eb76cef3cc70b2bf6649010c64471a84a446538a39306e1ba04d", size = 224566, upload-time = "2026-08-02T18:48:36.661Z" }, - { url = "https://files.pythonhosted.org/packages/b1/0f/df90cc1e8d095ce263968a93e04829821b2afb31ac2752c06a2e0a8e3c13/coverage-7.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:fa7b17902c3c1dd8a7adb52679b7f6340bba08443d710c8838e04db8cf62be2a", size = 225098, upload-time = "2026-08-02T18:48:38.941Z" }, - { url = "https://files.pythonhosted.org/packages/65/c7/ec49e43c58967a07163e2d1c6bbd58112b825b2772ab66784afd6a5400ba/coverage-7.15.3-cp312-cp312-win_arm64.whl", hash = "sha256:fcbe83fb7258eacd293bf5322d88807acb35ed12a5cfa99dd8215c083e3b0235", size = 224485, upload-time = "2026-08-02T18:48:40.682Z" }, - { url = "https://files.pythonhosted.org/packages/68/6e/62ae61e1fc434956bec38ed1d5b1c494f58cf579dbd998e77abffe7b3e6b/coverage-7.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1182eed05674c63d40951fae27c43e822749f04d25f75df64c2e4fa3168678de", size = 222522, upload-time = "2026-08-02T18:48:42.476Z" }, - { url = "https://files.pythonhosted.org/packages/13/ff/c74c673d81e0e77b6608c3d21331e3db42e30daeb3c8a0a8860d4c9e2e14/coverage-7.15.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c0c4b0d7c4cd56e470d0c9d8441f42e8a96cdfd95050fec027f1d4dd9f11006c", size = 222894, upload-time = "2026-08-02T18:48:44.274Z" }, - { url = "https://files.pythonhosted.org/packages/a1/91/ccb30f5ffafd7d69d0b18e5162f9b711a5654e807b7b0c13497f0826b33f/coverage-7.15.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5c9fce9f4998b0d50a753da765b9215a14decc7863822c89d72da7a89ca625b3", size = 253890, upload-time = "2026-08-02T18:48:46.097Z" }, - { url = "https://files.pythonhosted.org/packages/29/c6/e92a66cda49a2751b09826d51258f199b92aa0cb005bc5f34e9729a52a9c/coverage-7.15.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7a47e2a0a0ace9241e70ee00e44520f88b843094603dd54303f1bafecd929c30", size = 256484, upload-time = "2026-08-02T18:48:47.846Z" }, - { url = "https://files.pythonhosted.org/packages/96/7a/730929164b457cf25cf76c23898b90f9039a104a647890801b6586797b14/coverage-7.15.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:95bad94f83807ae60ed76f3ac012f69b2605ac9ea81bee959a5a483f7fa09c10", size = 257723, upload-time = "2026-08-02T18:48:49.664Z" }, - { url = "https://files.pythonhosted.org/packages/9e/be/04cb5672cb19f5c389eda81ba22d89807699a949653d3625b0e0fda169da/coverage-7.15.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:228e172a76c428bb17d1ab78a2ff188990b0597e5dbd291f52a4edf7412de049", size = 259854, upload-time = "2026-08-02T18:48:51.413Z" }, - { url = "https://files.pythonhosted.org/packages/96/25/5e7fd6af39f6507071455944b8906dd1fe5b7b6bffb6a163ceb20afa0d13/coverage-7.15.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cea9fb33887c99349996266f1fd60abe5af3577a90633392001d27ef46b4b66e", size = 254085, upload-time = "2026-08-02T18:48:53.158Z" }, - { url = "https://files.pythonhosted.org/packages/23/c8/55e58a853f1e61163a6e755897bd14a059d78411e86560f39d9951c019b5/coverage-7.15.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:81760de3155d7f52c21860c4046628dc6bed182f72e3c028e2b4fd46f65aa040", size = 255850, upload-time = "2026-08-02T18:48:55.031Z" }, - { url = "https://files.pythonhosted.org/packages/be/74/8bcec66dbcf3d22bea2a0b2b77ee2fa6f766a647d0023d4eabbc4f2b2756/coverage-7.15.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b47ea0a1d3a3d089826c6cbfad8429d7d8872e28e86baa95ddef330f6875da21", size = 253818, upload-time = "2026-08-02T18:48:57.163Z" }, - { url = "https://files.pythonhosted.org/packages/ce/06/450b673fdfece0997b4e16a31d6bde6b18889c578f1013ddd34c962ac6f9/coverage-7.15.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5459ba486b2a5d58a6c05254779ecdf525e7f20174d0210ceda75ba40fdb8f2c", size = 257973, upload-time = "2026-08-02T18:48:59.098Z" }, - { url = "https://files.pythonhosted.org/packages/56/fd/3ec7409aec0ddc943132452b65672f065f043b844f1830e1fe173c98b3ab/coverage-7.15.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c59209f80a08dbfcdd5109a80dc623cd3b9d22895c85757d34f57a6e6e95570f", size = 253638, upload-time = "2026-08-02T18:49:01.199Z" }, - { url = "https://files.pythonhosted.org/packages/75/20/30a8dabb194123631c93f860fdd86401ad405d56cfb1841873afbfe4e92b/coverage-7.15.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f863856c1779d4a5bb6a94698a2f9073e09c6706501f76f3e7780e72df97d21c", size = 255407, upload-time = "2026-08-02T18:49:03.143Z" }, - { url = "https://files.pythonhosted.org/packages/13/4d/e14365b1953b43653341412f9088b0d752614c626a73a705ff9af400f3a3/coverage-7.15.3-cp313-cp313-win32.whl", hash = "sha256:00cbdc5e322927dc30c5e42b863819b1bb867cc66f26ab5372c585850876ab93", size = 224575, upload-time = "2026-08-02T18:49:05.011Z" }, - { url = "https://files.pythonhosted.org/packages/1c/64/88f762ea80de2070207246faef514513be874486b2773528f2cc2b4b515c/coverage-7.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:835528518a1d823cf336740324b2f335f7c01e609e74abcb5d5163b3e66661e3", size = 225116, upload-time = "2026-08-02T18:49:06.894Z" }, - { url = "https://files.pythonhosted.org/packages/ab/66/03c34c53a319f522554cd29d4f2e16c5eab61aa4cdcf55753129fd7d926c/coverage-7.15.3-cp313-cp313-win_arm64.whl", hash = "sha256:0d2e1f2cbbf36b842f3e2aff8d118c60d677adb498bc6c7fa9c6838738f82767", size = 224509, upload-time = "2026-08-02T18:49:09.129Z" }, - { url = "https://files.pythonhosted.org/packages/35/6f/8c2dc014357618b3226c90f731b8282766c3685786f422558991dc49fbf2/coverage-7.15.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1e3bb08ad574bd9fb6a991f645728f70d333c1c1958dd5fcde65e24cb862813d", size = 222571, upload-time = "2026-08-02T18:49:11.242Z" }, - { url = "https://files.pythonhosted.org/packages/07/50/d867c7ceae9d56b7e74ee61ea834f1aa4f9a1e1c7f0ce39393ba573b1c12/coverage-7.15.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9e5860eaff02a0b7f1b73304bdf846596ee62ab3a78d25c68044ebf684cb1fef", size = 222902, upload-time = "2026-08-02T18:49:13.448Z" }, - { url = "https://files.pythonhosted.org/packages/62/77/4f6dfc490c5f2bcacb2d296d9aa4d1e128c43b48e94ad313fec7f49f09ad/coverage-7.15.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:60874e5bd67f0b1bdbe42ab42c7bafa66a6fb8de88721af6df3f7a02713960cd", size = 253947, upload-time = "2026-08-02T18:49:15.304Z" }, - { url = "https://files.pythonhosted.org/packages/16/8a/6777f192af264165103e2a3d3768dbadb9894a0a2359a16877141d9ae8f5/coverage-7.15.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9147be876e9d83765e0b82176674dc248a6b9283e25e01e7462611b97e9b731", size = 256452, upload-time = "2026-08-02T18:49:17.801Z" }, - { url = "https://files.pythonhosted.org/packages/7d/7b/3d7ac46a0234bc684f41ee42be95e29b2b6525695adb04083609d5ac2149/coverage-7.15.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61a01f8c3804760fcc5a3d31c4f3cab792d660d44e17bf7adeaf0ea51e07821e", size = 257798, upload-time = "2026-08-02T18:49:19.878Z" }, - { url = "https://files.pythonhosted.org/packages/ff/1e/c6ee59c29afcb5fdb35f936381340d1a06429a07c48f20e809646647acbe/coverage-7.15.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:95bf3e7f26f792e25eb185f85a5a659d48479265176dcfe22b6f334fd0081b5c", size = 260112, upload-time = "2026-08-02T18:49:21.858Z" }, - { url = "https://files.pythonhosted.org/packages/c1/e1/e8ea39a46e89e3a143312ee5f80336e992e3ae8fe44bf9c76b83fefeed42/coverage-7.15.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:44c41eff9e413fed8740eca75d5438ebeb9d3e45e7cd37c67329213e7a72c764", size = 253944, upload-time = "2026-08-02T18:49:23.926Z" }, - { url = "https://files.pythonhosted.org/packages/95/67/31ab5f6a37fd887d1386f81f0da9306851ad2264e9baaa9c7f606e0b3e17/coverage-7.15.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54146bafb61f3ba9895b43af0dd17eba01561d586d44ce84ea221b0cbbee5a9e", size = 255805, upload-time = "2026-08-02T18:49:25.973Z" }, - { url = "https://files.pythonhosted.org/packages/fb/6a/ee505a80c8fd89620fb337c0596daecff87f33171fbb4ee3015fc3d7331f/coverage-7.15.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:af000dd1bb859ff8066fda4c79512ff938c798116540307226b373099c7b151f", size = 253769, upload-time = "2026-08-02T18:49:27.883Z" }, - { url = "https://files.pythonhosted.org/packages/b0/41/6ab0f81c9e89660230d8f3f581d4732e5ddb75a885b0a5dfc73d315dc94f/coverage-7.15.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a1b82490577f3889950b5a04f18712aef0207243e0749d60fe28c3c73ebfd5fd", size = 258045, upload-time = "2026-08-02T18:49:30.201Z" }, - { url = "https://files.pythonhosted.org/packages/bc/62/c995e91cae28cf31d6defab3bfb553dda5ac83ac7381b0f2b121264c307a/coverage-7.15.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:c4fc90a60154c3e4b8a2dc206d6dbe852f1c235c249e0dc0cef909d032c9591a", size = 253587, upload-time = "2026-08-02T18:49:32.349Z" }, - { url = "https://files.pythonhosted.org/packages/84/df/f2049980f82d6890321f2065f9e66216eabbf4b2001815db958bc543f40a/coverage-7.15.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f25bb884814a892948b4c20394db3f2364dd452d9492736479e7a493e63b0eb6", size = 255243, upload-time = "2026-08-02T18:49:34.324Z" }, - { url = "https://files.pythonhosted.org/packages/1d/82/2c841b67a978c0eb9c3707630b68f93f9e7585d78bb906bc8823ec6b07a5/coverage-7.15.3-cp314-cp314-win32.whl", hash = "sha256:722dbf8e7828fbcfe0dc8586167dc0a5ce85ad6ea171dbb21ed3f8d6581d3cb8", size = 224759, upload-time = "2026-08-02T18:49:36.326Z" }, - { url = "https://files.pythonhosted.org/packages/b3/78/5c93ec43784fd3e404ca23cd0584ae24bc1732de4a3fc194b68c3be88db0/coverage-7.15.3-cp314-cp314-win_amd64.whl", hash = "sha256:64d0845f9c3ed47302bed265c15ab4dbb64aa4ec1490839b8e328f4e7fa914d2", size = 225246, upload-time = "2026-08-02T18:49:38.366Z" }, - { url = "https://files.pythonhosted.org/packages/9d/77/813a054371f3b018cc63c6bdb46a3c35d5e95d4e3ed4f1449d4196106db5/coverage-7.15.3-cp314-cp314-win_arm64.whl", hash = "sha256:69bc14684f8fbbee9f9dbaa4fe79719b0da9725fc37956785c06ec365acf6926", size = 224673, upload-time = "2026-08-02T18:49:40.552Z" }, - { url = "https://files.pythonhosted.org/packages/8f/63/8c9f36cc71178d26db930baa03a4494abcc516d8d41bf820d0d85ef1d80b/coverage-7.15.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f92df943c24b96cb215ca26b4f6a2283e63c5db80f1635aceea7fff11311917b", size = 223298, upload-time = "2026-08-02T18:49:42.634Z" }, - { url = "https://files.pythonhosted.org/packages/54/66/211f24d058ce9f56ebf1420d55b7574fdae924f6da3836f83c8bd4793e38/coverage-7.15.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:66591c46bdd2971d3ae2bc503a5f0459c2edcaf6b7e045b292000cc95bc6cb95", size = 223568, upload-time = "2026-08-02T18:49:44.706Z" }, - { url = "https://files.pythonhosted.org/packages/dd/bb/9c2ad5574a0d6420a96c6cade4f8a683931b9e79fe609f8924d7b6964616/coverage-7.15.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa64458b81b18bfc67cdf1f6dc02b23e3edc672f2f8e11771fad75865415a43", size = 264932, upload-time = "2026-08-02T18:49:47.153Z" }, - { url = "https://files.pythonhosted.org/packages/ba/91/938c39e77bdd5a0a440412f975609ce3702dabbda6ac715719d93ca45a7b/coverage-7.15.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:447f5421ccf5475956cf516d4ca1d575f487947b6f4e11f9d80c6aefe24b3dc8", size = 267052, upload-time = "2026-08-02T18:49:49.324Z" }, - { url = "https://files.pythonhosted.org/packages/b0/a3/7b431a98af35d9cc6394e54cde9435b33b8591672fbece6a4931267d7a8e/coverage-7.15.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a0c77ef8cd483a4987a5d12d1d9d5f7ee598dfdc6c0844417d847e5768dc779", size = 269473, upload-time = "2026-08-02T18:49:51.599Z" }, - { url = "https://files.pythonhosted.org/packages/32/58/dbc9951dce46be47a732823a1c571f62bcabdd54a68d8c281489a1a55cfb/coverage-7.15.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0b273f4ff657446a06c2d85bf80e134fa869a92852ba5f87854a70e1fb44da77", size = 270591, upload-time = "2026-08-02T18:49:53.865Z" }, - { url = "https://files.pythonhosted.org/packages/71/bd/1d610772c7c0889bfe477a59c46ee66ea53e271f3f06951e9d55b317f7c6/coverage-7.15.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daea8c4fafa22488600405be2c2be525a9406fba3fc0a83acc726db3e14e2005", size = 264007, upload-time = "2026-08-02T18:49:55.875Z" }, - { url = "https://files.pythonhosted.org/packages/69/97/852eb3dcdba156b1a9078503f098499916bf889f964b61ad4a08223ac169/coverage-7.15.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:93ff57c530f3fa7aa69f92fb9b8892b8aa82712aa970842f4abf28657f42fb57", size = 266926, upload-time = "2026-08-02T18:49:57.944Z" }, - { url = "https://files.pythonhosted.org/packages/52/f8/b72cd238757fba2b587fc7dee047efe6e10b0c18343509faaaf502dd4680/coverage-7.15.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4df21bef8b800eebda9018f53d49c9ace3aeb0090c850139b27923aafcb83e91", size = 264529, upload-time = "2026-08-02T18:50:00.035Z" }, - { url = "https://files.pythonhosted.org/packages/b8/0a/6c52ec4b7fb007cb6433d1fcfda4080cb15d75ad37ef9c31025f3427293e/coverage-7.15.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:db567b02685f26034adcbd85055f80d12cdf02111b8ed00886093d98b2874ce2", size = 268263, upload-time = "2026-08-02T18:50:02.161Z" }, - { url = "https://files.pythonhosted.org/packages/c0/4e/f1f9aa3efd109a04353563a43fb5155340c1fdcdeaa6296ebed3b6f510ea/coverage-7.15.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5318dd51b8600b947e058cf5a4fe54d183d9d13c49b97b64ca7be05a34df9bef", size = 263377, upload-time = "2026-08-02T18:50:04.243Z" }, - { url = "https://files.pythonhosted.org/packages/dd/fb/6b268a0b2728ef1c379ad656b899274477a5f6bed1bf6765b4b387fb0601/coverage-7.15.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c995bfa383c54704839b6c4c2627a1c00895597ada0e5e8190c81d8bd620555c", size = 265688, upload-time = "2026-08-02T18:50:06.428Z" }, - { url = "https://files.pythonhosted.org/packages/29/54/1a3ea96e5d5e7cd41dc432597bfc60692910e635d05e1cc25a8ccc243581/coverage-7.15.3-cp314-cp314t-win32.whl", hash = "sha256:6433fafb8da0e1d02eb53411e0ecdadb6b88f0224fdc23317e703c0e88937d42", size = 225066, upload-time = "2026-08-02T18:50:08.533Z" }, - { url = "https://files.pythonhosted.org/packages/31/9d/a7b0d9afd18ed5274dd00651a78e7810a931c70d94b79996f150bec1a30f/coverage-7.15.3-cp314-cp314t-win_amd64.whl", hash = "sha256:fe578952b1b29fe8c777f43f241d49efac4b56724a3434f5d22ebe3c208df429", size = 225897, upload-time = "2026-08-02T18:50:10.572Z" }, - { url = "https://files.pythonhosted.org/packages/ca/11/34c5ae40b945e69aa72b87dc268135b7049905f3824af573b7073acbb946/coverage-7.15.3-cp314-cp314t-win_arm64.whl", hash = "sha256:d2e1acb7aee29dfa8f3e48c23f36670898baca1209d9bdd3985a50c7f982165e", size = 225212, upload-time = "2026-08-02T18:50:12.63Z" }, - { url = "https://files.pythonhosted.org/packages/37/e7/7069b3d6c018917f49ba2e1c5fb910e498c7fefa3a1b78cb1b79e61ff45d/coverage-7.15.3-py3-none-any.whl", hash = "sha256:da78fa6fc7dafe4212839173133ee85afcf42c5cd5f3e47fa7c1c210453b445e", size = 214297, upload-time = "2026-08-02T18:50:14.709Z" }, +version = "7.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/060ce69008ac97bbc01b1411b3e55b61f6f015659400b46749b662107831/coverage-7.15.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b5bd92ff1ec22e535eab0de75fa6db021992791f461a2aceb7822c625a1187d", size = 221284, upload-time = "2026-07-15T18:53:29.52Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a3/d936e8b53edd9684100a6aefaf3fcabaa54728fe33324436c8d279c047aa/coverage-7.15.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:44826758cfe73fcd0e6af5deb4ba6d5417cc1d13df3acb35c93484a11160f846", size = 221799, upload-time = "2026-07-15T18:53:31.708Z" }, + { url = "https://files.pythonhosted.org/packages/ae/a3/ca234b06aec7ee28226f11d39a696b4481fe5eddfce8e03bf39979bb8ffb/coverage-7.15.2-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09f5c6ec5901f667bd97dd140b5b9a2586b10efec66f46fb1e6d8135f8b95bdf", size = 248544, upload-time = "2026-07-15T18:53:33.212Z" }, + { url = "https://files.pythonhosted.org/packages/2b/89/dda79527bb7573ba91828b2fb91b3105d87378d6a2749ca0c0924ce0addd/coverage-7.15.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1d16e3a7104ea84f03e614611b3edbf6fb6892554b3ab0fe7fbb3f2b2ef04376", size = 250374, upload-time = "2026-07-15T18:53:34.683Z" }, + { url = "https://files.pythonhosted.org/packages/67/c6/c33755a34572f81f49a8c0cdf6b622f35ccb3238b136e1909daf0cdd4319/coverage-7.15.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d46e62cb35d91e6e2589fda6d28074426b0e276422b5d2ebef2c6b11dc60dbfd", size = 252239, upload-time = "2026-07-15T18:53:36.205Z" }, + { url = "https://files.pythonhosted.org/packages/b9/6f/dc341741b375be53a5baeee5b4bf0f0e525d38caed428f7932d23bb7bcb1/coverage-7.15.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dfd3db045e95960ae3683059571e597fda7cc610106a8916f77c5839048c1deb", size = 254150, upload-time = "2026-07-15T18:53:37.863Z" }, + { url = "https://files.pythonhosted.org/packages/e9/8d/966a18a5b195cb4e77b14c53f5f3dce22b5da05e6de7fafd1e08f2d2067a/coverage-7.15.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:affd532502d34c0472d0cdb181325c89f1d2c44992fef0c17e88e7b1576259a1", size = 249234, upload-time = "2026-07-15T18:53:39.394Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8b/8b2e367496ab48484d48e79984fec76cdc1b7cb5d3a00ee799a5602e3ec9/coverage-7.15.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d17d7512151fedfcc64c1821a8977fc9be0dbf495754669afcab7b57abc98ae9", size = 250276, upload-time = "2026-07-15T18:53:41.027Z" }, + { url = "https://files.pythonhosted.org/packages/63/92/1199318a200eb6c8c6ce0192c892c8710ac791abbe0f35099294620bbfda/coverage-7.15.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e26ff680768b8095e8874aabe0e9d3a47a2a9f176a8340d05f8604c56457c23a", size = 248283, upload-time = "2026-07-15T18:53:42.557Z" }, + { url = "https://files.pythonhosted.org/packages/56/da/be284a55c5619bda891a89c27dfd59324a2c6a14d755cf6aac6960ceebeb/coverage-7.15.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:7e8f27131dc7cd53de2c137dd207b3720919320b3c20d499dc30aa9ee6173287", size = 252093, upload-time = "2026-07-15T18:53:44.271Z" }, + { url = "https://files.pythonhosted.org/packages/d4/53/ee112da833ddd77b73c6d781a98029b45b584b136615b4900ed0569f887e/coverage-7.15.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:728a33676d4c3f0db977990a4bd421dcaa3be3e53b5b6273036fff6666008e89", size = 248552, upload-time = "2026-07-15T18:53:45.7Z" }, + { url = "https://files.pythonhosted.org/packages/82/6a/802cfc802e9113494c80bf3f284cd4d72faeb1f24e244f61046af364f2ca/coverage-7.15.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:29c052f7c83ccfcc5c577eaae025d2e4a9bb80daf03c0ac31c996e83b000ce88", size = 249154, upload-time = "2026-07-15T18:53:47.256Z" }, + { url = "https://files.pythonhosted.org/packages/2c/65/529808e91d651147edae408fd9e894abc3b8cad7f3e594bbc36719a3e13a/coverage-7.15.2-cp310-cp310-win32.whl", hash = "sha256:1268ac8fb9ddcd783d3948dbabaf80a5d53bfdaa0575e873e2139a692f797443", size = 223334, upload-time = "2026-07-15T18:53:48.768Z" }, + { url = "https://files.pythonhosted.org/packages/68/0f/0e1829d7001130876dfbc0b4e1c737ea7c155b809e3e4a98a0aa268e2369/coverage-7.15.2-cp310-cp310-win_amd64.whl", hash = "sha256:9f4432898c4bf2fba0435bbe35dd4437d7264565e5a88a21f5b49d8662a6b629", size = 223959, upload-time = "2026-07-15T18:53:50.429Z" }, + { url = "https://files.pythonhosted.org/packages/7d/3a/54536704f507d4573bf9161c4d0dd3dd59b6d85e48c664e901b6844d8e33/coverage-7.15.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f1ec6f304b156669cfde653b4e9a953f5de87e247ea02ac599bce0ab2744036", size = 221414, upload-time = "2026-07-15T18:53:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d9/8ba925d29743e3577b21e4d8c11a702b76bc93c41e7fdfd1177af63d4b8d/coverage-7.15.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d3361879d736f469f45723c11ea1a5bbdaf1f6928f0e632c940378b5aa9b660", size = 221913, upload-time = "2026-07-15T18:53:53.682Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/a855f3aa0187f2b431ade4e4791b77b56282cfb5d201c83ec26a31b5b36a/coverage-7.15.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c6a98d698f9e2c8008d0370ec7fc452ebfcc530002ae2d0061170d768b992589", size = 252332, upload-time = "2026-07-15T18:53:55.467Z" }, + { url = "https://files.pythonhosted.org/packages/8e/d3/13ac97b4370640ba3452fc8559b06cc2f479ce3ba4a0b632a73e44c38a7d/coverage-7.15.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d50dd325e18ec25bfcc10cd7f99b04df1ab9ec76b0918c260e60817ad0643dee", size = 254243, upload-time = "2026-07-15T18:53:57.055Z" }, + { url = "https://files.pythonhosted.org/packages/88/83/5eca144942d8d0659d3f55176517f4a59cdc65eefd17146a0770935a3ebd/coverage-7.15.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67d7602480a47bdf5b675635403625553ebaa70d5a62a657c035149fd401cea0", size = 256352, upload-time = "2026-07-15T18:53:58.83Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ba/d3db2e01a50fc88cdb4c0f19542bcf6f61489e34dc9aa3538413e2459a38/coverage-7.15.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cee0f89f4767a6057c8fbf168f8135f18be651300496086bd873e3189fed0487", size = 258313, upload-time = "2026-07-15T18:54:00.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/b3/aba83416e9177df28e5186d856c19158c59fc0e7e814aaa61a4a2354ad1b/coverage-7.15.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a29ec5305a7335aacee2d799e3422e91e1c8a12474986e2b3b07e315c91be82f", size = 252449, upload-time = "2026-07-15T18:54:02.456Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a5/4b00ecac0194431ab451b0f6710f8e2517d04cef60f821b14dec4637d575/coverage-7.15.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:48ccc6395958eda89093ecdc35644c86f23a8b23a7f4d44958812b721aad67c1", size = 254043, upload-time = "2026-07-15T18:54:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/75/b6/cfa209b4313ee7f1b34da47efcd789ea51c024ad35af390e00f5a3c10a2e/coverage-7.15.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:81f382c5a94b434ec1f6da607edb904c76d7212e618cd4d1bc9f97bed4120ef5", size = 252107, upload-time = "2026-07-15T18:54:06.745Z" }, + { url = "https://files.pythonhosted.org/packages/36/67/e8cac5a6954038c98d7fe7eb9802afe7ab3ecb637bb7cc00e69b4148b56d/coverage-7.15.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bbc808daf4f5cd567af8075ecc72d21c6dfef9a254709a621a84c217c935ebc0", size = 255873, upload-time = "2026-07-15T18:54:08.48Z" }, + { url = "https://files.pythonhosted.org/packages/2c/92/395cca9f330a86c3fe3471d73e2c102116c4c58fdc619dbbc125c6e93a54/coverage-7.15.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a4c46b247b5d4b78f613bd89fea926d32b25c6cc61a50bd1e99ba310348f3dad", size = 251826, upload-time = "2026-07-15T18:54:10.083Z" }, + { url = "https://files.pythonhosted.org/packages/51/60/3e91b20295439652424f426b7086ec5bf4fbe3f604c73eda22b986c4fd6b/coverage-7.15.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:094dd37f3ef7b2da8b068b583d1f4c40f91c65197e16c52a71962d5d537fc5db", size = 252735, upload-time = "2026-07-15T18:54:11.878Z" }, + { url = "https://files.pythonhosted.org/packages/a5/eb/8c07839005e5e3c6b3877d3a6e2a80ce766589f31dd2b6882b78d59a7b8c/coverage-7.15.2-cp311-cp311-win32.whl", hash = "sha256:a63b9e190711134d581c4d703df5df09851b1acf99792c7aacbbe9f41f0283c9", size = 223500, upload-time = "2026-07-15T18:54:13.525Z" }, + { url = "https://files.pythonhosted.org/packages/2e/98/59d83c257cd59f0fbaf9d9ddb26b744a576760dfd1ae16e516408894a02b/coverage-7.15.2-cp311-cp311-win_amd64.whl", hash = "sha256:8bb9f4b4279187560796a4cdaca3b0a93dd97e48ee667df005f4ed9a97403688", size = 223973, upload-time = "2026-07-15T18:54:15.163Z" }, + { url = "https://files.pythonhosted.org/packages/ea/09/2d285c8bef5c4f695d120c1c96dc11715638aa8e134069f210bb6a62a9fe/coverage-7.15.2-cp311-cp311-win_arm64.whl", hash = "sha256:8c726b232659cbd2ae57ade46509eb068c9bd7a06df9fcbff6fe484870006934", size = 223519, upload-time = "2026-07-15T18:54:16.803Z" }, + { url = "https://files.pythonhosted.org/packages/6a/50/eb5bf42e531611a9f8d272556b1ed4de503f84a91413584094487cf69f8f/coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9", size = 221587, upload-time = "2026-07-15T18:54:18.439Z" }, + { url = "https://files.pythonhosted.org/packages/06/d1/da99af464c335d4e023a6efcd7ec30f63b88a43c93745154ab74ffb31cea/coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73", size = 221943, upload-time = "2026-07-15T18:54:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8a/13c42723d61ca447eafa18732e8141dd6a63f2732e1c7e1502c182dd88d7/coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d", size = 253450, upload-time = "2026-07-15T18:54:21.765Z" }, + { url = "https://files.pythonhosted.org/packages/d7/29/99021303f98fbdcb63504b4d07bea4cc025b9b2dd907c4f07c85d50a0dab/coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b", size = 256187, upload-time = "2026-07-15T18:54:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a8/fd503715ed6ca9c5d742923aa5209257340b367a867b2ced0c7d4ba8a0b9/coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296", size = 257301, upload-time = "2026-07-15T18:54:25.183Z" }, + { url = "https://files.pythonhosted.org/packages/da/40/3f4b8fb409810036ebc2857d36adc0498c6e957b5df0290c5036b2e143f1/coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6", size = 259562, upload-time = "2026-07-15T18:54:27.204Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8a/9bdffbef47db77cce3d6b02a28f7e919b19f0106c4b080c2c2246040f885/coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098", size = 253841, upload-time = "2026-07-15T18:54:29.134Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1e/9031efde019d31a06646261fce6dfc5c3c74e951e27a71e5c9a424563178/coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a", size = 255221, upload-time = "2026-07-15T18:54:31.142Z" }, + { url = "https://files.pythonhosted.org/packages/56/db/787acde872389fc84a9ef9d8cd1ccc658e391ab4cb5b28092a714426a394/coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b", size = 253366, upload-time = "2026-07-15T18:54:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/2f/9b/6f57bc4b93c842eef1695f8cdaf2318e35e7ba54f5ba80d84be213ab7858/coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2", size = 257434, upload-time = "2026-07-15T18:54:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/b3186a21b2acc83e451118978905c81c7072c3333707804db09a78c096a2/coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440", size = 252935, upload-time = "2026-07-15T18:54:36.548Z" }, + { url = "https://files.pythonhosted.org/packages/20/c2/c9f3376b2e717ea69ed7a6e9a5fcab968fb0b290db6cf4bd9a1fc7541b75/coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e", size = 254807, upload-time = "2026-07-15T18:54:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e1/dfc15401f4a8aaeb486e1ba3e9e3c40522a6e38bd0ecf0b3f29cb8082957/coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd", size = 223641, upload-time = "2026-07-15T18:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/91/40/81b6d809d320cd366ec5bdf8176575e897dcb8efe7fb4b489ef9e93e4d13/coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40", size = 224172, upload-time = "2026-07-15T18:54:41.882Z" }, + { url = "https://files.pythonhosted.org/packages/ef/28/9f14ec438149f7de557f45518f09b4a7917b795cc37083aa7db482693f8c/coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3", size = 223556, upload-time = "2026-07-15T18:54:43.674Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" }, + { url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" }, + { url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" }, + { url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" }, + { url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" }, + { url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" }, + { url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" }, + { url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" }, + { url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" }, + { url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" }, + { url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" }, + { url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" }, + { url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" }, + { url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" }, + { url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" }, + { url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" }, + { url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" }, + { url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" }, + { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, ] [package.optional-dependencies] @@ -382,6 +382,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + [[package]] name = "gp-furo-theme" version = "0.1.0a37" @@ -427,6 +436,7 @@ dev = [ { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, { name = "pytest-watcher" }, + { name = "pytest-xdist" }, { name = "ruff" }, { name = "sphinx-autobuild", version = "2024.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx-autobuild", version = "2025.8.25", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, @@ -453,6 +463,7 @@ testing = [ { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, { name = "pytest-watcher" }, + { name = "pytest-xdist" }, ] [package.metadata] @@ -479,6 +490,7 @@ dev = [ { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, { name = "pytest-watcher" }, + { name = "pytest-xdist" }, { name = "ruff", specifier = ">=0.16.1" }, { name = "sphinx-autobuild" }, { name = "sphinx-autodoc-api-style", specifier = "==0.1.0a37" }, @@ -503,6 +515,7 @@ testing = [ { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, { name = "pytest-watcher" }, + { name = "pytest-xdist" }, ] [[package]] @@ -931,11 +944,11 @@ wheels = [ [[package]] name = "packaging" -version = "26.3" +version = "26.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] [[package]] @@ -1035,6 +1048,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fc/3f/172d73600ad2771774cda108efb813fc724fc345e5240a81a1085f1ade5d/pytest_watcher-0.6.3-py3-none-any.whl", hash = "sha256:83e7748c933087e8276edb6078663e6afa9926434b4fd8b85cf6b32b1d5bec89", size = 12431, upload-time = "2026-01-10T23:28:17.64Z" }, ] +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1137,27 +1163,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.16.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/70/25/7113f6d5498888c5fb7db34081cba7d5971c4cb1bfb26819966eee68f003/ruff-0.16.1.tar.gz", hash = "sha256:fedad7c801dabd3fb9741d76aca39246e6ddd9ca446a015875207bf19f1e6bc7", size = 4877500, upload-time = "2026-07-30T19:37:01.379Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/bd/694da69368e0973de65df2ddc73ab18d43c469d5963d9b150911de6bc513/ruff-0.16.1-py3-none-linux_armv6l.whl", hash = "sha256:58edb313b88f0c5460a26adf5f39a37a3be789494a15e3e411e35fa78b89f9a0", size = 10839126, upload-time = "2026-07-30T19:36:13.697Z" }, - { url = "https://files.pythonhosted.org/packages/3f/f0/b626e5d5bd0dd9576263658ef12885e2288afd1029a48e26ffed65ec1ac1/ruff-0.16.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fde5a99e2f97479af66edd6622c6d5a2a7592c77cf4153d9e4428f5eeb55b60c", size = 11070253, upload-time = "2026-07-30T19:36:17.14Z" }, - { url = "https://files.pythonhosted.org/packages/83/63/f40acfb6b35b88623e71684942b552c3edd96035f5d98f313815f7b277de/ruff-0.16.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e0d4c20532fca4f7fa609369161d968dd28f65d83dabbd61d8e9c7edbf7001f6", size = 10561425, upload-time = "2026-07-30T19:36:20.04Z" }, - { url = "https://files.pythonhosted.org/packages/aa/dd/14ec0e9c2b4d315547dd38765004b4863e354e1b52cb308272215d9f6f6d/ruff-0.16.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30affbcedf59ad5703d9c91f82266e02b47739f797e1a7b6e158e5526a6dae38", size = 10948879, upload-time = "2026-07-30T19:36:22.476Z" }, - { url = "https://files.pythonhosted.org/packages/33/e9/9d870cbae575030fdef595f04b4b97573c525b5497cce4f4498cf2f85446/ruff-0.16.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24e9c631573cbca9d20f1283f8f479b2afa4a8503504822bd71a293889f16743", size = 10643691, upload-time = "2026-07-30T19:36:24.914Z" }, - { url = "https://files.pythonhosted.org/packages/c4/09/12743d544e2173f53ecd27217c65f90d2bc0f8424a66a60339e56bbc0457/ruff-0.16.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b41bdd48fb420987a9b5212e4957c26ad4abce401fa9ea9d4d85843727945f4f", size = 11435354, upload-time = "2026-07-30T19:36:28.447Z" }, - { url = "https://files.pythonhosted.org/packages/7f/89/a1652b2daee52083c9554a6333b678a8b01d0400f976827bb87857f9449a/ruff-0.16.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0d1e1393b7648079e13669de1c1f4fde06d4583e84d8fd5c1551e0a77a2aa75", size = 12259033, upload-time = "2026-07-30T19:36:31.326Z" }, - { url = "https://files.pythonhosted.org/packages/16/96/ecdcb8c54ee7b123b487f807eb014e6e019155a0b81dfb669acd52f28ce3/ruff-0.16.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07bf434b1c95f4e093be4532068ef4fcf00924eb2ade8796075980902d6fd54a", size = 11667981, upload-time = "2026-07-30T19:36:34.394Z" }, - { url = "https://files.pythonhosted.org/packages/cd/90/c52e12e0d862e9572f2a33aa227409143520abe53111e9a6babbac7b4af8/ruff-0.16.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39897739f112253ee4fdd2e8aa9a4f9ded99fb2be367d5f31dfa4ded6025584c", size = 11468183, upload-time = "2026-07-30T19:36:37.339Z" }, - { url = "https://files.pythonhosted.org/packages/2c/6b/4ffb7ad1d83eb16cf8cbb3c8815d3f11c88460fd162d4b372a2059be1c2a/ruff-0.16.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:82ae3c0c0d74daf17b968a10b7b3bb3ef297ab7de0c1f749646b25e690ccb150", size = 11470071, upload-time = "2026-07-30T19:36:39.91Z" }, - { url = "https://files.pythonhosted.org/packages/9c/72/32ae7db4c0b5e32ab611787caa19d1546800676d79f7483b7100a3561bf4/ruff-0.16.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4d5f2ed10f8242d83fc08d521301089364e3375375705356f20c0e31606ef3ef", size = 10919503, upload-time = "2026-07-30T19:36:42.65Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ca/3d901ba6ad6fc38da39c3448fc6c59ac945679293a17c3ceb6d6c1cba13e/ruff-0.16.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a4665b309891f83f3e3c25447935f1213e9abbd4b5640af7a1f2def9f8d413c1", size = 10649861, upload-time = "2026-07-30T19:36:45.18Z" }, - { url = "https://files.pythonhosted.org/packages/92/79/894ef1ced26552d5f8c9cf6d85b0687840e1128c55aeab7b9c2d54a0d880/ruff-0.16.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26e9ca5c9bc3971f20d3cf18a957f52ffd6a5f6564ff15c4912a144dcac22494", size = 11148137, upload-time = "2026-07-30T19:36:47.936Z" }, - { url = "https://files.pythonhosted.org/packages/2d/69/3609a09fa1cb46cc28b762363e440a354204e5dff01bd0c8d7437874d6b9/ruff-0.16.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:67e1e1e3fa4f0c82f0e36d4cd61e661f6e7a6196cb1aa92fe0828fa7b8f257cd", size = 11559211, upload-time = "2026-07-30T19:36:50.448Z" }, - { url = "https://files.pythonhosted.org/packages/fc/8a/fb22af2fd78a736e241fabf67e30ce1799a64244026377a49e133af90762/ruff-0.16.1-py3-none-win32.whl", hash = "sha256:d31765e131295b8445caf301e3e8a85b34d1b9b211b4109b7ba457888b051806", size = 10838258, upload-time = "2026-07-30T19:36:53.298Z" }, - { url = "https://files.pythonhosted.org/packages/d4/35/e57fd9fb5d423961df087a00b12d42c0a830288dc2f3b45ecca299158b4f/ruff-0.16.1-py3-none-win_amd64.whl", hash = "sha256:09b05e8b90c2cb06ad63464350e7a45e8e44a2dfe52072ebfba6666ca8d3f596", size = 11961111, upload-time = "2026-07-30T19:36:56.107Z" }, - { url = "https://files.pythonhosted.org/packages/cb/46/240ea004bf6dc4feb40e9832f2205a476a47dd5b8a3f8211a5fc5f95e20e/ruff-0.16.1-py3-none-win_arm64.whl", hash = "sha256:dbaadaac38c70239f056d306b7476f246b0bf000fa6b3876402acbf5b227eaf8", size = 11309414, upload-time = "2026-07-30T19:36:58.79Z" }, +version = "0.16.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/73/e1/4508a569211b35599016e84ba65c1a992b7a4004b4b6c4bea02a851cba1b/ruff-0.16.2.tar.gz", hash = "sha256:c3d7828d12e8927a6fc65fe38e2c2541b9e762d360a1786d752cb1b8883b3c9c", size = 4885811, upload-time = "2026-08-07T13:31:01.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/57/db19951540f98859c956b50bdb4d31089b4d91e9f15e2968e7d5193806d5/ruff-0.16.2-py3-none-linux_armv6l.whl", hash = "sha256:3c8de4cf2181f01d57946d87d777aa52916976fc09942aed89938fab5e013318", size = 10847925, upload-time = "2026-08-07T13:30:14.468Z" }, + { url = "https://files.pythonhosted.org/packages/13/5a/995fe85a8470d3e391ac0f7fa8054bb454eaf33ee138196d6172ed1079c0/ruff-0.16.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9a48cc05c6fbc811ca81b5d7ba95375affea6582d1b8024e455e41afbbf55344", size = 11072662, upload-time = "2026-08-07T13:30:18.143Z" }, + { url = "https://files.pythonhosted.org/packages/32/53/370d767c61c71a971a4ace36703a7ecd8c393956349a7325d7fab2b56827/ruff-0.16.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a2c0d14fcbb26c91f0f867a6dc9bd71bbc30b1b6151829c884f23faeab2e5700", size = 10566771, upload-time = "2026-08-07T13:30:20.899Z" }, + { url = "https://files.pythonhosted.org/packages/85/d6/9d96948caf5a632be62d62202d5ec914d6856f204fd79eb036e5915e79ea/ruff-0.16.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:335c621622c4650330be50842561c6586ac6971bb8ab5407fe34dcc9efb16bbe", size = 10975825, upload-time = "2026-08-07T13:30:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/92/ea87129b3414acb0b5770563779c51804d37ac67675c7ba35447ddb14773/ruff-0.16.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20e66910f2c37cc753f9ef6580c914a621b80c4fa3549d3e3521e29d0f5bfc3f", size = 10649437, upload-time = "2026-08-07T13:30:26.097Z" }, + { url = "https://files.pythonhosted.org/packages/ac/43/f8f291dcd4af5bb7872b74fdfa41a7cd7c856ca1d4069670971cf1b9f5cb/ruff-0.16.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7e36fbfba65510548156902bcf1350a979a958ce0347ce0f90d73894036b39f", size = 11446761, upload-time = "2026-08-07T13:30:28.752Z" }, + { url = "https://files.pythonhosted.org/packages/71/4a/ef991fb2fcf516ab71f0808adcdd8da5e18c8cde447f4ceaf5f47a5132a5/ruff-0.16.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f0eab35f80df8f134aae5d1630e751901321d317cc8e50dc39e36fa3ed34cd12", size = 12336364, upload-time = "2026-08-07T13:30:31.468Z" }, + { url = "https://files.pythonhosted.org/packages/f3/24/f615e74f307e6ca0e56a482872477b856c70d530aa356abfb6dfe5ca8a80/ruff-0.16.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ea8c0594feb894e89c8c61ab9c103d38b0ea72dfde6c594107147ca31b1140", size = 11630720, upload-time = "2026-08-07T13:30:34.426Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d3/8ef50149e8412a77f7ab409efdef0e2b23803707a3863da4fc64cb23d459/ruff-0.16.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab3d62dde0b19facdd632008cc4827fc28ada7736c6bd35ab6f1050f0bfed53f", size = 11466130, upload-time = "2026-08-07T13:30:36.958Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a7/a19334985c4dea8c381981fa252cd854c7ee52dc4b1686dc16f4a911c702/ruff-0.16.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e43e1f5b8388da9eca1b9e88328d47a5cec794633ccf6f7484ac2dd15eee92c0", size = 11523634, upload-time = "2026-08-07T13:30:39.822Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6c/96d192b0e742412ceda08c0a50f9669b253dde9fd6a60ea1a10c9fa79a63/ruff-0.16.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c24788a980581e1d7ea3a0cbe4344c4fbeb0a6a9b1f4713aa46bb104f8294690", size = 10949807, upload-time = "2026-08-07T13:30:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/fa/51/e26599ceca11e79ee255c7df515995561edf87e9ca1893284e44d98f5a86/ruff-0.16.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:81806b08329130005dd4a8a8394a0c9da8c6f4cafb16ba438d2a2ee6a18bedf1", size = 10646891, upload-time = "2026-08-07T13:30:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/68/01/800c4b1f97bc8d7c6029e06b1f20473a3cf1e13c4933d8f3342add83fc55/ruff-0.16.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4ce4e02bad779bef557f541a1b31f20d6abeae1cc05ed1b1ac019d4ffd1044c8", size = 11162063, upload-time = "2026-08-07T13:30:48.131Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d0/1477ea50fc5a0d4b0b71d1d63d50770bdd794d90b43e37a7618e63ec9894/ruff-0.16.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e0422abdf70070255fc4073ce9dfc814cc03db577013761ddd09bc1e4a9a4fbd", size = 11556038, upload-time = "2026-08-07T13:30:50.686Z" }, + { url = "https://files.pythonhosted.org/packages/b8/76/a7776f32048d991e16d4fa8ff91790b877342d3596cc3ed04acdbf1aaedc/ruff-0.16.2-py3-none-win32.whl", hash = "sha256:bf3a63d78fb39f4bf5ac8ae52051c5520505301abe19ba4e204c453b3f09bb0b", size = 10872850, upload-time = "2026-08-07T13:30:53.471Z" }, + { url = "https://files.pythonhosted.org/packages/00/0d/929c800d920e61397d82a01b60bffc68da3052c17d31de59efaad2e4ed75/ruff-0.16.2-py3-none-win_amd64.whl", hash = "sha256:bcabe2f6d0fc7819f1431793005af4e4de7371927d037345bf941252b195b9fa", size = 12023338, upload-time = "2026-08-07T13:30:56.193Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6c/93e26c22c5f78ff87363e07da49c84955affbeb1098bd1936bf3b3f293bf/ruff-0.16.2-py3-none-win_arm64.whl", hash = "sha256:d614e95cedf38a2053fd351c55b103ba30d017d61688fdbfd40ee0412852a99f", size = 11374065, upload-time = "2026-08-07T13:30:58.775Z" }, ] [[package]] @@ -1275,7 +1301,7 @@ dependencies = [ { name = "starlette" }, { name = "uvicorn" }, { name = "watchfiles" }, - { name = "websockets", version = "17.0.1", source = { registry = "https://pypi.org/simple" } }, + { name = "websockets", version = "17.0", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/e0/3c/a59a3a453d4133777f7ed2e83c80b7dc817d43c74b74298ca0af869662ad/sphinx_autobuild-2025.8.25.tar.gz", hash = "sha256:9cf5aab32853c8c31af572e4fecdc09c997e2b8be5a07daf2a389e270e85b213", size = 15200, upload-time = "2025-08-25T18:44:55.436Z" } wheels = [ @@ -1541,15 +1567,15 @@ wheels = [ [[package]] name = "starlette" -version = "1.4.0" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ac/16/dc49e4b9d348b0f0567a67613a946f4e26b3299408ccf78df318c51a2aec/starlette-1.4.0.tar.gz", hash = "sha256:ecf3a067176d63c6412f98c660dab3355b525584d3725c0c40a4519d33ec2130", size = 2708995, upload-time = "2026-08-05T09:36:43.693Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/f6/7916cd7717e196bba370c0f17abb30466014ce29094665c3581e51a8c24b/starlette-1.4.0-py3-none-any.whl", hash = "sha256:cacd43738e07b834844e2c8e97b898b40089d74f2678234b0a3e93cd3d515813", size = 74021, upload-time = "2026-08-05T09:36:41.944Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] [[package]] @@ -1644,16 +1670,16 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.52.1" +version = "0.52.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/03/18/ccce41535dee1be77735592bd19965f3972c82e07ee703d324709496b716/uvicorn-0.52.1.tar.gz", hash = "sha256:112ec661814189acbccd3f7b86460147cc065fc92c0821afa78918780e4354dd", size = 100571, upload-time = "2026-08-01T18:19:30.732Z" } +sdist = { url = "https://files.pythonhosted.org/packages/05/c8/2d307868453a4bca6e64fa3581d122ae0748a0869c53f159339def179c7c/uvicorn-0.52.0.tar.gz", hash = "sha256:ca8876ad6c1983f394157c168b39d52f6dd56dabf5602fa0982751cffc2293ae", size = 97504, upload-time = "2026-07-29T08:45:34.065Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/d5/68e6e9bca63c0badf67002890a46d3784c958de45b65e1275ec583ca1f06/uvicorn-0.52.1-py3-none-any.whl", hash = "sha256:e4403f9d93188cf9d1088e9f40e3acd12630e2df8675316704379a7fc20fff6a", size = 79859, upload-time = "2026-08-01T18:19:29.294Z" }, + { url = "https://files.pythonhosted.org/packages/39/e6/b5c0630ace9757232aec07112be8146b812787db52141ff9d50674aa7634/uvicorn-0.52.0-py3-none-any.whl", hash = "sha256:3d887809810b89ed33501bcf0a9aba469b06ecd608158efce04bd6b48d8c9b08", size = 79058, upload-time = "2026-07-29T08:45:32.492Z" }, ] [[package]] @@ -1926,119 +1952,119 @@ wheels = [ [[package]] name = "websockets" -version = "17.0.1" +version = "17.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15'", "python_full_version >= '3.11' and python_full_version < '3.15'", ] -sdist = { url = "https://files.pythonhosted.org/packages/f7/96/e01084f83a64bcb3a27994bd0cb0db68ff29d9c6707fae37ec19b18ba990/websockets-17.0.1.tar.gz", hash = "sha256:5baa9bc0dfbae8c507e51c8cf1b6d4628086f7a87bbd3a9952bd5f035451f1cc", size = 183298, upload-time = "2026-07-31T11:31:27.665Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/54/b2bce5b754b91b727b852e78af6d7193d4fe985e420dc54e6c2abe161c1c/websockets-17.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c38515cb54902f7e97d0239e81ef46c4444f9475f4807fb9bbdb789b4089abcf", size = 212573, upload-time = "2026-07-31T11:29:04.803Z" }, - { url = "https://files.pythonhosted.org/packages/78/1b/eaefeb695b217d8c735fc377ab53c6b00bc9ed64a01a3f6f797096ac0ee8/websockets-17.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:70d438268e49f1a4bd096b6b6f7010f3ab48b5db2574dbf7d8c864c46ce7a06a", size = 210262, upload-time = "2026-07-31T11:29:06.298Z" }, - { url = "https://files.pythonhosted.org/packages/87/2d/68439a174c74969fb51619bbe9af9496826610883b828a2edd2f022c94fc/websockets-17.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0b52c76b8a870b141b7ca0705289452183ce7a523101954ccfe29a25986a673f", size = 210535, upload-time = "2026-07-31T11:29:07.553Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ab/13a856b488dbac7fd3c5473e38098897cda0baa04e3ca3b34ec6c8a32b46/websockets-17.0.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b98860aefbd3d9bc8e3c7f0eefb83b11142b16110739c68cd33d3b4d6e84e536", size = 219602, upload-time = "2026-07-31T11:29:09.227Z" }, - { url = "https://files.pythonhosted.org/packages/45/a1/2b100e71aa1fe283ec8fb73f8b74a8576d855486a49117903b100dd3b78d/websockets-17.0.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d41e9845514754a42d1d83b2fca9d27fee2ca7b3b0bee6843ba5a9bb2b6e25ac", size = 219873, upload-time = "2026-07-31T11:29:10.362Z" }, - { url = "https://files.pythonhosted.org/packages/3b/71/92e6146d3588d145136c0e0e16d106bbb855bb5047e13ec3fdee39cce770/websockets-17.0.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9aac6081513f02eac3f8caace800dbfc5c608b69e4a7bef69e414eabfc95aa1", size = 221108, upload-time = "2026-07-31T11:29:11.708Z" }, - { url = "https://files.pythonhosted.org/packages/09/8d/357afa2ffd29686536109e7e3cb2a94f2d09e535df4cf3989393dc506a40/websockets-17.0.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b85b960a4507b0714c0a1246d031be9118d908ee974dc085257297a955205f1d", size = 224401, upload-time = "2026-07-31T11:29:12.959Z" }, - { url = "https://files.pythonhosted.org/packages/01/27/9efba1e7a8df48e405d017e67513c6b2a8f0b59c8500b820c47cd5f3dea9/websockets-17.0.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c356dbddab0a529ed7574f78f559d75a223735c321c28f6f587fbf02b11ed301", size = 221670, upload-time = "2026-07-31T11:29:14.199Z" }, - { url = "https://files.pythonhosted.org/packages/01/85/ab27d62103e8a150f3657e043e5fc711ad3e018c8cd8a715093e93d7640c/websockets-17.0.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5661f868ef191d33dfc6a0cc7c5b3d495f0cc8bb3f8b30d87bda8755c61c95f5", size = 220442, upload-time = "2026-07-31T11:29:15.417Z" }, - { url = "https://files.pythonhosted.org/packages/6d/04/289e00b8001b622b0c397a6901fcc4aa8f34a6d2ed42be17f2704f2faae1/websockets-17.0.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2fa2cb465a131c347ba6717a78c887746e73edb1c131d01c982d6ef0d68b82e0", size = 217760, upload-time = "2026-07-31T11:29:16.772Z" }, - { url = "https://files.pythonhosted.org/packages/2f/70/0fe58cdac988dfc0066786cc07b09dfd72b48b0c01e5de667721192b2e6e/websockets-17.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:55383d8177b3c99fd873ee5db0e0193f4c1dd4a3feaccf1a4a03c1b7cf539cac", size = 220597, upload-time = "2026-07-31T11:29:18.075Z" }, - { url = "https://files.pythonhosted.org/packages/d5/76/d3eaf120710d1a791d1c7a4963f0b50a589df8ba675394fd2a97dfea3746/websockets-17.0.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:038cfad5d5417f8bb09295abe986029a26d22f34bda622ccc79b670efd4dab56", size = 219187, upload-time = "2026-07-31T11:29:19.468Z" }, - { url = "https://files.pythonhosted.org/packages/e6/00/4e9ae886bdb1647537176ca33fca66d79dddb3773d213ac98ddc6bba9ab4/websockets-17.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:15920057a6b723f84734f0641403bca163a4b176e5af809ee4f0c4a1e75e9fed", size = 219955, upload-time = "2026-07-31T11:29:20.641Z" }, - { url = "https://files.pythonhosted.org/packages/7c/67/cd7cc6849a86cf8c9979c0c570b6b6ccee75d2872854238d8d54e77be6ec/websockets-17.0.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:5508f38c98ac29def9e747b87543b008a58b075df6da70b2cf2e0b47073d33bb", size = 221002, upload-time = "2026-07-31T11:29:21.753Z" }, - { url = "https://files.pythonhosted.org/packages/de/5c/4d14eaf7b2f1448d1af24c1641f04eb74c1632a5802952aac4b7e068b8e7/websockets-17.0.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a68e604c6d1b0338e46652e2688cbce8096ad9c03548b075fda9e2ea19a9b7dd", size = 218579, upload-time = "2026-07-31T11:29:22.888Z" }, - { url = "https://files.pythonhosted.org/packages/15/67/ff8bc4b8a6ec235ed8985de12fecc59ad2cd68cc8fc79b97deaa42e412ac/websockets-17.0.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a8af570fc29cd998a921c7131c8ac81d9434466d6d25300cb12a690fb56a8a08", size = 219612, upload-time = "2026-07-31T11:29:24.052Z" }, - { url = "https://files.pythonhosted.org/packages/91/eb/103d81d655bab3ffd5c7d5d4b08f92c374499decd1d4be6035ce715b385b/websockets-17.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:246927ae9ae06ca0d42a483a4bdb80d4862e1ee5b4cab37c354a5e1ad8356448", size = 219846, upload-time = "2026-07-31T11:29:25.421Z" }, - { url = "https://files.pythonhosted.org/packages/b8/1e/3495161b1827941258545604fdf72e3e053d03f25bb61752228a784c26a1/websockets-17.0.1-cp311-cp311-win32.whl", hash = "sha256:1d4cf7e8e5b8b1fa40758ac7524843a00237b124ab217e227542cafcfeb7a946", size = 213046, upload-time = "2026-07-31T11:29:27.094Z" }, - { url = "https://files.pythonhosted.org/packages/7b/b1/ba0ce59681db38c320a6d485f95a497ddea20356d9a9e8e70615ddd867b9/websockets-17.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:02f0b037a737d0cb0c33866c97bcd1a0b73170dfbf42d69d8fb86f51002fd5ae", size = 213344, upload-time = "2026-07-31T11:29:28.28Z" }, - { url = "https://files.pythonhosted.org/packages/83/9e/abfdde9cbd57f0b5867a70e3426ac63341e1a21557b273c39bb6d2ccf8b9/websockets-17.0.1-cp311-cp311-win_arm64.whl", hash = "sha256:1bdd8c4be420905dd732e00dcd669852d8128cc723efa585a0c0e51adb00a28a", size = 213277, upload-time = "2026-07-31T11:29:29.504Z" }, - { url = "https://files.pythonhosted.org/packages/50/ff/6199a52d864215750af8668d84b0274775011a90052081f5a9495807a92b/websockets-17.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:10f461191125c63902ea7394ae9e752b1b5785641850c1d365bb30b0f88bc53f", size = 212603, upload-time = "2026-07-31T11:29:30.771Z" }, - { url = "https://files.pythonhosted.org/packages/54/7e/439a962bcada88dcf586da77a1b2385f91e2d2910e9359540934c827156b/websockets-17.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cffc84ddec6da7f447677266fee2a3c40ecc78172f00752aa1150b8a8d65df1d", size = 210286, upload-time = "2026-07-31T11:29:32.157Z" }, - { url = "https://files.pythonhosted.org/packages/d9/82/123660edc759c225626b3b91952c7625f85c77a8362acbc35a4623120f7d/websockets-17.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c23e532c8a2325a1e7486de8763a60dc43e83f01bcaeca07e3ba79652c156db1", size = 210549, upload-time = "2026-07-31T11:29:33.388Z" }, - { url = "https://files.pythonhosted.org/packages/cd/2f/2940e57080cf56f28190287516400126d5a76b52b9a61dc10ba6f6400dbe/websockets-17.0.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c09e097d0e46e3c289bedab9a475ae344b70c30ff5646e46af22b4e6fdc97b21", size = 219874, upload-time = "2026-07-31T11:29:34.608Z" }, - { url = "https://files.pythonhosted.org/packages/42/28/9ec976c16d63cc51c28dfec74b66854048c0b8b6579946e902f91b69e8bf/websockets-17.0.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f47b0815af3948ec6a440b3afa02f05b18cc0939549e91b5c677b5d9c2c8472a", size = 220150, upload-time = "2026-07-31T11:29:35.831Z" }, - { url = "https://files.pythonhosted.org/packages/f4/a4/850c699a16bbc451723856360c59bd997bec075e637154f3fa96e80d5760/websockets-17.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8848c207049ad49d318e5f64a3d4d7bb189f8328d0d98e65647788f2a085785c", size = 221389, upload-time = "2026-07-31T11:29:37.189Z" }, - { url = "https://files.pythonhosted.org/packages/47/e1/f60a891c1a4b3420d5052333a84eb7241e1fb4a71866dec1562f5fa30027/websockets-17.0.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2604de7228506b13a44a256a9d223943340c0e725af5d367dc068e192b027761", size = 224169, upload-time = "2026-07-31T11:29:38.61Z" }, - { url = "https://files.pythonhosted.org/packages/26/fb/e2a893be6fae4fddfe50ddc3035a331d3f381103d5467b7900026bdb3a64/websockets-17.0.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07abc3bd196a48af476a82fd47f3f79a6a3f70937a9f930cef703cfa0c9d83b6", size = 222025, upload-time = "2026-07-31T11:29:39.897Z" }, - { url = "https://files.pythonhosted.org/packages/d9/72/e3144b2d79276fab9798ed7d4aea2f0847434f186800b6f56a1eddcb3114/websockets-17.0.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:769ce7e2acfd9a89f2bed3a9c0da229459516bbc00bd4c9e2ca492c613ae4861", size = 220779, upload-time = "2026-07-31T11:29:41.084Z" }, - { url = "https://files.pythonhosted.org/packages/c0/5e/69c02174fbcf1c40c6adc45d3c316a401558392fe7bab8969ef8c46f1689/websockets-17.0.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07d78a509c3333f5908c83d7f78144ea68a6c9ec28110f5c54d81d8fcdc262c4", size = 218053, upload-time = "2026-07-31T11:29:42.322Z" }, - { url = "https://files.pythonhosted.org/packages/b3/09/7574778b095b99cfa0856583462f56568df784f9b41485145169b2ec9c64/websockets-17.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ffad64ce7ad3703d652a3fd9af26238377d24ce52c6ad8ff35d26d82f61f493f", size = 220825, upload-time = "2026-07-31T11:29:43.553Z" }, - { url = "https://files.pythonhosted.org/packages/c5/e6/f46571f38765dbc4cbc0d0b47de8db65768006dbbd4340e6f5f51bc1d895/websockets-17.0.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e95e321d0d763f2b6633512605f6112ebd70d5746f3ce05c941909d4a25233f2", size = 219427, upload-time = "2026-07-31T11:29:44.731Z" }, - { url = "https://files.pythonhosted.org/packages/9d/31/6ff1fee057bd7e9dd5237fc064a749615378d003aa045b5bfc2d12b2f4f7/websockets-17.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cd526c8228e759c1006c4b7c9ac71dc4e925ced1a6a6a5a8e94643709738f63e", size = 220198, upload-time = "2026-07-31T11:29:45.997Z" }, - { url = "https://files.pythonhosted.org/packages/7a/4f/d41847227a44b9ad87c3d5a9fddbfad8b7c4d6032878d8460d9d37c2d44f/websockets-17.0.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8cd3369e42c0246afaf9d669cfc19797e3a49e8c0a639544459c57597108b966", size = 221304, upload-time = "2026-07-31T11:29:47.314Z" }, - { url = "https://files.pythonhosted.org/packages/63/30/21a7e326c6ad2eb526cd5b816383d59cdeb28b8805b65a543c3cfbd8e8ce/websockets-17.0.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:b580794e926cab7ff42ee4371ef14e0b22cb2bb722a607f77769136468f49a3f", size = 218858, upload-time = "2026-07-31T11:29:48.587Z" }, - { url = "https://files.pythonhosted.org/packages/2d/48/55b0331cd5bec9ce29748f79edc00075805450a47011d0c8e3b1c61dbf04/websockets-17.0.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5033ffe6804dd53afafa7d08e8c3eef2d2431f34d58ca30507a8442dd04a033a", size = 219840, upload-time = "2026-07-31T11:29:49.791Z" }, - { url = "https://files.pythonhosted.org/packages/78/6e/2e8bc06e546f49b32a58a2bc2957902d1809ecc37552d3d7ccd6639a126e/websockets-17.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6db9e5bf3649ab506c6ae8a3ac85a00fb1ae3816d75962771b2df8adbc5d40d2", size = 220116, upload-time = "2026-07-31T11:29:51.026Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ad/4bac01fa41aca54307157b9c9f68b066a6bb51fb18716ba618078a67b283/websockets-17.0.1-cp312-cp312-win32.whl", hash = "sha256:bc0bca48ba24c6c866847fd20478a51dd547fa0ad258dab9615c414ec534bbc0", size = 213050, upload-time = "2026-07-31T11:29:52.328Z" }, - { url = "https://files.pythonhosted.org/packages/82/d8/c3a78cccc74a554780e9e76e323d5cde891048627025f0f82623e22dc3df/websockets-17.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:2b3f3020171202b135ca078e20434977c6b2b02af647130d6980c9e39b9462e3", size = 213348, upload-time = "2026-07-31T11:29:53.891Z" }, - { url = "https://files.pythonhosted.org/packages/7b/25/e1b8824bd632c8a5a62d504b61e9e35e470b67e4be0206f5c28f90c7f86d/websockets-17.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:41d6aa06b5ab832aee72fedf47a149535b121ac900b6bb4d3fe14712afac9a79", size = 213276, upload-time = "2026-07-31T11:29:55.299Z" }, - { url = "https://files.pythonhosted.org/packages/ba/a8/79c577bc2f874ee22f6f5ccdab97ba9ce6b96806be3fcc3a6d8490f88a21/websockets-17.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:55b12e47dcee83673a40d07686cfb6f9d6dfc285976ade9463f61d2bef3fad22", size = 212593, upload-time = "2026-07-31T11:29:56.518Z" }, - { url = "https://files.pythonhosted.org/packages/db/99/e1cfaf419bb3b2fcfd6792a846f1d936293132b0b9a56530ced016c83c7b/websockets-17.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c1c118a6b0e25bfc9a6802075d748fa6321714ffbdf3c88d29d9a0e3c7386c75", size = 210280, upload-time = "2026-07-31T11:29:57.768Z" }, - { url = "https://files.pythonhosted.org/packages/a2/ef/cc994494bf7d97e41833f6ff55c24f535e4d527a10370b9631737e9c2f00/websockets-17.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:734d20364dc2cfe03674883cafcf580b6e431c5ce42b476312b9285310230cf9", size = 210538, upload-time = "2026-07-31T11:29:59.021Z" }, - { url = "https://files.pythonhosted.org/packages/87/32/fbf2d132f63ba3e67f675bccf333469786a24e0418969ce1d8e6ff9e6f02/websockets-17.0.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9493314a99e599163c854fb5900ad7f7ea38c5cb9d9103aa30b3c6b8181c01fa", size = 219925, upload-time = "2026-07-31T11:30:00.298Z" }, - { url = "https://files.pythonhosted.org/packages/16/50/64eee3d25a47fe744a9490e0627cc373dca096755db740f91c28bd61cd35/websockets-17.0.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:18ded646ce98cdd3c0235825b3252f1df55765ba49b616bb10282f758667b4d0", size = 220206, upload-time = "2026-07-31T11:30:01.52Z" }, - { url = "https://files.pythonhosted.org/packages/15/56/10ed4bc4dd75f204e3c62bd4898e44a8742a27773c80b188cfa7888aad2d/websockets-17.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1bec5d6a19f5fbe87e4940739cfc65e7bb53d8b353e1029b8037a1653b321bc", size = 221445, upload-time = "2026-07-31T11:30:02.788Z" }, - { url = "https://files.pythonhosted.org/packages/bf/be/bb14328614c068ab09569962fbf218fc00413ce3febc6d2684c764b6f37e/websockets-17.0.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:872273e629ca7e3d35f16a2dc6ede84e1d5c831e616b8277de6e4f83114e7c58", size = 222887, upload-time = "2026-07-31T11:30:03.943Z" }, - { url = "https://files.pythonhosted.org/packages/32/1b/4cb0eec2fee310007104687493175af190019f705940c864f9c523fe9f6f/websockets-17.0.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1df81d174c1561292de9e40b141cafc04f69077272f6c352afe1d743e20810df", size = 222072, upload-time = "2026-07-31T11:30:05.258Z" }, - { url = "https://files.pythonhosted.org/packages/6c/9c/14e6391de777ddb39c439c450deb551406d445e25a5877d6fa25c49d4544/websockets-17.0.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:759adeb5b0c5775b563254ec63b5b79089fc0045b479143a0b1b8c0ebaae1253", size = 220826, upload-time = "2026-07-31T11:30:06.53Z" }, - { url = "https://files.pythonhosted.org/packages/cb/57/96e94e384442247bbed5d3ab67381c7257355c2d66b62c3ad33a17f5d385/websockets-17.0.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1d99db29b5444e3982f1ce2ba8a833508ad44b2f1fbd0bd99e81d825c0b461", size = 218107, upload-time = "2026-07-31T11:30:07.766Z" }, - { url = "https://files.pythonhosted.org/packages/c0/8c/9c9dedd14c3919435df9b35cdee7111268c751252b87652f3a6a4f56e760/websockets-17.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:02ed63bf26dda9fa27df730a41f6664586c4ee05972c8fb667ce1725b3fd13d3", size = 220889, upload-time = "2026-07-31T11:30:09.035Z" }, - { url = "https://files.pythonhosted.org/packages/94/4d/ca73c2ac82c00f50c529784bacb323e42da4816333211bc1543d90c9cf11/websockets-17.0.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:eab6de8a98b9a7772cf686d00b4de439fc7efb8ab05ae106ef227291d06f87c5", size = 219486, upload-time = "2026-07-31T11:30:10.289Z" }, - { url = "https://files.pythonhosted.org/packages/5b/da/fb37ac09dcd7c69dd73bac979ed393df35f78a3c232e293d1ff3bd586d24/websockets-17.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2a855b6dfe21c4d3420be265ae031829ba8ba0be0ea350d9f7c3ef30ae63ebe2", size = 220258, upload-time = "2026-07-31T11:30:11.605Z" }, - { url = "https://files.pythonhosted.org/packages/fc/04/9693f191d968a93f37326a17301a101d49580889c688f466699f89ecdee1/websockets-17.0.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7002d5f9e1c3ddd991cdfdbfee18cc8c8b196b2445022892badacd6cb338bbbc", size = 221358, upload-time = "2026-07-31T11:30:12.858Z" }, - { url = "https://files.pythonhosted.org/packages/cf/29/ad0d85c01db5dcf22898d51648bd2c25af0dd0a4a41c550b11acddeeeba7/websockets-17.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c395bda8e7d8f51a02e80261fb57127979e5c472675d9a96b2860619ad47da48", size = 218921, upload-time = "2026-07-31T11:30:14.064Z" }, - { url = "https://files.pythonhosted.org/packages/18/3b/bf8e855e495dcca63f2b8aa019cf2ada3160e1fa66d833c7417f3b1f7f38/websockets-17.0.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aadc298969ad229d8e3029fc5cc751fdad286696230f9cf014e90ff9cd8e6ea0", size = 219871, upload-time = "2026-07-31T11:30:15.358Z" }, - { url = "https://files.pythonhosted.org/packages/3b/db/c7abd6639a93a40279cd1ddc57e09e1c4f8381c4cfccdb775aa5aac9770a/websockets-17.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f11a398d8170b7ac5000baf7f258dcda579ef3ea744e0cc6a165e0dfbc0d3198", size = 220154, upload-time = "2026-07-31T11:30:16.96Z" }, - { url = "https://files.pythonhosted.org/packages/f6/2a/25a9f8f2e5a6ef34e911d2f55d9f756bdeb92b4c28cfb77b8430bbc73cb1/websockets-17.0.1-cp313-cp313-win32.whl", hash = "sha256:846a4a8b0833e3cad57523d9e3bd50ec8ea05ab9d06c582f82a1340ba096af5f", size = 213038, upload-time = "2026-07-31T11:30:18.434Z" }, - { url = "https://files.pythonhosted.org/packages/81/2f/ea1380f72bb11b64fc5bc7ae0d42de5bbf3e6dc13b965706b2a1d4e17cdf/websockets-17.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:409d93efcaa14f7a99592c5baaef5ec6ca94fba0f5aec1a86f693977c69c9c1c", size = 213348, upload-time = "2026-07-31T11:30:19.693Z" }, - { url = "https://files.pythonhosted.org/packages/e6/c7/b956ed9151c3c74530ebc62d716fbfdbde7507a6acc6423a64f9ecfb6b8a/websockets-17.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:90246fa9e6cb192a778ce6ce024057ec54317a894db7899c922dcdc1f4cbf6a5", size = 213282, upload-time = "2026-07-31T11:30:21.045Z" }, - { url = "https://files.pythonhosted.org/packages/98/dc/cadab608924ac605647031472fb1f8792d7d4ea07565ba1899ec42028e0d/websockets-17.0.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:53b90c00bc6201ab6695c7ff51a04d0e425514c37515e9eeecd2c1b978ac6c0e", size = 212640, upload-time = "2026-07-31T11:30:22.436Z" }, - { url = "https://files.pythonhosted.org/packages/16/7a/b034d13ca181211bbd58bb50835cb196a7784cd505b5a2079d4d03374f9f/websockets-17.0.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5f33a649bfcb8312524173cc4bbafa7dbb236e18eee9aa31a1d324ca0ddda28c", size = 210332, upload-time = "2026-07-31T11:30:23.608Z" }, - { url = "https://files.pythonhosted.org/packages/2f/4d/943ede39b53744768edf1ed84a3f9401527388228a3d6c1249c02c3d6bd7/websockets-17.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cddc675ec31bca65473321f9a9794e488b43b3b8de5d02c8ef4810c5d5792163", size = 210546, upload-time = "2026-07-31T11:30:24.932Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e3/88dc159d2ae66743c669443246243f28d873b0c5e58271b8cc1ca0440334/websockets-17.0.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b3ff0ad440ad52dda64138f16895f66403f40192365e39b1010e889f289746b0", size = 219928, upload-time = "2026-07-31T11:30:26.221Z" }, - { url = "https://files.pythonhosted.org/packages/fe/f2/ff27eaefa15851a5cf7f004ab827a022bf2d6632cb520f89cb100db7e84b/websockets-17.0.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:72d7f2a5aeb4e82daa4ee18f125b4277f427033359be5c745ad709608446cc2c", size = 220279, upload-time = "2026-07-31T11:30:27.49Z" }, - { url = "https://files.pythonhosted.org/packages/19/2e/a5166149f363d2449c1cb2dde6486a245521979509d53b87a09f3e79662b/websockets-17.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6fd88365da261c53d3e943fb37e0d0721b9cde119f6b2e3fc84369b6ab234d63", size = 221525, upload-time = "2026-07-31T11:30:28.872Z" }, - { url = "https://files.pythonhosted.org/packages/9b/b5/f46931269b3ff3bde65d27c65ddb22f9bb8ce92ac2c6c4df0910128f6219/websockets-17.0.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ab9f962a5b64a5c3c845d556b7dc4e6fb683f7b67179f8205e814bb2e0213ffe", size = 222897, upload-time = "2026-07-31T11:30:30.164Z" }, - { url = "https://files.pythonhosted.org/packages/42/f4/deccf3439f35df953ec35e13fe07986821c5f1ab5785d69614283bdb9034/websockets-17.0.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8c07f145d0b9e90cbd96035f31fb79199aef4da1872854e36ebeb258e3d57594", size = 222129, upload-time = "2026-07-31T11:30:31.489Z" }, - { url = "https://files.pythonhosted.org/packages/35/a5/e1b57a59da92ade37fd021567a17b518ea8267b28e5530075844cdb525fe/websockets-17.0.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9f7747d3daa41a11f25f7cca5dc988fc51da97b311bed4c9d843860f79779283", size = 220875, upload-time = "2026-07-31T11:30:32.805Z" }, - { url = "https://files.pythonhosted.org/packages/f0/30/e7d0889c790a854156de424575fd67af79ddbaed9ff3157ae863dfd1c1dc/websockets-17.0.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2abb1ba0a5133b7d2ef3c1c9f4b0c1e8a101012dce0b594ab2b2888d9a64820e", size = 218160, upload-time = "2026-07-31T11:30:34.512Z" }, - { url = "https://files.pythonhosted.org/packages/09/2e/43db785d6ed9ae7594fae7b62bbc9cb4dfee2b015e06a1005f1e5ce283b6/websockets-17.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f3fd9a1f87f8f0f3f8e9f9bd0195f7516562d13f5b178db8c5784d1f60b60bed", size = 220951, upload-time = "2026-07-31T11:30:35.806Z" }, - { url = "https://files.pythonhosted.org/packages/eb/f5/4ac3cab3d5e8a830657a822f64a8910e3803229c6783e59c3fd9a3487427/websockets-17.0.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2bc14b481e05e331811108daa1aeb41a5e237a5564ef2f02ec5a356a0f102f78", size = 219460, upload-time = "2026-07-31T11:30:37.273Z" }, - { url = "https://files.pythonhosted.org/packages/03/0e/c3a4020673ffc17c82cf1a467835038a196a555d3b4f2a50f0f063cf8ccc/websockets-17.0.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:57d2ee9b24b404ce75f3814f92073c0ed88106c950148d2427fe8d25ca254d1f", size = 220248, upload-time = "2026-07-31T11:30:38.527Z" }, - { url = "https://files.pythonhosted.org/packages/7d/87/e47a6a278cc1dfade38444c893ce18322943c25d4b780a74450d9d164be1/websockets-17.0.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1b363bfd72a52c0658a3154a4cff219f15a474b35a235057d38853bf151acce7", size = 221421, upload-time = "2026-07-31T11:30:39.879Z" }, - { url = "https://files.pythonhosted.org/packages/da/8f/473d5fc4e3836e375b0233c6ef26777e6e5e3f7bfc84ccd524eae4090ed5/websockets-17.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:10b1587c599fa0f2c89154587c80e0fda98ade6c9fa8c0260a2823fb1800b685", size = 218975, upload-time = "2026-07-31T11:30:41.192Z" }, - { url = "https://files.pythonhosted.org/packages/d4/b9/819ec2dcdf69031d7e9cab11247f3a6ff9bbc8c7c53ada1dbcb9055b227b/websockets-17.0.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d7d72843691f50b91127c50688df10cb72ec6f4c4b1d7e2c11ab33b16acf8e51", size = 219925, upload-time = "2026-07-31T11:30:42.524Z" }, - { url = "https://files.pythonhosted.org/packages/85/b9/6c0da301f6118502e079cf92f4e864adf28e56b3f8c0f6085076ced7b876/websockets-17.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90973a3a00f23afdfd1c9b06fb84289bf0220f247ef8a62501a1967c7af54f7b", size = 220218, upload-time = "2026-07-31T11:30:44.04Z" }, - { url = "https://files.pythonhosted.org/packages/55/f9/cba32dc9dd856565263d6272f594255bd0e2781deb8cd982c026a54760ad/websockets-17.0.1-cp314-cp314-win32.whl", hash = "sha256:599b03beb77633bffc095334338fad79cafc2b01fbd58953838130a9ae967d7b", size = 212626, upload-time = "2026-07-31T11:30:45.579Z" }, - { url = "https://files.pythonhosted.org/packages/fa/95/91cdd8c192287d7ea741f37cf7d64fdc1a14410f06f73805e428a1a590af/websockets-17.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:81ce19c6046ace11da7001781be7317bb1dc389f399af4b2ed962190f76f9add", size = 212969, upload-time = "2026-07-31T11:30:46.983Z" }, - { url = "https://files.pythonhosted.org/packages/8e/fd/8c98a1e431960661c5769ab1a4dd66494e87ab02d791cc79e51e0d9a289f/websockets-17.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:efe0ae052a8d023b87198921e8a7ce1dc7768816bcd2fbc20df171ac73a04891", size = 212850, upload-time = "2026-07-31T11:30:48.304Z" }, - { url = "https://files.pythonhosted.org/packages/13/c1/142f5186ee7dc3beee0426b998a79e223e067b7689afcaa95890d64aa800/websockets-17.0.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ab56439c9f74c52770690c7b2f616b3bf775cb3920453ee355ac765c032d8bbf", size = 212967, upload-time = "2026-07-31T11:30:49.688Z" }, - { url = "https://files.pythonhosted.org/packages/02/de/4b03ed316c9dee180365286c298219809ff247be39beeaaf9958b21167ab/websockets-17.0.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:20a92f78ac8250984ed459faa9ca48c285adbfc0038ddc3fdac6046990a9c9ed", size = 210504, upload-time = "2026-07-31T11:30:50.987Z" }, - { url = "https://files.pythonhosted.org/packages/cd/d8/ad2b3e8f867e1e8cac3077e2f33ffb60b71bd763d6cfc71bd916f113c3bf/websockets-17.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6a434e59962a4fb9016bea327e1d14d6cd67670ecfb8942b4f4a0c24036634ce", size = 210702, upload-time = "2026-07-31T11:30:52.261Z" }, - { url = "https://files.pythonhosted.org/packages/48/18/7a77a82ce9d6f831c07b176da3942f7e71acd0f115f3ecdb1d00a040eb01/websockets-17.0.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2503c7e2a5049a12d5dac917a46d5d52591283a766165b8176bb167560421b38", size = 220290, upload-time = "2026-07-31T11:30:53.582Z" }, - { url = "https://files.pythonhosted.org/packages/f5/af/43c3e3c3ea7ba4693c2181743f3221957df28bededadcbd9fc8a0661bde0/websockets-17.0.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:28012a54510fe8301bb893ef143cec30a2780a2d3bc20b7bbdf4379d7a63945d", size = 220573, upload-time = "2026-07-31T11:30:54.966Z" }, - { url = "https://files.pythonhosted.org/packages/1f/bd/ed48eca15725743ee7e2dc172e15c61de29e85ec98dace7b14257f366836/websockets-17.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22bd00f8bae2bccdb5dbe41e20f58ba44ca9fff0b4b561aaf39099c35da762ed", size = 221747, upload-time = "2026-07-31T11:30:56.762Z" }, - { url = "https://files.pythonhosted.org/packages/99/50/838deb7937a8225c4925dd4a977eafea473fabf444178a99de0bc7e92bb0/websockets-17.0.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e98ec9ec61cce5bc4b8b218322ad090b0994eb060bb04da704c62ef0a3d864e6", size = 223891, upload-time = "2026-07-31T11:30:58.127Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e9/657fb70c6eb6bcd01adfa5d2b06496e9911e1c1a8813d353b8c00f7591cd/websockets-17.0.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e387adb0c692c6b5571bdeafc8ac9d1901ea30f10309134780b16ecd35e6605", size = 222317, upload-time = "2026-07-31T11:30:59.416Z" }, - { url = "https://files.pythonhosted.org/packages/07/4c/82cb722afa5428fed981331210c4c07600570db01bb1620579f655b5adaf/websockets-17.0.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd1470d2c53fe53269bf5619da7725d30dd9b9693f1689f7a85eab8dea734442", size = 221047, upload-time = "2026-07-31T11:31:00.757Z" }, - { url = "https://files.pythonhosted.org/packages/90/84/bd6d67d6bc65f0de0cb50de55dab42f256a9876a351c4736522eb168fda0/websockets-17.0.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:884af729b8ab50486acd94d9768c2b60914bf39b579ebba0a5cb73bfdfd61fd2", size = 218626, upload-time = "2026-07-31T11:31:02.48Z" }, - { url = "https://files.pythonhosted.org/packages/96/cb/6a372c8553976f0d8f97f5115826ed47e34b3be6b8bf0d0249af249a7416/websockets-17.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a60fa1a25cca1bcc2bf87b8d6be37a741f0a3239fb5e9cfb7a37173b68ffcf87", size = 221299, upload-time = "2026-07-31T11:31:03.795Z" }, - { url = "https://files.pythonhosted.org/packages/bc/31/f966e8472337974f74d788b3ef6c6f3b8b9a5f201efd16a843c91d269fa5/websockets-17.0.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:e8208f2729cba030ff872a92064c97584eeb9502f53d32a05a0f05d5a17ca6c6", size = 219789, upload-time = "2026-07-31T11:31:05.08Z" }, - { url = "https://files.pythonhosted.org/packages/57/34/404e83a6cc7b0efcac810b7041bffd72ff76900e6fd0aa45a26c92fb2ffe/websockets-17.0.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:54cdcaa56f5d3eafd57058f0fa4a3de93a310b43a3c4699f06efc4c0bd054a5a", size = 220678, upload-time = "2026-07-31T11:31:06.635Z" }, - { url = "https://files.pythonhosted.org/packages/e5/70/8946188c2a68d67251859b589a3634918cf7867bf0b891347a5ecaa43d30/websockets-17.0.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4d41c0a1d47a478bc432b3b9068097bee1ce0c5b19327ea6f75c2ab34ab1f2fb", size = 221697, upload-time = "2026-07-31T11:31:08.023Z" }, - { url = "https://files.pythonhosted.org/packages/04/16/ee73fc2083a2938ac6209f4ec804960496835b20a0068dbcfe8424957c04/websockets-17.0.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f991247276797d0c61ab7770bc9791eadc16f683b4d83517f624932adc1a8bab", size = 219390, upload-time = "2026-07-31T11:31:09.378Z" }, - { url = "https://files.pythonhosted.org/packages/94/6a/d5f88033c69932af6cdaa72da62516ade47c257e3bf69f4c0ba5f40e12a2/websockets-17.0.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:733e3cc7171fa1b899edbe725ef9382d0e960657dc1fd933f3281ae910c01dab", size = 220161, upload-time = "2026-07-31T11:31:10.915Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2e/6183dd2c0370287ecf4afe0bb33aca364208e5e7b0e1a286adcaecc0c78b/websockets-17.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:810cb3fb5fa6e447216f4e82d9a85cb8aed0929ae3538153ddfe8a6e3121a58d", size = 220591, upload-time = "2026-07-31T11:31:12.289Z" }, - { url = "https://files.pythonhosted.org/packages/26/93/70f6516d85b9744f7eac224c4b1b9ef4e84133f80b53be02080cb1c3e663/websockets-17.0.1-cp314-cp314t-win32.whl", hash = "sha256:17ac37716c0244e82c9e384c41653c090b1864c6610224ca3857e7f7b58fce10", size = 212755, upload-time = "2026-07-31T11:31:13.883Z" }, - { url = "https://files.pythonhosted.org/packages/f1/2c/9d9c1da5a7ea9af307b386d25f64d1dead4729644198d2b92e36db5dfd41/websockets-17.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bb31f42ea095ea826463c770829aa188a86c9a5c976b1467cbbf583c811de833", size = 213094, upload-time = "2026-07-31T11:31:15.367Z" }, - { url = "https://files.pythonhosted.org/packages/5b/24/a585e7573e128070605d003b5544729bcd58d9756c7e99d550818ca4b916/websockets-17.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dbfae8e75b342e31fc6fd1a8bbb393b7cbb91d6cfd581650300a94381e7b7e2b", size = 213009, upload-time = "2026-07-31T11:31:16.776Z" }, - { url = "https://files.pythonhosted.org/packages/51/77/63b4abd29f15107d856f010de6f35434faa7c49ef89151e051d2807a9c40/websockets-17.0.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:49266e4488309b38783257293a38298942b9a03aa106fcb45195377a77c0c1e2", size = 210194, upload-time = "2026-07-31T11:31:18.046Z" }, - { url = "https://files.pythonhosted.org/packages/ef/ab/6160542ee644f72b865af13ddfff23740c95595b237e16312262cafdb641/websockets-17.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d69fd559f9f0e8a52d2fce6f04ee143f86e70df0a189cd95164eddac599e810f", size = 210465, upload-time = "2026-07-31T11:31:19.333Z" }, - { url = "https://files.pythonhosted.org/packages/53/1a/d4437d3cb0691eeac2c6064e21c83a9eeda04f6ef0261abfc0dde708590d/websockets-17.0.1-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a39ce3a7b0e6059be093213d637963101380157bcbad355916738fafb490698d", size = 211417, upload-time = "2026-07-31T11:31:20.687Z" }, - { url = "https://files.pythonhosted.org/packages/88/28/2d671e23a20359a1cc142848384659813b49028d46cb0acdc28e81ac59b5/websockets-17.0.1-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2437d4ca208cc0f246d3a2297ae7474b4ba18261aaf5b9c79c84c031ecf348e1", size = 211309, upload-time = "2026-07-31T11:31:21.969Z" }, - { url = "https://files.pythonhosted.org/packages/02/52/b85a676b161991e5c0d884252376435f60510789e7740e3da73489d22a6e/websockets-17.0.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6740be6d1bab69f08ab52cb15b08f76c143b6fe61c580ba62bd929f3ab7a1d42", size = 212203, upload-time = "2026-07-31T11:31:23.38Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e3/91e297e41381d9131f3142a9c1a50389fd96b98125ce9b81526fa4e14b9f/websockets-17.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:afbce6e3f0fac32dc87c2a0d84869d1a706460d64f39f3889386413e6e4d3d26", size = 213434, upload-time = "2026-07-31T11:31:24.707Z" }, - { url = "https://files.pythonhosted.org/packages/09/ce/3929538b2b9918f5eee623fbf3346893973191f6df93f19bbda097bd7bb7/websockets-17.0.1-py3-none-any.whl", hash = "sha256:c6be9cba65c65cc76dfa3d4619e359ff02a4476c74e179b215236c11a0b32345", size = 206718, upload-time = "2026-07-31T11:31:26.037Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/da/ea/c0f7924f7ccf005d6ad1f829971762ae751727497d6db1977ba5a635314f/websockets-17.0.tar.gz", hash = "sha256:6bbe83c4ef52a7533d2d8c6a3512b93722fd0db6bc6bc638d45edd49ef201444", size = 183456, upload-time = "2026-07-29T18:07:16.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/66/1fa9cd9c0e2e77f74c5b9391f5e154b939efbf9695eb5e5bb72e1d993669/websockets-17.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ddd0444e942d1f42ea2ab5c38f6f9dddfd6782a5bda0a29e210b414dda7e3636", size = 212719, upload-time = "2026-07-29T18:04:23.164Z" }, + { url = "https://files.pythonhosted.org/packages/1b/63/43d85076ba399257685c79726309c1367c9d6a133ef620b8fe1d166d7324/websockets-17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f1edeb9d17bbd4e5bb45c230fc77cd140e4b445d6daaf395910c72aa703e3606", size = 210403, upload-time = "2026-07-29T18:04:24.885Z" }, + { url = "https://files.pythonhosted.org/packages/7f/75/b98ec2482ac7f82c6a098d0350ed6d206032944230d8a18284c700fb2455/websockets-17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37f79808bf93a97c040ccb4dbee77ea1527d0fc3656077001428409866a06784", size = 210681, upload-time = "2026-07-29T18:04:26.675Z" }, + { url = "https://files.pythonhosted.org/packages/a6/8d/6d37513adec534af9ed1f3f990be3e42aab2ec062d4730b24f01dc85d8f9/websockets-17.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd1b0bdb6f6692baad8dbc366886c9ecd167862ccfce4d227cd05f6ef26698d7", size = 219745, upload-time = "2026-07-29T18:04:28.085Z" }, + { url = "https://files.pythonhosted.org/packages/0e/51/a083d572986f8532369e8a376452bfdbb403899e7ac18c4982a05ee8123b/websockets-17.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cf609755e58e3eee3f105dac839d5a57687d67ade20752b4459402a96fe1c216", size = 220018, upload-time = "2026-07-29T18:04:29.489Z" }, + { url = "https://files.pythonhosted.org/packages/ec/fc/399ff59d88a6378f1f6a291676c0c0b0bd287617584ab49968ed91c05f90/websockets-17.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65fc0f621c801762ad16f95f6728c2498b4a2a9244938635d79e72234887cd1c", size = 221252, upload-time = "2026-07-29T18:04:31.04Z" }, + { url = "https://files.pythonhosted.org/packages/e8/67/eb0c001332545a7616c6f32110c11a46185e3df305e507cdc3970f1a3807/websockets-17.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cf2a17a24719b3666130cc42f4c22c5f067c94d78981a2895b5782687ac91978", size = 224544, upload-time = "2026-07-29T18:04:32.658Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/348ead1b20ddac653797f7a3681395189e8d2d6815844d6ef845e1d46dd8/websockets-17.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90aba12b1e2e9b79c6f7a56fbd16bcbbeab23ef51c11122b346f5cc4cfd9b10d", size = 221814, upload-time = "2026-07-29T18:04:34.239Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8f/22a9185f219cd21583ad1d7292061a867af03f9c3cb76b24ff8532efacb9/websockets-17.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ef569c690e1a7de6b218c1a8fba5a5b8560d6d141fa76e0e865e1c98fa4b140", size = 220586, upload-time = "2026-07-29T18:04:35.625Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ce/fbf20ff14a52e03ec76a706d2e768d9b0e6dd5f20bccafc214df854b89eb/websockets-17.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb6a5c404a3982c1ea834a758558c0b13f4917c78658a6e87eb728fb268b0f4c", size = 217880, upload-time = "2026-07-29T18:04:37.092Z" }, + { url = "https://files.pythonhosted.org/packages/d4/2b/8663a96e9765074a9d76fb3dc336d7d3d51eef19866248b374f01fd24a49/websockets-17.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b3c20b64398f0a0ce4a8b7caf6988e738de3eda2d7049e42ce655c137cc987d9", size = 220741, upload-time = "2026-07-29T18:04:38.588Z" }, + { url = "https://files.pythonhosted.org/packages/e2/37/6e37383539995c3cb2924af89541c771b85158930e6ce5fd059b0bf37a39/websockets-17.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:7018d5c1a0e161237aa52e282aaf2364daf45f0b792b212f6d3c1bc85a03ae36", size = 219332, upload-time = "2026-07-29T18:04:40.045Z" }, + { url = "https://files.pythonhosted.org/packages/b1/57/d5d42031a3ee438018ad3874f52104ea1144caa9edc455871d90fc3d9a1e/websockets-17.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e8e4545866fe949e932e0a895471b06d2784c6e0fcd35b3c7da02d7600d766f7", size = 220100, upload-time = "2026-07-29T18:04:41.495Z" }, + { url = "https://files.pythonhosted.org/packages/da/71/4763704b3b80757ed926d8d0cc06542e90a9e41aebd379324c950fcafc4f/websockets-17.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a9273bc1a7441ffd7a0bb63cf21cbe56bc046744cc4df24df060fe6806fb1c81", size = 221145, upload-time = "2026-07-29T18:04:43.094Z" }, + { url = "https://files.pythonhosted.org/packages/fe/96/12bd7d70842c2a4f4894d2905ddcd7078509e468a78c8efeada2836db0d8/websockets-17.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:95143a62308b1d2b81157ea8ebce502a8b07087f6c47226175f23a5e2358c09e", size = 218724, upload-time = "2026-07-29T18:04:44.873Z" }, + { url = "https://files.pythonhosted.org/packages/14/6b/d8ff625ac0c6fdba6cf1eb0d884aa618db864aacba992fceaafd977c9a53/websockets-17.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6ad3fad2a03731b788d7003e2f7603772a1cbe701a840a6acaa8305b7605bfbf", size = 219757, upload-time = "2026-07-29T18:04:46.415Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6d/c4fb9895b1e57e548b60905a40b9e8dba4098b32bfae54c3a415512ad777/websockets-17.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e0aec4d4fc61ce7a24912026be07a6329a5d7b8c9012c45b573ab78878fe4e41", size = 219993, upload-time = "2026-07-29T18:04:47.947Z" }, + { url = "https://files.pythonhosted.org/packages/d2/a9/8cb56af6c9d123a7f1b61694d1c5405a3742bb89108bbdfb3255fd0d9b11/websockets-17.0-cp311-cp311-win32.whl", hash = "sha256:577be42e4cbe01cfbaf322b7a4998c0a0124d11582d34774f7226911a35c32bd", size = 213202, upload-time = "2026-07-29T18:04:49.495Z" }, + { url = "https://files.pythonhosted.org/packages/0f/42/0987257ab1ffce8492800c409106a3c2b4d247d6f93023a0f5de9f33680a/websockets-17.0-cp311-cp311-win_amd64.whl", hash = "sha256:d2f9829d91acf2863c1fb97e39095f5423b5f704fb1e478379ccc27a0c58df0c", size = 213499, upload-time = "2026-07-29T18:04:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/fa/70/3a62e87a178317739dba28f870c329e1cd34ee6ba051f3c936f7582d5c9b/websockets-17.0-cp311-cp311-win_arm64.whl", hash = "sha256:525488db5030b4c9bb03328269ab803a6f43a2232fc12e67c3a6b5c422ea96e3", size = 213430, upload-time = "2026-07-29T18:04:52.297Z" }, + { url = "https://files.pythonhosted.org/packages/b6/e3/e4f27930a556ea4039487415ed7100ce96d607b29dfc65ac309168695ba4/websockets-17.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:6312d9926196483550c0ad83459595dd02dd816fa0523ec91dac5601b35de2da", size = 212744, upload-time = "2026-07-29T18:04:54.041Z" }, + { url = "https://files.pythonhosted.org/packages/e6/14/2bcbc1805f1b42b94fa6fc81e7a0d1ffc1029d938cf9ce4b8e3a48875116/websockets-17.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12a21ef5e185f9e0c1c9ad23649aca411b04e49e030287f0a47b889d9e1724a9", size = 210425, upload-time = "2026-07-29T18:04:55.613Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9d/a88e66b7b8581f433b990f20738045093bfc15dd3b8b939980daf793121d/websockets-17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e219be64a9dff86d33b3314ecc6c42289a2d8a447821931012f874b2cc3c70a9", size = 210692, upload-time = "2026-07-29T18:04:56.944Z" }, + { url = "https://files.pythonhosted.org/packages/e3/8e/f8565de07cb99b9e9f21a6932ce87d28cd65e06bf8b9e6cfc795d7fb12ea/websockets-17.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:98e4882f2f37b4efa7e1c41eb97db1e86384b6252135ab8f5794656cb3bec1ae", size = 220018, upload-time = "2026-07-29T18:04:58.304Z" }, + { url = "https://files.pythonhosted.org/packages/be/7c/883fddde356c9366bbb1abc9a16d02e20515aadb89de3364c5dd7b9cc360/websockets-17.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:abfa93514d5d7fe50988c4b6092585da0e9a737c1063530cf62fecfe93f7acf0", size = 220295, upload-time = "2026-07-29T18:04:59.958Z" }, + { url = "https://files.pythonhosted.org/packages/9a/18/2b2c71d158206b759e79a2e606ad057a3e3f01e05353a676081417ea9bc2/websockets-17.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aacbf208ef605c463e5cc888d26e25b68732baa171990339c1b4e2880f7b60dd", size = 221533, upload-time = "2026-07-29T18:05:01.734Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/d58c3f516dcfed9d98804fa25c679958df32286bfabd6029dabeec5f1ce7/websockets-17.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fdea04f18e814a15ef115356392624f8a694f29bb6b8ed65828a6d53eeb96654", size = 224312, upload-time = "2026-07-29T18:05:03.166Z" }, + { url = "https://files.pythonhosted.org/packages/77/49/33946a85a09638f046c2db6506fe53aee35f71fcef9347d343ce668c9cb5/websockets-17.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d7c3b3c1fda46b2d40d57503278755f3ad47f09eec57c4f6145cd80f1c8beecf", size = 222169, upload-time = "2026-07-29T18:05:04.635Z" }, + { url = "https://files.pythonhosted.org/packages/61/e3/e2441326cd2132b4861ff1a0b03671dedacdca6e7996e913137ec1b4ad26/websockets-17.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da8b74ac47a129bcb82f40aab234ead2d31ed20566e6e75d1929ac4d61f22a55", size = 220924, upload-time = "2026-07-29T18:05:06.252Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ee/ae47d5aace0b71c7e038d00f1651086cd32fa44190f179182c58a6c5b795/websockets-17.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6e43040c1f6b0e0fced4a3020693f32914e4d57605be63da30c197bfa118c6d7", size = 218171, upload-time = "2026-07-29T18:05:07.655Z" }, + { url = "https://files.pythonhosted.org/packages/6a/99/2872777a8d96c4bc546bc79a22acd7db57aa2acddcbd3527c83515c7d789/websockets-17.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8ddfc7ae598004778e8e092580aafec16ae9f8f16ebf0c178bb76292db6e8dd", size = 220970, upload-time = "2026-07-29T18:05:09.071Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8e/64472cc08da2e6ed2ee40c372abfe090e7d368965aa861dc32382aba051d/websockets-17.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:180837e1f4f82fb4779fe4561d246a55028d01f7f41c4a00b24117804d382f14", size = 219572, upload-time = "2026-07-29T18:05:10.548Z" }, + { url = "https://files.pythonhosted.org/packages/a9/df/61c12777165b02a578e4a0055ccbcb48bad92f3ae4373b2bb449a28ceebf/websockets-17.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4736675b7079a09b04558f1e5613dacb71165ff9868b7dd01c2488159ca5c089", size = 220342, upload-time = "2026-07-29T18:05:12.006Z" }, + { url = "https://files.pythonhosted.org/packages/58/bc/e6e60c01b6100ac9f9a1afd3391a5f3e0c72eee536429d001c4be3af7004/websockets-17.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1922e2124f7eb7ca7ba203973a0b8b3f598447efe6937feaf63fbb1775341eb8", size = 221450, upload-time = "2026-07-29T18:05:13.436Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d3/64cb3002bbb6ee592591f668a2c802deccc183fbf5a41071145bdb133d57/websockets-17.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a3cfb0ea471e325b596e9259d2f35f3040ecd1896e2d608649f25748929febc0", size = 219002, upload-time = "2026-07-29T18:05:14.894Z" }, + { url = "https://files.pythonhosted.org/packages/55/08/0877015b5b252d83c7f441023e11293fd0d0be9dc05c792c5f91712c8eec/websockets-17.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1a44cbbf2ab144f1ce5268c1dc4a541e9ed0cd35a892d38a9a52e3d01456cbf7", size = 219983, upload-time = "2026-07-29T18:05:16.539Z" }, + { url = "https://files.pythonhosted.org/packages/57/f8/271327f8fa4c07326ba9c79c9daea81e4c043029c6df48bbddfb0bf46649/websockets-17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3c59f7a03967dcdb490098a7e684b1e691f8032835f8176d9cb3cbc654773381", size = 220259, upload-time = "2026-07-29T18:05:18.217Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8b/31e77872bc730124acd9e0af977667b9805c4450519e9bd220e4450f4749/websockets-17.0-cp312-cp312-win32.whl", hash = "sha256:67e3de3a5abbea437cd73505a2220a3fa37b3e38b68c7dd410de6fadb9492dc5", size = 213205, upload-time = "2026-07-29T18:05:19.575Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/5a5706da118fe90038a529ca43557092c1f5665876b00570d777bd19cfff/websockets-17.0-cp312-cp312-win_amd64.whl", hash = "sha256:5f7cef3e552397fc4313b1caf4fe1fabf53dfde4e4153aa1a74d73b5a246794b", size = 213502, upload-time = "2026-07-29T18:05:21.023Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d9/fd6d3c80f548dbae84687f9c50b26407707e63d624ba2edc6736c0aa68fc/websockets-17.0-cp312-cp312-win_arm64.whl", hash = "sha256:499e8536471f07de659bc3f003f1fcef60da953de8ffc26d01253828f6b0a003", size = 213430, upload-time = "2026-07-29T18:05:22.369Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/ad36c2cd987b89447e2216d19355306eb9a66a9ce4fbcfb22924ade347a1/websockets-17.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:29a24b93f223c701053db3e07416769f64ac69bc2204131d286ca9e309f78012", size = 212738, upload-time = "2026-07-29T18:05:23.902Z" }, + { url = "https://files.pythonhosted.org/packages/26/03/c89dc12a6fd49948b2aa0cda77765859c1310f6ec2ad50fc43d15851fa7a/websockets-17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:005d06fe6af0071625a41c231848342da013709738cae9c22031d396b85fa875", size = 210420, upload-time = "2026-07-29T18:05:25.362Z" }, + { url = "https://files.pythonhosted.org/packages/27/df/9fdf5fd50ab0b9db8fdd4037d54064703f5f99a8c34c995b3d25a8099c65/websockets-17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1feba08ed3370fad0efc1295b5b314115b920b8014d1fc20d3535dada44c155", size = 210682, upload-time = "2026-07-29T18:05:27.029Z" }, + { url = "https://files.pythonhosted.org/packages/6e/71/e56676f18dc9b906018aa8e9e106080edb81240df12672a71b2a0273677f/websockets-17.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8d8b6160b46996d2821659ae6fcf9aa20b2641bc7a08972b15308c65b0764295", size = 220067, upload-time = "2026-07-29T18:05:28.481Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a5/0d742c23f1ba6e60c5cb0fd402f89a5faeeee3c23c8dffcc3308125b124c/websockets-17.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ce75f71335f3d682d37ff7464d1e1c20a065794108087ddcf3404aa03ba91295", size = 220352, upload-time = "2026-07-29T18:05:29.982Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c9/43201b9fbc5c58f89e0bee12c14a67d847a453449d8ba95f29adab128855/websockets-17.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d5721fc96349667b623d6e1209f3c111667946d346715023013b11681d8d37b", size = 221589, upload-time = "2026-07-29T18:05:31.394Z" }, + { url = "https://files.pythonhosted.org/packages/76/37/c226a8bf87376165fe15e0fa2ab1557433463ed279a9e17e899c77cb307e/websockets-17.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:dd09cacb19f2e6d7e01c9e8d870ab40e4d4b1d59508646e74cdb963bbb73730a", size = 223030, upload-time = "2026-07-29T18:05:32.868Z" }, + { url = "https://files.pythonhosted.org/packages/96/e8/b7b7cad3d1bfff2c60c51bd64a3e29f48c988b1e7f1731fe9e89b09dcfa5/websockets-17.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d599bf4fab7e1bc1c009a966c8ded26c97cb8983410ab6d404f21b2e750557c9", size = 222216, upload-time = "2026-07-29T18:05:34.512Z" }, + { url = "https://files.pythonhosted.org/packages/09/ca/6b1dab07811b26bd79b85788aaf1d14acdeb2bc0252d2e18999e46e9f834/websockets-17.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:46a13ca29de8d60ef9cc6cba58e9c4e65a19a0cf25140576285f561f23827044", size = 220971, upload-time = "2026-07-29T18:05:36.021Z" }, + { url = "https://files.pythonhosted.org/packages/95/25/7943eeb82ba8f323f36c0b52f471ea012b563af1e50bfe15230fd973ac7d/websockets-17.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c153840709258daef58a13a0e4cf78b5d838d5b15261de0d49f6ec1fd2538d44", size = 218227, upload-time = "2026-07-29T18:05:37.582Z" }, + { url = "https://files.pythonhosted.org/packages/7c/39/a88e72a5b8ff80e4f7c1c5ddb335d64432650252a5856935fc6fe3065869/websockets-17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63609c513bc5f8757e8ecb0eb788afc54825807cf151216ce7d3359576899b70", size = 221034, upload-time = "2026-07-29T18:05:39.347Z" }, + { url = "https://files.pythonhosted.org/packages/4a/14/a8bfd634a5dad970a946aca76de7c9e8e717b8f9960e290d20b6f21d5931/websockets-17.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:e2b977c946503cd3182a7f7cf3d18255d682580400cf4ecdeeccad435b5d2bfe", size = 219632, upload-time = "2026-07-29T18:05:41.066Z" }, + { url = "https://files.pythonhosted.org/packages/19/c9/9cfca56b5a216b001c9d3dd2351f2e3af7b967473b89df7aae656d61e048/websockets-17.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5668320cde66fa7737a26e894fda39e0ad76d4edf96832650cab84370c561ad0", size = 220401, upload-time = "2026-07-29T18:05:43.589Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2f75906e489049cd3420c46511054f95fb063a54dcf99483cc063e14a713/websockets-17.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9a2e5e26e649b0786b8e696c41a8a3147a4c68c79fe6e0b1f07bbefeba054d56", size = 221503, upload-time = "2026-07-29T18:05:45.1Z" }, + { url = "https://files.pythonhosted.org/packages/3f/04/8d95434937e1fbaa0fee8bcf764867e9ccf8d42abb8a159c2681dd68a112/websockets-17.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42fec6309ac1c20e45982460321468858f2b2cbc66d1919cfa04663e0aaaefcb", size = 219063, upload-time = "2026-07-29T18:05:46.57Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ee/3217cee93eaccf717c291d678a0594a5388555b024b9f46b0555fc25a812/websockets-17.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d648a61bfd3e2f3be8643a27eded0c7fe4e178670ee1534061f1235f2c857be1", size = 220017, upload-time = "2026-07-29T18:05:48.074Z" }, + { url = "https://files.pythonhosted.org/packages/3f/9d/bf0c9c0905b3b6e4eaf9cdf37361d38c2707815baf6c0bbf69fc873ddb76/websockets-17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d802fd1ff5d1e1773d815c5fee634b9e94e9829afb4fdbcfc8dab39c648095d", size = 220299, upload-time = "2026-07-29T18:05:49.549Z" }, + { url = "https://files.pythonhosted.org/packages/b9/03/33fe4e800d3bc72101cff3c148de55ac73eb51bbae142e6aafaf835901cf/websockets-17.0-cp313-cp313-win32.whl", hash = "sha256:c2786b3cc77a84afa612c2c60fc20c22b576ec46e7ae1e79cc14ad43cd1ed05a", size = 213194, upload-time = "2026-07-29T18:05:51.085Z" }, + { url = "https://files.pythonhosted.org/packages/bd/18/6c358b4611ce7a1c438bcb6cf7dbe9be32993c1c785d1a9cef495ab34e6e/websockets-17.0-cp313-cp313-win_amd64.whl", hash = "sha256:aa9b082460c6775f98179aa78d9186ff68ad69eca8edd30c816e689190e1bf6b", size = 213503, upload-time = "2026-07-29T18:05:52.581Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d0/e51d30d7a9b1ecb3135871b4faece90bee14cf0c754881583e3a5b9a30a1/websockets-17.0-cp313-cp313-win_arm64.whl", hash = "sha256:169412f60a48be88350dc5e89a446de89c11d2c6f6a9c62b6ab796e1b490d7d8", size = 213435, upload-time = "2026-07-29T18:05:54.3Z" }, + { url = "https://files.pythonhosted.org/packages/df/6c/ff0c7950af50bae08ce0ae68bbf3fe72710851566693a709231cea9f3fd4/websockets-17.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:94bbd0c509cdbc2cfd245cc5442b2bb6f2a9df6e60a0d9e4f9d1b1926e30dbbd", size = 212783, upload-time = "2026-07-29T18:05:55.773Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4f/1a4f4129c9a8827559eacb4769b78bd856080cf84b8e7c09ae721802f65e/websockets-17.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:bb43ca37efbc140e1e6f1acf8acf7e85569f48fad588ce95e7f8bc723ec506c8", size = 210471, upload-time = "2026-07-29T18:05:57.255Z" }, + { url = "https://files.pythonhosted.org/packages/4e/34/a086c3caf087cc6a3965a09835c856c8e5a870bb611e1ccf6d73f73494aa/websockets-17.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b0958c062f61b05ebc226d4fc8ccf8a10cbd109db06c745a91fee6218fea77e9", size = 210690, upload-time = "2026-07-29T18:05:58.746Z" }, + { url = "https://files.pythonhosted.org/packages/34/2d/0cb31555e1a22c82e1a72e87db1c158a9ef5b71edc16dc30b43ecd60d1de/websockets-17.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:95f3bfa818c458ea6caf5420cd4b9b487b3a61e411fd55e2d5848aa553da15ea", size = 220071, upload-time = "2026-07-29T18:06:00.199Z" }, + { url = "https://files.pythonhosted.org/packages/39/9a/c231a7395aaea78179b660ff06337608db2114cf0e8c172b6e13234459b9/websockets-17.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8ce14ded954d5fdf3a173d951f1a17cfa40456f8cb4289fdc5ed49348351b7a7", size = 220423, upload-time = "2026-07-29T18:06:01.729Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e4/5a61bc45103267ac116c646f632532b31a12b64392b91c8d63cbf0f6845f/websockets-17.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbfb30a6123a2851cb4a4cacc468dabf8d9f335f63f6cd8dd1a23be7c315979e", size = 221669, upload-time = "2026-07-29T18:06:03.385Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c0/f69a14158ac5d2ef47ce435fb25c72ab95f8483db7def5c11d1732f9b108/websockets-17.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ce99ec8fe4509021bffcdd473651ddfe9064ed142ec83f84eec1c2bf2fe6ad37", size = 223041, upload-time = "2026-07-29T18:06:05.131Z" }, + { url = "https://files.pythonhosted.org/packages/6d/22/e24745306baa56abafeaae99975f8dfe4e531f07a198da741ffbf8dcb662/websockets-17.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3bf6df721d343cf628bce98ca23fa36a7b374c9a022f37bbb55a200a242e4afe", size = 222273, upload-time = "2026-07-29T18:06:06.736Z" }, + { url = "https://files.pythonhosted.org/packages/68/1c/ab93e8018e3102268082c5ccb14f7f77795173c918f023cd01d764790ab7/websockets-17.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3c5c1ddd419ae6f61b8f26ea3577f8f6b75c90bfee563cd2feedf773414cea5a", size = 221019, upload-time = "2026-07-29T18:06:08.222Z" }, + { url = "https://files.pythonhosted.org/packages/ae/07/11414c237d046204de8fca6a1ec4cfffe152c3c5c0fed537cfb88b641226/websockets-17.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c9ed428a473c0d54bb8d60d76928a88fc7cbad8581e60996005185c28b755cf2", size = 218280, upload-time = "2026-07-29T18:06:09.756Z" }, + { url = "https://files.pythonhosted.org/packages/01/0b/fc29062bd253ffc0e19279afb7a85df76d0e84d9adb4bc07932138d52fc7/websockets-17.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:78c73aeaaad88633494a5d3e8aa6a2dbc28aad160cdcd99f29f4f2bb3d8842e8", size = 221095, upload-time = "2026-07-29T18:06:11.712Z" }, + { url = "https://files.pythonhosted.org/packages/b1/7b/5c1aaadd1d392a15a3637225128ad15e41f8c170c8c925323b61dc085bf9/websockets-17.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9c986364dfb39d10a1d06deee2552e89163d9642a9c9175a41bdc8e136ef89a6", size = 219606, upload-time = "2026-07-29T18:06:13.242Z" }, + { url = "https://files.pythonhosted.org/packages/c0/e0/108c722318f8e55570b9705b930d51a4b4ff1bd24d830059d8cbafbdc6b8/websockets-17.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:76431676743151e985ad9f8ae0ca4372ae3ca2e8462f9227ec9bcf6f8b84c762", size = 220392, upload-time = "2026-07-29T18:06:14.884Z" }, + { url = "https://files.pythonhosted.org/packages/71/0a/9ff02d0c71dcb2b3562fc81487e622dbe482e20a45959c37179dd428b3da/websockets-17.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:cfaadf6866cf62edab1c1b8bedf09b80255af90ec00b0eb0da55407d9ec8f260", size = 221564, upload-time = "2026-07-29T18:06:16.434Z" }, + { url = "https://files.pythonhosted.org/packages/25/7f/d3a12c95e509a612d79efa78be50d94663385b11b2345f70ae3b2f210386/websockets-17.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:954b80f73046bc79b694c8c13d7f4429da149183ed45f171008f780048a37f6d", size = 219119, upload-time = "2026-07-29T18:06:17.99Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1b/e33c4027444df9b279807feb87d9312f7ca5fea09e103e53fce21e307ed0/websockets-17.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:a973940286a570d22a6b65b5531ab6e0d6e4485379bcfc11d239a4ab14f28392", size = 220069, upload-time = "2026-07-29T18:06:19.465Z" }, + { url = "https://files.pythonhosted.org/packages/0e/81/6a65d5971b7e328cdb6d503bde0b4063bfea7caab8acfb7837b2876e2fc5/websockets-17.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3c60792e8a1004cc1aba943c4671d35432f903bc57ff338092de4e4062b4a4f3", size = 220363, upload-time = "2026-07-29T18:06:20.928Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b0/2d47c5004c696dc749de93fd1af5730b296a619454efbaf8520bbe65962e/websockets-17.0-cp314-cp314-win32.whl", hash = "sha256:19ef9a3d55b8176ba6b71b6eb11373ccaa2b674162ced5c7ee26dc90d912fbcc", size = 212734, upload-time = "2026-07-29T18:06:22.533Z" }, + { url = "https://files.pythonhosted.org/packages/f3/94/3d3e1c0016f2938ca026172df97f4a84f6d546f422dc4b6cf07ebdbd1a17/websockets-17.0-cp314-cp314-win_amd64.whl", hash = "sha256:bd902b19f9ff1e88dcf9939500dba8da791b8102da93deceafb696659c7c1f94", size = 213079, upload-time = "2026-07-29T18:06:24.554Z" }, + { url = "https://files.pythonhosted.org/packages/46/9d/3a24ef81d8e05beab88bc36d1ed2695ec59c91194fa40f47fbffbccfbbfa/websockets-17.0-cp314-cp314-win_arm64.whl", hash = "sha256:9a7acf1542a53350d4623c023e4944e5fe3bd9ee6b4385b86fd6287d8d549d81", size = 212958, upload-time = "2026-07-29T18:06:26.372Z" }, + { url = "https://files.pythonhosted.org/packages/9d/91/88c7e6b9f1acbe80643f9189c06c8084a6a81e3653fdbb7aafadaabcc4bf/websockets-17.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:583416c24586432ee8a745cca4727efc2d4682c453f69d79debacbde72863160", size = 213116, upload-time = "2026-07-29T18:06:28.095Z" }, + { url = "https://files.pythonhosted.org/packages/f6/3e/ade0e4181523b906fde2097813583a06c54360a38f3730eb86cf12843979/websockets-17.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:208ba355ab37f488b5d19b1c3a70240c88ffb9ce8407ff991f702e5781bbb5c4", size = 210650, upload-time = "2026-07-29T18:06:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/ff/00/75e805330de2413de10c80adb4e46d83b029a168434cb08f8b7a39733e1c/websockets-17.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ce88616250de9fa206c17a484d07ba2fdba94daefedfd7a8ffa689b0c5ec1fe7", size = 210847, upload-time = "2026-07-29T18:06:31.533Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/63db81708c3b688dfc7f66a9a35a0b09a818b78c6588d5b2745c481c9bbd/websockets-17.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:14a6c9aaed860f9cd1d3fb71b37b38a436b864f2e78ff605491f43da959227fb", size = 220434, upload-time = "2026-07-29T18:06:33.173Z" }, + { url = "https://files.pythonhosted.org/packages/07/cf/b98becac799a2bb4d5e9f197642f1bc82d586ab66314aafe459814cb2d44/websockets-17.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fc5b304b0100aabb46613e6c911fcbb959e5542fd94c89a1e5df704bf703c6ec", size = 220717, upload-time = "2026-07-29T18:06:34.854Z" }, + { url = "https://files.pythonhosted.org/packages/7e/56/c443f81b483de8f40e00cf41037a14ea4f32e1a67110d1be9293cb8980da/websockets-17.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ea0aaf55be94d587f2b895938434d24d809bd34762407a84de67a42cbfe9af61", size = 221891, upload-time = "2026-07-29T18:06:36.499Z" }, + { url = "https://files.pythonhosted.org/packages/ee/c5/97b101b5afef7c527d7f22484abc1f949447d5a6e55d50b78ce90745b8f0/websockets-17.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15452af52e7e536cd240c0da28605247d0629da828643f5e7d1fd119e7256197", size = 224033, upload-time = "2026-07-29T18:06:39Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8e/2a1e0f66aca3142ea244caa1f03af49616ff43f61a2ab8a60b8da40c6954/websockets-17.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:79bdaf80414d0c0bf86a016dc6fce803e1cde9046cd900298d74690109c5f118", size = 222462, upload-time = "2026-07-29T18:06:40.635Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/1538ef951aff7616dffaf7cc64cf64e1ddafddcd8ff0ee3d77aedc9c3ce8/websockets-17.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:51e89a46eb1b7c824e8dd85f2a4544503385af68d1017b4a42800523ac35382c", size = 221192, upload-time = "2026-07-29T18:06:42.453Z" }, + { url = "https://files.pythonhosted.org/packages/06/4c/27deb9b47b06fa891798a33c4ef1be5d02f8b3045c313798abb79f56510e/websockets-17.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a34089ead0fd516f4fa0ad4fedad445520f2144f1764d54b8cda07c466edfb49", size = 218746, upload-time = "2026-07-29T18:06:44.346Z" }, + { url = "https://files.pythonhosted.org/packages/e8/03/14ff4635d6afbf23724234e362354d58e128d2a67fea6de3bb9426ae3024/websockets-17.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c3874b45bb5d235c607c910c5721e2f7b3e7a47cc876e0c37108f55554820a69", size = 221443, upload-time = "2026-07-29T18:06:46.254Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1f/6fe2474ce511c604336b29b1798fe01d7688b24568736fbe4d4f09666742/websockets-17.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d306f1f15f06f879b43036fc4ece102630ca1d48d7cd2ff79f02fc66ae5db5e8", size = 219933, upload-time = "2026-07-29T18:06:48.018Z" }, + { url = "https://files.pythonhosted.org/packages/87/be/faba0fc471d3bab1d1d63f10e7ff7cba97d580af8f4b9219a6274b664c1a/websockets-17.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b853c76629b92576e905ca46249435f04ce41cffdda3df3aac378132b40a33ce", size = 220822, upload-time = "2026-07-29T18:06:49.663Z" }, + { url = "https://files.pythonhosted.org/packages/b6/92/5fc01c01d6cce63002329c6d4d3a7b2ac6f758b10e198065273a88d60461/websockets-17.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9d0d77ce8e8080daf411eaa0889b834ee1defd076e386e55a90a75f0187a2008", size = 221843, upload-time = "2026-07-29T18:06:51.241Z" }, + { url = "https://files.pythonhosted.org/packages/9c/18/dae84b24f45852ecfcd734e4a85550e639af1b58bc1f5214dcb1a7e58346/websockets-17.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:27a95b0d35c0f88da71adf52d263f7b6ed23914cd459477cf0b13d2b52a48d48", size = 219534, upload-time = "2026-07-29T18:06:52.86Z" }, + { url = "https://files.pythonhosted.org/packages/a9/1d/1efb52128dc311812127ea337b729a89a945be5d65a75a5dba1f3c4f1d7e/websockets-17.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:c3796b7fb9605dd9df50cd09091c0e9612d30707ba2bcf0371a3a4c5d25219c9", size = 220306, upload-time = "2026-07-29T18:06:54.698Z" }, + { url = "https://files.pythonhosted.org/packages/02/6f/06920cefcfd4adea34565e60b2c08eef0265e6a97e6743586fb9a088da8a/websockets-17.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:41435357c5e80b63085c8e26b8ab2c44963bdd9c4b131c5ef352d3c9107e8c78", size = 220735, upload-time = "2026-07-29T18:06:56.484Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e2/8ad920e410bc7b64f82ca697e31eb71dae995c28cb7761c5ce4a201e2be3/websockets-17.0-cp314-cp314t-win32.whl", hash = "sha256:ede2d4b60d4acc8a4c03b5392808c2b074e38c99b08bcbb45373f1459aef2934", size = 212865, upload-time = "2026-07-29T18:06:58.169Z" }, + { url = "https://files.pythonhosted.org/packages/56/23/286f283a0fbf64cb43dc15f53022c36e749dfae5e70ad1e58ea76813a656/websockets-17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:85849eff1a1a39caf82a73c853006e01eb9a080cb03ba9022a8d72839ac3d671", size = 213206, upload-time = "2026-07-29T18:06:59.816Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f0/b48652b29d781850d0f685f680935a7ae2b2a6d9668f6f4ad7876ef0684d/websockets-17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:8122f76dc4418fa7cb1cd015444871469e277ea845761169009ca4167835f6a8", size = 213122, upload-time = "2026-07-29T18:07:01.758Z" }, + { url = "https://files.pythonhosted.org/packages/28/d8/7879b3a9d00343f9574ffdf5b854419a33b5bae8a96a20b2583ef502e892/websockets-17.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cad3963bc9664468223b9e75734a04b1092e5e6947783d9162877c7be68091d2", size = 210337, upload-time = "2026-07-29T18:07:03.635Z" }, + { url = "https://files.pythonhosted.org/packages/44/aa/e38fe356c3cb92af10894e7e3affed5bd831af5d4ed7fbe64fe1b00213c4/websockets-17.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:162188a53ffb58b175dc41bc9aee1232b87205e46591cb327be71315f8630bec", size = 210610, upload-time = "2026-07-29T18:07:05.297Z" }, + { url = "https://files.pythonhosted.org/packages/f6/2f/0681ddc3a07af06e1be2b2954b6cb07f9caf67c69ada44a81e029573cfd4/websockets-17.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4e95999b19cd99b01d401937f2adebc515b815fa2c7cfb043fc64cd0cdf2d3", size = 211560, upload-time = "2026-07-29T18:07:07.602Z" }, + { url = "https://files.pythonhosted.org/packages/b8/6f/8630e03816889034aed3765a4de67839b36f04acdca52648ced6b690f89e/websockets-17.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e2314ae31ab4a629cac708ece44e28d88fae9fbb1bd4bb5b21718b7ac4ec7e91", size = 211454, upload-time = "2026-07-29T18:07:09.347Z" }, + { url = "https://files.pythonhosted.org/packages/d7/1c/a8d02a7a9f92804daba7f861ba539cf6c25765d8b2a7d7c8ea355c79deb8/websockets-17.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60ed4a3b760ed8db9a0c2c01ad65b2c253603b0edd7236ef24dbe363e417f31b", size = 212348, upload-time = "2026-07-29T18:07:11.29Z" }, + { url = "https://files.pythonhosted.org/packages/6e/14/ac6da556d66c5f5fcf21e2f8468cd303262ae46a7f460bb481425d77ed42/websockets-17.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:69852d81e27f53bb69db752c55ecbbb73a0988692c654bafd1651d3e51441476", size = 213586, upload-time = "2026-07-29T18:07:13.442Z" }, + { url = "https://files.pythonhosted.org/packages/9d/b4/9b5bd8ad82a7ace4e4a497aed083b6a9bf9076b1ea1a0bf5831686b4af71/websockets-17.0-py3-none-any.whl", hash = "sha256:0c24d62cafaca7dc1631e9f3bf0672fa83f010e66a2aeff4d00727b18addcd8e", size = 206871, upload-time = "2026-07-29T18:07:15.156Z" }, ]