diff --git a/README.md b/README.md index 4aa81e3..88f2f59 100644 --- a/README.md +++ b/README.md @@ -26,59 +26,68 @@ Each `console` code block in Markdown files collected by the plugin generates a The test runs each command in the block and compares its actual output to the expected output written in the block. -For example: +Take this Markdown file: -```` +````markdown # README.md +Here is an example of running my app: + ```console $ uv run myapp.py Hello, world! ``` ```` -This creates a passing test case if your app actually prints "Hello, world!". +Running pytest with this extension creates a test case that passes if the app actually prints "Hello, world!". -### Signal expected failures +### Match partial output -Sometimes you want to illustrate expected failures. To convey this to both the reader and the plugin, place a `# Error: `comment at the end of the failing command, or on the line preceding it: +Use `...` anywhere in the expected output to match any text in its place, including multiple lines: -```` +````markdown ```console -$ uv run myapp.py --bad_option # Error: this will fail -$ # Error: this will also most definitely fail -$ uv run myapp.py --still_bad +$ python -c "print(object())" + ``` ```` -### Match partial output +This is useful when part of the output is non-deterministic, such as memory addresses or timestamps, or when the output is very long. -Use `...` anywhere in the expected output to match any text in its place, including multiple lines: -```` +### Signal expected failures + +Sometimes you want to illustrate expected failures. To convey this to both the reader and the plugin, place a `# Error: `comment at the end of the failing command, or on the line preceding it: + +````markdown ```console -$ python -c "print(object())" - +$ uv run myapp.py --bad_option # Error: this will fail +... +$ # Error: this will also most definitely fail +$ uv run myapp.py --still_bad +... ``` ```` -This is useful when part of the output is non-deterministic, such as memory addresses or timestamps. +To pass the test case, the command line must fail and the error message produced by the application, if any, must match. ### Filter by platform -To restrict a test to specific platforms, use the `platform:` directive: +To restrict a test to specific platforms, use the `platform:` directive in an HTML comment immediately before the fence: -```` -```console platform:linux,macos +````markdown + +```console $ echo "This will generate a test case on Linux and macOS" ``` ```` To exclude a platform, prefix its name with `!`: -```` -```console platform:!windows +````markdown + +```console $ echo "This will not generate a test case on Windows" ``` ```` @@ -87,8 +96,9 @@ $ echo "This will not generate a test case on Windows" By default, all commands run in the same directory as the Markdown file. To use a different directory, use the `cwd:` directive: -```` -```console cwd:../ +````markdown + +```console $ uv run myapp.py ... ``` @@ -99,18 +109,19 @@ Relative paths are resolved relative to the Markdown file's location. ### Exclude a block from testing -To exclude a block from being collected as a test at all, add `notest` to the fence: +To exclude a block from being collected as a test at all, use the `notest` directive: -```` -```console notest -$ echo "This block is for illustration only" +````markdown + +```console +$ echo "This block will not be tested" ``` ```` ### Controlling test case runs globally -By default, test cases generated by this plugin are run. To exclude them: +By default, test cases generated by this plugin are run whenever pytest is invoked. To exclude them entirely: ```bash pytest -p no:markdown-console @@ -123,14 +134,34 @@ pytest -m markdown_console ``` +### Customising the directive tag + +By default, directive comments use the `pytest-markdown-console` tag: + +````markdown + +```console +$ echo "This block will not be tested" +``` +```` + +You can change this tag via `pyproject.toml`, for example to keep your Markdown source shorter: + +```toml +[tool.pytest.ini_options] +markdown_console_directive = "console-test" +``` + +With the above setting you would write `` instead. + + ## Why this extension? This plugin makes your documentation testable — specifically `console` blocks — within the same pytest suite you use for the rest of your Python code. -It is similar to `pytest-markdown-docs`, which works on Python code blocks, and follows similar conventions. - Other tools can test `console` blocks in Markdown files, but we couldn't find one that is simple, supports Windows, integrates with pytest, and requires no boilerplate. + ### Does it make sense to test `console` blocks? Testing `console` blocks is admittedly niche. They often contain installation instructions or shell-specific commands that don't translate across platforms. diff --git a/pyproject.toml b/pyproject.toml index 809a542..3a7b19a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,4 +23,6 @@ build-backend = "uv_build" dev = [ "pre-commit>=4.6.0", "pytest>=9.0.3", + "pytest-cov>=7.1.0", + "ty>=0.0.40", ] diff --git a/src/pytest_markdown_console/parsing.py b/src/pytest_markdown_console/parsing.py index 0fe8a28..4226f32 100644 --- a/src/pytest_markdown_console/parsing.py +++ b/src/pytest_markdown_console/parsing.py @@ -6,7 +6,7 @@ from .models import Command, ConsoleBlock -_FENCE_OPEN = re.compile(r"^```\s*console(?:\s+(.+?))?\s*$", re.IGNORECASE) +_FENCE_OPEN = re.compile(r"^```\s*console\b", re.IGNORECASE) _FENCE_CLOSE = re.compile(r"^```\s*$") @@ -42,29 +42,29 @@ def _strip_error_comment(cmd: str) -> tuple[str, bool]: return cmd, False -def _parse_fence_tokens( +def _parse_directive_tokens( tokens_str: str, ) -> tuple[bool, str | None, frozenset[str], frozenset[str], str | None]: - """Return (notest, cwd_override, only_platforms, skip_platforms, shell) from a fence token string.""" + """Return (notest, cwd_override, only_platforms, skip_platforms, shell) from a directive token string.""" notest = False cwd_override: str | None = None only: set[str] = set() skip: set[str] = set() shell: str | None = None for token in tokens_str.split(): - if token == "notest": + if token == "notest": # noqa: S105 notest = True elif token.startswith("cwd:"): - cwd_override = token[len("cwd:"):] + cwd_override = token[len("cwd:") :] elif token.startswith("shell:"): - shell = token[len("shell:"):] + shell = token[len("shell:") :] elif token.startswith("platform:"): - for p in token[len("platform:"):].split(","): - p = p.strip() - if p.startswith("!"): - skip.add(p[1:]) - elif p: - only.add(p) + for p in token[len("platform:") :].split(","): + name = p.strip() + if name.startswith("!"): + skip.add(name[1:]) + elif name: + only.add(name) return notest, cwd_override, frozenset(only), frozenset(skip), shell @@ -77,7 +77,7 @@ def _handle_dollar_line( raw: str, current_cmd: Command | None, current_block: ConsoleBlock | None, - pending_failure: bool, + pending_failure: bool, # noqa: FBT001 ) -> tuple[Command | None, bool]: cmd_part = raw[2:] stripped = cmd_part.lstrip() @@ -94,50 +94,73 @@ def _handle_dollar_line( ), False -def parse_blocks(source: str) -> list[ConsoleBlock]: +def _parse_file_config( + lines: list[str], + directive_tag: str, +) -> tuple[bool, str | None, frozenset[str], frozenset[str], str | None]: + """Return file-level directive defaults from the first matching ``-file:`` comment.""" + file_re = re.compile(rf"^\s*\s*$") + for line in lines: + m = file_re.match(line) + if m: + return _parse_directive_tokens(m.group(1)) + return False, None, frozenset(), frozenset(), None + + +def parse_blocks(source: str, directive: str = "pytest-markdown-console") -> list[ConsoleBlock]: """Parse all fenced console blocks from Markdown source text.""" + directive_re = re.compile(rf"^\s*\s*$") blocks: list[ConsoleBlock] = [] lines = source.splitlines() + f_notest, f_cwd, f_only, f_skip, f_shell = _parse_file_config(lines, directive) in_block = False current_block: ConsoleBlock | None = None current_cmd: Command | None = None pending_failure = False + indent = "" for lineno, raw in enumerate(lines, start=1): if not in_block: - m = _FENCE_OPEN.match(raw) - if m: + stripped = raw.lstrip() + if _FENCE_OPEN.match(stripped): + indent = raw[: len(raw) - len(stripped)] in_block = True - notest, cwd_override, only, skip, shell = _parse_fence_tokens(m.group(1) or "") + prev_line = lines[lineno - 2] if lineno >= 2 else "" # noqa: PLR2004 + directive_match = directive_re.match(prev_line) + tokens_str = directive_match.group(1) if directive_match else "" + notest, cwd_override, only, skip, shell = _parse_directive_tokens(tokens_str) current_block = ConsoleBlock( line_number=lineno, - notest=notest, - cwd_override=cwd_override, - only_platforms=only, - skip_platforms=skip, - shell=shell, + notest=notest or f_notest, + cwd_override=cwd_override if cwd_override is not None else f_cwd, + only_platforms=only or f_only, + skip_platforms=skip or f_skip, + shell=shell if shell is not None else f_shell, ) current_cmd = None continue - if _FENCE_CLOSE.match(raw): + line = raw.removeprefix(indent) + + if _FENCE_CLOSE.match(line): _flush_cmd(current_cmd, current_block) current_cmd = None if current_block is not None: blocks.append(current_block) current_block = None in_block = False + indent = "" continue - if raw.startswith("$ "): - current_cmd, pending_failure = _handle_dollar_line(raw, current_cmd, current_block, pending_failure) - elif raw == "$": + if line.startswith("$ "): + current_cmd, pending_failure = _handle_dollar_line(line, current_cmd, current_block, pending_failure) + elif line == "$": _flush_cmd(current_cmd, current_block) current_cmd = None elif current_cmd is not None: if current_cmd.expected_output: - current_cmd.expected_output += "\n" + raw + current_cmd.expected_output += "\n" + line else: - current_cmd.expected_output = raw + current_cmd.expected_output = line return blocks diff --git a/src/pytest_markdown_console/plugin.py b/src/pytest_markdown_console/plugin.py index f4f8aff..7c9054d 100644 --- a/src/pytest_markdown_console/plugin.py +++ b/src/pytest_markdown_console/plugin.py @@ -29,7 +29,8 @@ class MarkdownFile(pytest.File): def collect(self) -> Generator[ConsoleBlockItem, None, None]: """Yield a ConsoleBlockItem for each testable console block.""" source = self.path.read_text(encoding="utf-8") - blocks = parse_blocks(source) + directive = self.config.getini("markdown_console_directive") or "pytest-markdown-console" + blocks = parse_blocks(source, directive=directive) for idx, block in enumerate(blocks): if not block.commands or block.notest: continue @@ -71,8 +72,6 @@ def runtest(self) -> None: effective_shell = self.block.shell or ini_shell for command in self.block.commands: - if not command.cmd.strip(): - continue cwd = run_command(command, cwd, shell=effective_shell) def repr_failure(self, excinfo: pytest.ExceptionInfo[BaseException]) -> str | pytest.TerminalRepr: @@ -97,13 +96,21 @@ def reportinfo(self) -> tuple[Path, int, str]: def pytest_addoption(parser: pytest.Parser) -> None: - """Register the markdown_console_shell ini option.""" + """Register ini options for pytest-markdown-console.""" parser.addini( "markdown_console_shell", - help="Default shell for console blocks (pwsh, cmd, sh, bash, zsh, …). Defaults to pwsh on Windows, sh elsewhere.", + help=( + "Default shell for console blocks (pwsh, cmd, sh, bash, zsh, …). Defaults to pwsh on Windows, sh elsewhere." + ), type="string", default=None, ) + parser.addini( + "markdown_console_directive", + help='HTML comment tag for directives (default: "pytest-markdown-console").', + type="string", + default="pytest-markdown-console", + ) def pytest_configure(config: pytest.Config) -> None: diff --git a/src/pytest_markdown_console/runner.py b/src/pytest_markdown_console/runner.py index fa39b3e..4f6af93 100644 --- a/src/pytest_markdown_console/runner.py +++ b/src/pytest_markdown_console/runner.py @@ -40,7 +40,7 @@ def _pwd_cmd(shell: str) -> str: def _run(cmd: str, cwd: str, shell: str | None = None, **kwargs: object) -> subprocess.CompletedProcess: """Run *cmd* in the given shell (or the platform default if None).""" resolved = shell or ("pwsh" if _WINDOWS else "sh") - return subprocess.run(_build_argv(cmd, resolved), cwd=cwd, **kwargs) # type: ignore[call-overload] + return subprocess.run(_build_argv(cmd, resolved), cwd=cwd, **kwargs) # type: ignore[call-overload] # noqa: S603 PLW1510 def matches(expected: str, actual: str) -> bool: diff --git a/tests/test_parsing.py b/tests/test_parsing.py index c5fd034..fbc813f 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -98,12 +98,12 @@ def test_expect_failure_detected(source, expected_cmd): # --------------------------------------------------------------------------- -# Fence token parsing +# Directive comment parsing # --------------------------------------------------------------------------- @pytest.mark.parametrize( - ("fence_tokens", "attr", "expected"), + ("directive_tokens", "attr", "expected"), [ ("notest", "notest", True), ("cwd:../", "cwd_override", "../"), @@ -115,15 +115,15 @@ def test_expect_failure_detected(source, expected_cmd): ], ids=["notest", "cwd_parent", "cwd_subdir", "shell_pwsh", "shell_cmd", "shell_bash", "shell_sh"], ) -def test_fence_token_scalar(fence_tokens, attr, expected): - """Scalar fence tokens (notest, cwd:, shell:) are parsed onto the ConsoleBlock.""" - source = f"```console {fence_tokens}\n$ echo hi\n```\n" +def test_directive_comment_scalar(directive_tokens, attr, expected): + """Scalar directive tokens (notest, cwd:, shell:) are parsed onto the ConsoleBlock.""" + source = f"\n```console\n$ echo hi\n```\n" block = parse_blocks(source)[0] assert getattr(block, attr) == expected def test_shell_token_absent(): - """shell is None when no shell: token is present.""" + """Shell is None when no shell: token is present.""" block = parse_blocks("```console\n$ echo hi\n```\n")[0] assert block.shell is None @@ -138,23 +138,62 @@ def test_shell_token_absent(): ], ids=["only_two", "only_one", "skip_one", "skip_two"], ) -def test_platform_token(platform_token, only_platforms, skip_platforms): +def test_platform_directive(platform_token, only_platforms, skip_platforms): """Platform tokens populate only_platforms and skip_platforms correctly.""" - source = f"```console {platform_token}\n$ echo hi\n```\n" + source = f"\n```console\n$ echo hi\n```\n" block = parse_blocks(source)[0] assert block.only_platforms == only_platforms assert block.skip_platforms == skip_platforms -def test_combined_fence_tokens(): - """Multiple fence tokens in one line are all parsed independently.""" - block = parse_blocks("```console notest cwd:sub platform:linux shell:bash\n$ echo hi\n```\n")[0] +def test_combined_directives(): + """Multiple directive tokens in one comment are all parsed independently.""" + source = "\n```console\n$ echo hi\n```\n" + block = parse_blocks(source)[0] assert block.notest is True assert block.cwd_override == "sub" assert block.only_platforms == frozenset({"linux"}) assert block.shell == "bash" +def test_directive_comment_not_adjacent(): + """A directive comment separated from the fence by a blank line is ignored.""" + source = "\n\n```console\n$ echo hi\n```\n" + block = parse_blocks(source)[0] + assert block.notest is False + + +def test_unrelated_html_comment_ignored(): + """An HTML comment without the directive prefix does not set any directives.""" + source = "\n```console\n$ echo hi\n```\n" + block = parse_blocks(source)[0] + assert block.notest is False + assert block.cwd_override is None + assert block.shell is None + + +def test_second_block_without_directive(): + """When only the first of two blocks has a directive comment, the second gets defaults.""" + source = "\n```console\n$ echo a\n```\n\n```console\n$ echo b\n```\n" + block_a, block_b = parse_blocks(source) + assert block_a.notest is True + assert block_b.notest is False + + +def test_old_directive_tag_not_recognised_by_default(): + """The legacy pytest-console: tag is not recognised with the default directive.""" + source = "\n```console\n$ echo hi\n```\n" + block = parse_blocks(source)[0] + assert block.notest is False + + +def test_custom_directive_recognised(): + """A custom directive string is matched when passed explicitly.""" + source = "\n```console\n$ echo hi\n```\n" + block = parse_blocks(source, directive="my-project")[0] + assert block.notest is True + + # --------------------------------------------------------------------------- # Line number # --------------------------------------------------------------------------- @@ -166,8 +205,9 @@ def test_combined_fence_tokens(): ("```console\n$ echo hi\n```\n", 1), ("# Title\n\n```console\n$ echo hi\n```\n", 3), ("\n\n\n```console\n$ echo hi\n```\n", 4), + ("- item\n\n ```console\n $ echo hi\n ```\n", 3), ], - ids=["first_line", "after_heading", "after_blanks"], + ids=["first_line", "after_heading", "after_blanks", "indented_fence"], ) def test_line_number_recorded(source, expected_line): """The 1-based opening-fence line number is stored on the block.""" @@ -207,9 +247,9 @@ def test_no_command_produced(block_body): ("```console\n$ my_app --flag 42 # to order pizza\n```\n", "my_app --flag 42", False), ("```console\n$ my_app --flag #no-space-after-hash\n```\n", "my_app --flag", False), # '#' inside double quotes also containing a single quote — must not be treated as comment - ("```console\n$ echo \"'#\" # comment\n```\n", "echo \"'#\"", False), + ('```console\n$ echo "\'#" # comment\n```\n', 'echo "\'#"', False), # '#' inside single quotes also containing a double quote — must not be treated as comment - ('```console\n$ echo \'\"#\' # comment\n```\n', "echo '\"#'", False), + ("```console\n$ echo '\"#' # comment\n```\n", "echo '\"#'", False), # '# Error' annotations still set expect_failure ("```console\n$ false # Error: expected\n```\n", "false", True), ("```console\n$ false # Error\n```\n", "false", True), @@ -240,3 +280,157 @@ def test_fence_case_insensitive(language_tag): """The console language tag is matched case-insensitively.""" blocks = parse_blocks(f"```{language_tag}\n$ echo hi\n```\n") assert len(blocks) == 1 + + +# --------------------------------------------------------------------------- +# Indented fences (e.g. inside list items) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("source", "expected_cmd", "expected_output"), + [ + ( + "1. Step one:\n\n ```console\n $ echo hi\n hi\n ```\n", + "echo hi", + "hi", + ), + ( + "- item\n\n ```console\n $ echo hi\n hi\n ```\n", + "echo hi", + "hi", + ), + ], + ids=["indented_4_spaces", "indented_2_spaces"], +) +def test_indented_fence_detected(source, expected_cmd, expected_output): + """Console blocks indented inside list items are parsed correctly.""" + blocks = parse_blocks(source) + assert len(blocks) == 1 + cmd = blocks[0].commands[0] + assert cmd.cmd == expected_cmd + assert cmd.expected_output == expected_output + + +def test_indented_fence_multiline_output(): + """Multi-line expected output inside an indented block is de-indented correctly.""" + source = "- item\n\n ```console\n $ printf 'a\\nb'\n a\n b\n ```\n" + blocks = parse_blocks(source) + assert blocks[0].commands[0].expected_output == "a\nb" + + +def test_indented_fence_directive(): + """A directive comment before an indented fence is still applied.""" + source = "- item\n\n \n ```console\n $ echo hi\n ```\n" + blocks = parse_blocks(source) + assert blocks[0].notest is True + + +def test_mixed_indented_and_plain_blocks(): + """A mix of indented and non-indented blocks in the same source are both collected.""" + source = "```console\n$ echo a\n```\n\n- item\n\n ```console\n $ echo b\n ```\n" + block_a, block_b = parse_blocks(source) + assert block_a.commands[0].cmd == "echo a" + assert block_b.commands[0].cmd == "echo b" + + +# --------------------------------------------------------------------------- +# File-level directives +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("directive_tokens", "attr", "expected"), + [ + ("notest", "notest", True), + ("cwd:../", "cwd_override", "../"), + ("shell:pwsh", "shell", "pwsh"), + ("platform:linux", "only_platforms", frozenset({"linux"})), + ("platform:!windows", "skip_platforms", frozenset({"windows"})), + ], + ids=["notest", "cwd", "shell", "only_platform", "skip_platform"], +) +def test_file_directive_sets_default(directive_tokens, attr, expected): + """A file-level directive comment sets the default for all blocks in the file.""" + source = f"\n```console\n$ echo hi\n```\n" + block = parse_blocks(source)[0] + assert getattr(block, attr) == expected + + +def test_file_directive_applies_to_all_blocks(): + """File-level defaults propagate to every block in the file.""" + source = ( + "\n```console\n$ echo a\n```\n\n```console\n$ echo b\n```\n" + ) + block_a, block_b = parse_blocks(source) + assert block_a.shell == "bash" + assert block_b.shell == "bash" + + +def test_block_directive_overrides_file_directive(): + """A block-level directive takes precedence over the file-level default.""" + source = ( + "\n" + "\n" + "```console\n$ echo hi\n```\n" + ) + block = parse_blocks(source)[0] + assert block.shell == "pwsh" + + +def test_block_directive_partial_inherits_file_defaults(): + """A block directive for one field still inherits other fields from the file level.""" + source = ( + "\n" + "\n" + "```console\n$ echo hi\n```\n" + ) + block = parse_blocks(source)[0] + assert block.shell == "pwsh" + assert block.cwd_override == "./sub" + + +def test_file_directive_anywhere_in_file(): + """A file-level directive comment is recognised wherever it appears in the file.""" + source = ( + "# My Docs\n\n" + "Some intro text.\n\n" + "\n\n" + "```console\n$ echo hi\n```\n" + ) + block = parse_blocks(source)[0] + assert block.shell == "bash" + + +def test_file_directive_after_blocks(): + """A file-level directive comment is applied even when it appears after the blocks.""" + source = "```console\n$ echo hi\n```\n\n\n" + block = parse_blocks(source)[0] + assert block.shell == "bash" + + +def test_file_directive_first_occurrence_wins(): + """When multiple file-level directives appear, the first one wins.""" + source = ( + "\n" + "\n" + "```console\n$ echo hi\n```\n" + ) + block = parse_blocks(source)[0] + assert block.shell == "bash" + + +def test_file_directive_custom_tag(): + """A custom directive tag also controls the -file variant.""" + source = "\n```console\n$ echo hi\n```\n" + block = parse_blocks(source, directive="my-project")[0] + assert block.shell == "bash" + + +def test_file_directive_no_effect_without_suffix(): + """A comment matching the block tag (not -file:) does not act as a file directive.""" + source = "\n\n```console\n$ echo hi\n```\n" + # The blank line above makes the block directive adjacent check fail, + # so the block should get no shell from either source. + block = parse_blocks(source)[0] + assert block.shell is None diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 1566607..51dc51f 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -41,7 +41,7 @@ def test_collects_md_files(pytester, md, source): @pytest.mark.parametrize( "source", [ - "```console notest\n$ echo hi\n```\n", + "\n```console\n$ echo hi\n```\n", "```console\n```\n", ], ids=["notest_flag", "empty_block"], @@ -95,6 +95,15 @@ def test_output_mismatch_reported(pytester, md): result.stdout.fnmatch_lines(["*Expected*", "*Got*"]) +@pytest.mark.skipif(sys.platform != "win32", reason="uses pwsh Write-Output") +def test_output_mismatch_reported_windows(pytester, md): + """An output mismatch produces a failure with Expected/Got in the report (Windows).""" + md("```console\n$ Write-Output hello\nwrong\n```\n") + result = pytester.runpytest("-v") + result.assert_outcomes(failed=1) + result.stdout.fnmatch_lines(["*Expected*", "*Got*"]) + + @pytest.mark.skipif(sys.platform != "win32", reason="Windows-only: uses 'exit 1'") def test_unexpected_command_failure_windows(pytester, md): """A command that exits non-zero without expect_failure produces a failure.""" @@ -103,6 +112,15 @@ def test_unexpected_command_failure_windows(pytester, md): result.assert_outcomes(failed=1) +@pytest.mark.skipif(sys.platform != "win32", reason="uses pwsh Write-Error") +def test_command_failure_stderr_in_report_windows(pytester, md): + """A failing command with stderr output includes a Stderr section in the report.""" + md("```console\n$ Write-Error 'oops'; exit 1\n```\n") + result = pytester.runpytest("-v") + result.assert_outcomes(failed=1) + result.stdout.fnmatch_lines(["*Stderr*"]) + + # --------------------------------------------------------------------------- # Expected failure # --------------------------------------------------------------------------- @@ -125,6 +143,15 @@ def test_unexpected_success_fails(pytester, md): result.stdout.fnmatch_lines(["*Expected a non-zero exit code*"]) +@pytest.mark.skipif(sys.platform != "win32", reason="uses pwsh Write-Output") +def test_unexpected_success_fails_windows(pytester, md): + """A command annotated with # Error: that succeeds is a failing test (Windows).""" + md("```console\n$ Write-Output hi # Error:\n```\n") + result = pytester.runpytest("-v") + result.assert_outcomes(failed=1) + result.stdout.fnmatch_lines(["*Expected a non-zero exit code*"]) + + # --------------------------------------------------------------------------- # Platform filtering # --------------------------------------------------------------------------- @@ -140,7 +167,7 @@ def test_unexpected_success_fails(pytester, md): ) def test_platform_only(pytester, md, platform_token, expected_outcome): """platform: token runs the block on matching platforms and skips it on others.""" - md(f"```console platform:{platform_token}\n$ echo hi\n```\n") + md(f"\n```console\n$ echo hi\n```\n") if expected_outcome == "collected": result = pytester.runpytest("--collect-only", "-q") result.stdout.fnmatch_lines(["*console*"]) @@ -151,7 +178,7 @@ def test_platform_only(pytester, md, platform_token, expected_outcome): def test_platform_skip_current(pytester, md): """platform:! causes the block to be skipped on this platform.""" - md(f"```console platform:!{sys.platform}\n$ echo hi\n```\n") + md(f"\n```console\n$ echo hi\n```\n") result = pytester.runpytest("-v") result.assert_outcomes(skipped=1) @@ -165,7 +192,7 @@ def test_cwd_override_valid(pytester, md): """cwd: pointing to an existing directory runs the command there.""" subdir = pytester.path / "sub" subdir.mkdir() - md("```console cwd:sub\n$ echo ok\nok\n```\n") + md("\n```console\n$ echo ok\nok\n```\n") result = pytester.runpytest("-v") # Windows echo adds a trailing space; just check it didn't crash badly assert result.ret in (0, 1) @@ -173,7 +200,7 @@ def test_cwd_override_valid(pytester, md): def test_cwd_override_missing_raises(pytester, md): """cwd: pointing to a missing directory causes the test to fail.""" - md("```console cwd:nonexistent\n$ echo ok\n```\n") + md("\n```console\n$ echo ok\n```\n") result = pytester.runpytest("-v") result.assert_outcomes(failed=1) diff --git a/tests/test_runner.py b/tests/test_runner.py index 1fae133..71d832c 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -34,6 +34,7 @@ ids=["pwsh", "powershell", "cmd", "sh", "bash", "zsh"], ) def test_build_argv(shell, cmd, expected): + """_build_argv() produces the correct argv list for each shell.""" assert _build_argv(cmd, shell) == expected @@ -51,9 +52,21 @@ def test_build_argv(shell, cmd, expected): ("bash", False, "&&"), ("bash", True, ";"), ], - ids=["pwsh_ok", "pwsh_fail", "powershell_ok", "powershell_fail", "cmd_ok", "cmd_fail", "sh_ok", "sh_fail", "bash_ok", "bash_fail"], + ids=[ + "pwsh_ok", + "pwsh_fail", + "powershell_ok", + "powershell_fail", + "cmd_ok", + "cmd_fail", + "sh_ok", + "sh_fail", + "bash_ok", + "bash_fail", + ], ) def test_separator(shell, expect_failure, expected): + """_separator() returns the correct shell-specific command separator.""" assert _separator(shell, expect_failure) == expected @@ -70,6 +83,7 @@ def test_separator(shell, expect_failure, expected): ids=["pwsh", "powershell", "cmd", "sh", "bash", "zsh"], ) def test_pwd_cmd(shell, expected): + """_pwd_cmd() returns the correct pwd command for each shell.""" assert _pwd_cmd(shell) == expected @@ -200,6 +214,17 @@ def test_run_command_expected_failure_output_mismatch_raises(mock_run, tmp_path) run_command(Command(cmd="false", expected_output="expected error", expect_failure=True), str(tmp_path)) +@patch("pytest_markdown_console.runner.subprocess.run") +def test_run_command_returns_original_cwd_when_pwd_output_invalid(mock_run, tmp_path): + """run_command returns the original cwd when the pwd probe produces no valid directory path.""" + mock_run.side_effect = [ + _make_result(stdout="hello\n", returncode=0), + _make_result(stdout="not_a_real_directory_xyz_abc\n", returncode=0), + ] + result = run_command(Command(cmd="echo hello", expected_output="hello"), str(tmp_path)) + assert result == str(tmp_path) + + # --------------------------------------------------------------------------- # Exception attributes # --------------------------------------------------------------------------- diff --git a/uv.lock b/uv.lock index 4d9d093..1c45218 100644 --- a/uv.lock +++ b/uv.lock @@ -20,6 +20,90 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/fd/0ab2772530e946e1be1abd0bc09e647ec9b02e88f0867857601fefca8953/coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be", size = 920132, upload-time = "2026-05-26T20:41:36.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/b7/bdbb725ba02c5b42825b200c940f38b7a54fcad24627b7192f78f8110d76/coverage-7.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c", size = 220022, upload-time = "2026-05-26T20:39:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/72/81/fdc0898a55c6219223291ec1a1fe89966ef212ce82276aa0899df84b5de0/coverage-7.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c", size = 220379, upload-time = "2026-05-26T20:39:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/de/72/de048c4a25e13bce59ac6a339351c10bdf2515e07459afcdaf04dc3143a2/coverage-7.14.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b", size = 251888, upload-time = "2026-05-26T20:39:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/28/30/300c343f68beb9d4cbb64ec81e58c5b6b80b56927f72d2b38654ac26e013/coverage-7.14.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6", size = 254624, upload-time = "2026-05-26T20:39:09.037Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ed/7b25642496e8170b6bac14adce00537c6e5fa2d586159401a4de3e8b49e6/coverage-7.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:442cc9c952b2df400cda54bb04ab87330cf2cd08a8692cbbea36773531eb6f37", size = 255739, upload-time = "2026-05-26T20:39:10.889Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a2/abd210b8c4e29c24e4624916db97bb519097a91034aaeb767f937e7da794/coverage-7.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8270544c361ed405a27a060dbc9ed2c124b084d96dfdc2d9a2510482aef981ad", size = 257998, upload-time = "2026-05-26T20:39:12.722Z" }, + { url = "https://files.pythonhosted.org/packages/7f/24/7c50beed3792fe62f6ce0545c6686ce83379719e2c0276179333d97eae92/coverage-7.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:48b283b1dd6372e8de2a7a9a4c4d5dc06f4d4fd209b876f3c88a7a205a0c8f84", size = 252296, upload-time = "2026-05-26T20:39:14.259Z" }, + { url = "https://files.pythonhosted.org/packages/15/05/0f874628ebcbfc77ead559ff210281ef06a97db08481832e7dd39274a135/coverage-7.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5b0c99ba93a07d56f6df340bb79be53202a082b2fdb81bfe6190b741a3470d54", size = 253658, upload-time = "2026-05-26T20:39:15.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/6f/ca6ad067364b337ef997802115e7ecad2abd2248b05471464b0dea02b4d4/coverage-7.14.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e471bc5769ff073b058cfadb0d736b56ce067c8560eabeb0da88462df98c23e7", size = 251803, upload-time = "2026-05-26T20:39:17.537Z" }, + { url = "https://files.pythonhosted.org/packages/c0/30/b9b4d377cd9f40baf228068f5a81faf8450c6228503011bd499708483a50/coverage-7.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f497a1ea81d4cd7c10ddcaa685135b9aabd291af3d55775a9ddf3cb7a364cdd9", size = 255873, upload-time = "2026-05-26T20:39:19.414Z" }, + { url = "https://files.pythonhosted.org/packages/3c/21/7c721a9e5e6bb88547d30a787aefb97512d3f54c1324c7488d9b3743f7f9/coverage-7.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2222be86d0b54f5dd5a38f45f17f315f737245e857bf0bdedc70734f84a13c02", size = 251372, upload-time = "2026-05-26T20:39:21.169Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f8ae5a2200130e1503cd7661a6cd3b2b7bacef98277fbf3571fb13f8b766/coverage-7.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:85e85586565842f6932abebd4c18bcb1074223dc0b3576e7d173ca710622813a", size = 253245, upload-time = "2026-05-26T20:39:23.097Z" }, + { url = "https://files.pythonhosted.org/packages/34/62/70a9024672a5f6910517d9628c52c9afbdd3cf8f46426af52bb148a56fff/coverage-7.14.1-cp312-cp312-win32.whl", hash = "sha256:4a28fd227808366b196a75476dced2eb35b351d6766ba9c858dc93319e87f4f1", size = 222567, upload-time = "2026-05-26T20:39:24.868Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/8b7cd386839b039ebe1855733b9f9449a8dec5d79564018234f185a7fa70/coverage-7.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:54acdb6674a4661768d7bf7db32dfb9f46ab1d764f8aba6df75ce1a6a088724e", size = 223372, upload-time = "2026-05-26T20:39:26.603Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ba/b44d472022f620d289d95fa830143235c0c36461c6f2437ea8d51e5481ed/coverage-7.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:99cd41ff91afd94896fea3bc002706b6ae4ce95727d06e4a0f39c0a8d8bd8b1a", size = 221989, upload-time = "2026-05-26T20:39:28.242Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/5f6d56327c62b185225d145191c607e07515294a0aa6338e58805cd4a5ac/coverage-7.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793", size = 220044, upload-time = "2026-05-26T20:39:29.902Z" }, + { url = "https://files.pythonhosted.org/packages/75/92/e82aca356744cbbc0f77a0b623e38918c1872361963413a3bab5d0340393/coverage-7.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d", size = 220412, upload-time = "2026-05-26T20:39:31.561Z" }, + { url = "https://files.pythonhosted.org/packages/27/c9/385bde0bf7ed0f4bf3a7ee5367060a86b5d218718cfd6fb943c0f836b34f/coverage-7.14.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247", size = 251412, upload-time = "2026-05-26T20:39:33.337Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/23faf6a2343a0d17f960a4bd56c43bc7eb4cf312f774dd6ceebd82c7d8fc/coverage-7.14.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d", size = 254008, upload-time = "2026-05-26T20:39:35.009Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/36f4aa9ca8a815e6036156e80706a67828bb97bd826948244f6996dda957/coverage-7.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b", size = 255241, upload-time = "2026-05-26T20:39:36.71Z" }, + { url = "https://files.pythonhosted.org/packages/ca/79/95266316352f90f6b1c6736bb413302edfde2453fb32422d3911642691b3/coverage-7.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be", size = 257373, upload-time = "2026-05-26T20:39:38.412Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9c/58316d1f66c488b5fca8a0eb3e98348807813efa8a0d0833b9021be27488/coverage-7.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43", size = 251635, upload-time = "2026-05-26T20:39:40.268Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5a/ca2398a568e16fed7bb713e84ba3603a7164fb65779abe645c565ec890d5/coverage-7.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901", size = 253373, upload-time = "2026-05-26T20:39:42.145Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2c/0396562c32deaebe7be51d865b3a41e9a87d7561acafe1a28f53b07e019a/coverage-7.14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff", size = 251341, upload-time = "2026-05-26T20:39:43.907Z" }, + { url = "https://files.pythonhosted.org/packages/fd/8f/a94f9221184c9cae1ee115820e3798e48b6b17777a9f19e46fb9a0c8dc74/coverage-7.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4", size = 255497, upload-time = "2026-05-26T20:39:46.166Z" }, + { url = "https://files.pythonhosted.org/packages/71/69/505d70e47db1eaebcd002c39759707621ef184cd6b1ae084d9f41293f323/coverage-7.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d", size = 251159, upload-time = "2026-05-26T20:39:48.03Z" }, + { url = "https://files.pythonhosted.org/packages/e0/aa/58681c383aa33a9d2ed40a02d7a22fbf780d1fa4d575396365777828198c/coverage-7.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33", size = 252934, upload-time = "2026-05-26T20:39:49.872Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fd/11c928cd6bdffc7074bb5965c173d9ebf517fb00205e1da524b98d29ef92/coverage-7.14.1-cp313-cp313-win32.whl", hash = "sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c", size = 222584, upload-time = "2026-05-26T20:39:51.68Z" }, + { url = "https://files.pythonhosted.org/packages/6f/92/fb416fc26d340dcba19518c418d6048e913186e17243982c5e435e41fa7a/coverage-7.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416", size = 223394, upload-time = "2026-05-26T20:39:53.472Z" }, + { url = "https://files.pythonhosted.org/packages/73/c6/02d56e3867972f77d5036de924643f26c056e848f00452cafb4dbc3c29b4/coverage-7.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42", size = 222015, upload-time = "2026-05-26T20:39:55.374Z" }, + { url = "https://files.pythonhosted.org/packages/4d/9e/fcc77914050df73f7662fa1f00902774c79c075a8388ab334074574bf77e/coverage-7.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d", size = 220733, upload-time = "2026-05-26T20:39:57.189Z" }, + { url = "https://files.pythonhosted.org/packages/f7/67/2963cbdaf5cbadec44efa3a1e39eaa1f02df4079585f05387607a221e126/coverage-7.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5", size = 221086, upload-time = "2026-05-26T20:39:59.019Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/8701645574e11881f2f47d8930f98bc48b5d43b25eb5b4430dfc4a2f9f48/coverage-7.14.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52", size = 262381, upload-time = "2026-05-26T20:40:00.822Z" }, + { url = "https://files.pythonhosted.org/packages/7c/28/7a64d73598263e0c5abd5084211a8474488d31b3c552ff531c719dfcff62/coverage-7.14.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a", size = 264458, upload-time = "2026-05-26T20:40:02.506Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d8/4969179db9f7eb4df218e69540adf829d1c835f59452513d065d15446802/coverage-7.14.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a", size = 266884, upload-time = "2026-05-26T20:40:04.421Z" }, + { url = "https://files.pythonhosted.org/packages/a6/78/a45d5794dbc9bafd97afc96a4377c86c7820d78b6cf51b89bc1d4e919275/coverage-7.14.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2", size = 268022, upload-time = "2026-05-26T20:40:06.298Z" }, + { url = "https://files.pythonhosted.org/packages/21/cb/4f5e354e9e3e67af96bd4e57113e6db6b22298c7168b13eec408a549903d/coverage-7.14.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e", size = 261631, upload-time = "2026-05-26T20:40:08.226Z" }, + { url = "https://files.pythonhosted.org/packages/ec/49/eced49af4cb996d5d8b7e94e736175c513e4facd3398507b89892b4326d8/coverage-7.14.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d", size = 264443, upload-time = "2026-05-26T20:40:10.137Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d8/5603a88a7c5913a6b54f6cb1a8c46f7b39cbb30f27cd3f492908da09b2d7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb", size = 262069, upload-time = "2026-05-26T20:40:11.999Z" }, + { url = "https://files.pythonhosted.org/packages/f0/59/2ae3cb79da554a06c8619d6c88ea19dd1e4aed4b834b6a83bb1fa243bdc5/coverage-7.14.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d", size = 265780, upload-time = "2026-05-26T20:40:13.858Z" }, + { url = "https://files.pythonhosted.org/packages/af/5f/b130c1dc999031f2648bd25317fbce505ad8d5562079b4ed81e736a84967/coverage-7.14.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69", size = 260970, upload-time = "2026-05-26T20:40:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/87/d1/ec13ccddeb48ec963bdfa72a11224bac2584bd045ba13beca82f8113e9c7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54", size = 263157, upload-time = "2026-05-26T20:40:18.382Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c2/cd91ead503045161092d3845f7bb95ea2f25131ce96d3e314dd835d91b9c/coverage-7.14.1-cp313-cp313t-win32.whl", hash = "sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1", size = 223259, upload-time = "2026-05-26T20:40:20.381Z" }, + { url = "https://files.pythonhosted.org/packages/71/9f/1e28d97e6bd2c76b07f38b7c02870f1371255ff6717f54eca578fcbbdd0e/coverage-7.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce", size = 224320, upload-time = "2026-05-26T20:40:22.316Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e0/d936e908f0e1efa55e52b91e01b52f1055cef5e1ab2718493390ed8e2fb8/coverage-7.14.1-cp313-cp313t-win_arm64.whl", hash = "sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1", size = 222577, upload-time = "2026-05-26T20:40:24.894Z" }, + { url = "https://files.pythonhosted.org/packages/d6/34/fc2f101b151af3799a101f0550b0454aa008afdc0add677394ec4aa8ea10/coverage-7.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee", size = 220091, upload-time = "2026-05-26T20:40:27.249Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a7/1ebae2ab5b961b5c79bb09fe7b3ac99edb190d8be4a8c510b2cf66f46468/coverage-7.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500", size = 220421, upload-time = "2026-05-26T20:40:30.084Z" }, + { url = "https://files.pythonhosted.org/packages/5e/90/92aca9cf0acc95123c96cd1eb1f08917897a7f5dee01e15738922971ec31/coverage-7.14.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906", size = 251466, upload-time = "2026-05-26T20:40:32.542Z" }, + { url = "https://files.pythonhosted.org/packages/26/2b/78048cbe3b999f6cbf9cc0d90abba6a88a3e0863a8c1c6cbc762f3f8802f/coverage-7.14.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42", size = 253973, upload-time = "2026-05-26T20:40:34.473Z" }, + { url = "https://files.pythonhosted.org/packages/8e/21/c2e33b29d1cfde484a19d437afc343c6cd30b08d78cbbf9f5aff14e57b2b/coverage-7.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8", size = 255318, upload-time = "2026-05-26T20:40:38.154Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ee/aad2f108d63b769121005302f16bf66db8625c88ceaba466942e09a2607e/coverage-7.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851", size = 257633, upload-time = "2026-05-26T20:40:40.164Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f8/11a2c29b4fd76d9849f81d0bb812ec0017a9396df3217214e38934a8c837/coverage-7.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034", size = 251488, upload-time = "2026-05-26T20:40:42.631Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b8/9a5820de4b8ac2b71d85e3b5fb49108d7469c665f0e2ad0dd7569023e305/coverage-7.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c", size = 253329, upload-time = "2026-05-26T20:40:45.208Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ff/f33e4823667e27548e8fd8df44217515303f9808d0ff29817db56f87d990/coverage-7.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36", size = 251291, upload-time = "2026-05-26T20:40:47.502Z" }, + { url = "https://files.pythonhosted.org/packages/68/9b/489db0ebb209054766b90a9014a45f6d26eb724c02ec21311c3733b5a644/coverage-7.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5", size = 255564, upload-time = "2026-05-26T20:40:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/27/b5/16bc2d4c2409b23c7737edb68c83bc89e345f378050549fe1d75ac7d34d5/coverage-7.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4", size = 251107, upload-time = "2026-05-26T20:40:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/7d/0c/2629997469a00cd069d588a41c9dc887610f2775ae89d250c4791e65272a/coverage-7.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d", size = 252764, upload-time = "2026-05-26T20:40:54.267Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ee/f78d63c8f079e0d7211c7e2401fa17e311514534ba61bae03e4b287ce4ab/coverage-7.14.1-cp314-cp314-win32.whl", hash = "sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee", size = 222837, upload-time = "2026-05-26T20:40:56.496Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b9/be539854f93a70dfbeec69117f33ec70dc42ff0b65b5b07ab8d40d04228e/coverage-7.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7", size = 223650, upload-time = "2026-05-26T20:40:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9e/24e2842fef40f35ac82ba3a7719c8023d011bf3bf652d0675316a9d088a1/coverage-7.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343", size = 222218, upload-time = "2026-05-26T20:41:00.321Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1d/ac0a9df5fe31c1e8bdd658074905fc12844a05c1a7e3fdb8417e97c31e23/coverage-7.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1", size = 220822, upload-time = "2026-05-26T20:41:02.281Z" }, + { url = "https://files.pythonhosted.org/packages/32/cf/f964fd9aff20323f9f1a726c97135f8a76bcd87b92dad141a456a43f3c64/coverage-7.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b", size = 221084, upload-time = "2026-05-26T20:41:04.593Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5e/7e5ef2aba844de2b80d678619fcf0841b42e3f37f16411226f3fe4c1016f/coverage-7.14.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474", size = 262454, upload-time = "2026-05-26T20:41:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/75809bded87015cc4935524218a2a8ed8dd1a8498bfed30a2f4f7a4b4d34/coverage-7.14.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86", size = 264578, upload-time = "2026-05-26T20:41:08.556Z" }, + { url = "https://files.pythonhosted.org/packages/f3/42/d33392dc14633525012d2d504fa1a33b05538bf535f5c1d64675e5754b78/coverage-7.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e", size = 266981, upload-time = "2026-05-26T20:41:10.824Z" }, + { url = "https://files.pythonhosted.org/packages/2a/49/0157c4428c2aca7f1e09d5565930586fd5ae36f1655f08b0daa7cf1fcae1/coverage-7.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65", size = 268112, upload-time = "2026-05-26T20:41:12.966Z" }, + { url = "https://files.pythonhosted.org/packages/96/26/86b9ce71f4092b1ed325ce1421698081df1286b833400b6836912834d6e0/coverage-7.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e", size = 261558, upload-time = "2026-05-26T20:41:15Z" }, + { url = "https://files.pythonhosted.org/packages/20/4c/c311210c5472cf5401d8422b0d7812cdd520f24417673afabda6c323faca/coverage-7.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8", size = 264447, upload-time = "2026-05-26T20:41:17.369Z" }, + { url = "https://files.pythonhosted.org/packages/fb/71/59513f8710ed3e6b0ac0a050a5b7e977bb9c9e880354863b5d00d8809256/coverage-7.14.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07", size = 262048, upload-time = "2026-05-26T20:41:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/bceed32dc494f5bbf50f775cd2e78ca814953942b5ea28d3c1c3ac316f14/coverage-7.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de", size = 265781, upload-time = "2026-05-26T20:41:21.559Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c5/9348fe40dbfd4991aaf78df2c6c3098bfb2cc834d1fd362a64b4efef855a/coverage-7.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890", size = 260896, upload-time = "2026-05-26T20:41:23.428Z" }, + { url = "https://files.pythonhosted.org/packages/ca/92/1ea0f03929da7cf87206b1fa24f4c8e9c158be0455481af29ec0a1f3503f/coverage-7.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd", size = 263214, upload-time = "2026-05-26T20:41:25.419Z" }, + { url = "https://files.pythonhosted.org/packages/f6/a9/b2493c054c0e01a643266742ab45e15744e60743f9260cd930c7142b1124/coverage-7.14.1-cp314-cp314t-win32.whl", hash = "sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e", size = 223624, upload-time = "2026-05-26T20:41:27.795Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bd/3e1e6a57fccd2d7c83fcdf338e93ba98eb85c6e877dd34731ac585375490/coverage-7.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c", size = 224728, upload-time = "2026-05-26T20:41:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/31066cf1d2f0c6c797fce911bcfa01dd35642dc6da992a950256097c5860/coverage-7.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af", size = 222752, upload-time = "2026-05-26T20:41:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/8a/3c/1a983b9a745d7f83d53f057bcc5bf79ba6a2bbc08266b3f0c7d6fe630c9b/coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2", size = 211815, upload-time = "2026-05-26T20:41:34.078Z" }, +] + [[package]] name = "distlib" version = "0.4.0" @@ -133,6 +217,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + [[package]] name = "pytest-markdown-console" version = "0.0.1.dev1" @@ -145,6 +243,8 @@ dependencies = [ dev = [ { name = "pre-commit" }, { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ty" }, ] [package.metadata] @@ -154,6 +254,8 @@ requires-dist = [{ name = "pytest", specifier = ">=8" }] dev = [ { name = "pre-commit", specifier = ">=4.6.0" }, { name = "pytest", specifier = ">=9.0.3" }, + { name = "pytest-cov", specifier = ">=7.1.0" }, + { name = "ty", specifier = ">=0.0.40" }, ] [[package]] @@ -215,6 +317,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "ty" +version = "0.0.40" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/f8/a754c96967b71de8723f88be17df8738216bd382ffed229cd500b7a24d13/ty-0.0.40.tar.gz", hash = "sha256:883b53dd98f6e5b33ab1c8e1a3cd94b0f29c762ef22cdf1e86aaffb4fd711c67", size = 5726484, upload-time = "2026-05-27T17:55:43.615Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/42/d029a72165ad39f95228b67355927fbd35c821dc8e3e475d49f47c2eeb1e/ty-0.0.40-py3-none-linux_armv6l.whl", hash = "sha256:9defb4742450e569a6a09de286a04008d6c2e815112da4362c88b6eaa2f52a36", size = 11406372, upload-time = "2026-05-27T17:55:49.633Z" }, + { url = "https://files.pythonhosted.org/packages/23/99/7f8ea09b7e49afbf795cb3341a3217f30f228db7e62a2268ed8cbbf813d6/ty-0.0.40-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:868258a3330db88b683fcafe2c4e936d6226a6312799bf15b585d93557b2d38c", size = 11159782, upload-time = "2026-05-27T17:55:47.405Z" }, + { url = "https://files.pythonhosted.org/packages/04/d8/1ea745ee97a98b26ae9564d19a430a76a35297cd450e84dcaad22e1f7ee8/ty-0.0.40-py3-none-macosx_11_0_arm64.whl", hash = "sha256:589c81060cf1e7a9ffa2f45bfa35ffd9b9fbd214104e3f13959f113627efcd91", size = 10594139, upload-time = "2026-05-27T17:55:37.206Z" }, + { url = "https://files.pythonhosted.org/packages/39/1a/fbef21273c6617ff4715b4827ee1c0b6550aa7d1df4b8c43b325545c1cf4/ty-0.0.40-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b06108990cb338d941c315ae6e9ba2fff8f518bc15d3f33e5619ff6a6c9beab", size = 11114156, upload-time = "2026-05-27T17:55:56.11Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f9/389fc4976d7ec016a7473cf1274bf9c4f491bb54c66649bd022bff9f2b6a/ty-0.0.40-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3913ef37336bec4f96bd2512f8c3a543ca34c259b7170f7eb5adf75b3ed7f04c", size = 11189050, upload-time = "2026-05-27T17:55:54.099Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a9/4ecabbf4bdda7df0d99d8d3892c6edac0efc8c4cae756a5109178a3d0e86/ty-0.0.40-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8fd1486bd5fe48779a8aa857137f3642a0a9161f5cf57d4380f4a0ecea01c8f3", size = 11664266, upload-time = "2026-05-27T17:55:28.17Z" }, + { url = "https://files.pythonhosted.org/packages/45/02/0aa78730116507c265afb1d6d5961c583b49d4c2e368c4a49fd81bcae6dc/ty-0.0.40-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1668364d5254a734329917ee66c2c5fdd5665389d41043f6fce0f22ddb32b749", size = 12187743, upload-time = "2026-05-27T17:56:04.337Z" }, + { url = "https://files.pythonhosted.org/packages/e6/68/ccabf2d173523598271a385c1d3f864dbda23e5ebdc67f5969b9e830ea05/ty-0.0.40-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:43f77a73edb91e5dfa2ab9af7c4cac64614f8cc121f38a8875f22e830d3aba6a", size = 11862999, upload-time = "2026-05-27T17:55:58.087Z" }, + { url = "https://files.pythonhosted.org/packages/03/8d/6d7ec22771bb23d534797cdb446eb644bccfe7a62b729bb99e7235a02fc3/ty-0.0.40-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1274ce0212ecbfed01bda7c3659c46e8bd0068e32d00c46c790466a95274c3df", size = 11743896, upload-time = "2026-05-27T17:56:00.017Z" }, + { url = "https://files.pythonhosted.org/packages/cd/a4/f9fa076b010c91cb249b1fcc3476569b7b8462cb4b688da2d04c23a0622f/ty-0.0.40-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5ee1261dbc363e5cc1a0c5bb0c8612c192bfe53491214df8bc85a540835685f9", size = 11883581, upload-time = "2026-05-27T17:56:02.319Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0f/5b776a2328c756d574dd4d6afbd30fc24e1ab4b76935c7c3c23f27ebbcb9/ty-0.0.40-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6220e2cd5cdc4683dd87fb150d195bbd9f1a021395e04cb08bd3c66ea6da6ef8", size = 11093946, upload-time = "2026-05-27T17:55:33.284Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/eb23154bae83ad7c2935e9e5916660fb3e31598a92ee232aebd79410480c/ty-0.0.40-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:46b9ed69d01d98ef046afac9983c68336f572605ea2a27b90fbe6f80bfc8d6b7", size = 11210737, upload-time = "2026-05-27T17:55:45.523Z" }, + { url = "https://files.pythonhosted.org/packages/ff/19/1fb2529703f708cacfd13a89f98613cae2907dfa941b26976467e6119803/ty-0.0.40-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ddbca9fab4406260f141674ab5efcfe7b02bd468e6985e4cdde0a21626e69ffe", size = 11332563, upload-time = "2026-05-27T17:55:41.674Z" }, + { url = "https://files.pythonhosted.org/packages/87/69/b3f5a8ef26c31204e0391147b3adcdb0674eda3e7d99868478ef168a41c6/ty-0.0.40-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1fcc082a749e6dc11b68fe9aab0420238bbf2a2374c2c7aa3c22e8c1618b136", size = 11843216, upload-time = "2026-05-27T17:55:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e8/20193069d32787f3e1a6ec8940aaa3759d3de8f48f9281bcc0c5cb0939da/ty-0.0.40-py3-none-win32.whl", hash = "sha256:75feb115b3587824c5bdf8f8305e9547b0d1e398e3077b0addc7a1988ea9bb50", size = 10670731, upload-time = "2026-05-27T17:55:31.316Z" }, + { url = "https://files.pythonhosted.org/packages/a3/f9/8b2aa4da61db81322d4a2f9db227afeb48110ca15ae31d380f64c64ceb63/ty-0.0.40-py3-none-win_amd64.whl", hash = "sha256:b0f905edaad788bd61f779a85801b60a267a25ed57fca05aaddd168d9d8896be", size = 11766211, upload-time = "2026-05-27T17:55:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/04/87/369056ed46f1b235130ec0595393262f9cd2061ca3dab276d490980f9343/ty-0.0.40-py3-none-win_arm64.whl", hash = "sha256:07da2b09d9130e2c9a257d2a29beb53105835b0256ee5fdb288fe1aab83fee47", size = 11117369, upload-time = "2026-05-27T17:55:39.329Z" }, +] + [[package]] name = "virtualenv" version = "21.3.3"