From 37e0b358a36107205ea8a2964669974526732700 Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Fri, 16 Oct 2026 12:11:08 +0200 Subject: [PATCH 01/15] feat: parse functional specs as func-id headings A functionality is a `# ` heading followed by its body: a prose statement line and the `- ` bullets indented under it. The parse result entry carries the id, the body markdown, its linked resources and empty conformance and acceptance test lists. A flush-left `- ` list in functional specs, and an acceptance tests or conformance tests section in a core spec, are the old syntax; the error names the migrate-plain-syntax skill. The nested acceptance test code is removed and every fixture is converted. --- src/plain_parser/concept_utils.py | 9 - src/plain_parser/plain_file.py | 448 ++++++++---------- src/plain_parser/plain_spec.py | 15 +- .../cli/acceptance_test_binary_resource.plain | 10 - tests/data/cli/acceptance_test_resource.plain | 10 - tests/data/cli/base64_resource.plain | 3 +- tests/data/cli/binary_resource.plain | 3 +- tests/data/cli/code_variable_conflict.plain | 6 +- tests/data/cli/missing_resource.plain | 3 +- tests/data/cli/parent_link/a/spec/top.plain | 3 +- .../cli/requires_binary_resource_base.plain | 3 +- .../cli/requires_binary_resource_top.plain | 3 +- .../cli/resource_before_frid_walk/base.plain | 3 +- .../cli/resource_before_frid_walk/top.plain | 6 +- tests/data/cli/shared_link_helper.plain | 3 +- tests/data/cli/shared_link_top.plain | 3 +- tests/data/cli/subdir/nested.plain | 3 +- tests/data/cli/template_link/spec/top.plain | 3 +- tests/data/cli/undefined_concept.plain | 6 +- tests/data/cli/valid.plain | 3 +- .../data/imports/circular_imports_main.plain | 3 +- tests/data/imports/diamond_imports_main.plain | 3 +- .../imports/import_with_functionalities.plain | 3 +- .../imports_module_with_functionalities.plain | 3 +- tests/data/imports/non_existent_import.plain | 3 +- .../missing_non_functional_requirements.plain | 3 +- .../plain_source_with_absolute_link.plain | 3 +- .../plain_source_with_missing_link.plain | 3 +- .../plain_source_with_url_link.plain | 3 +- .../task_manager_with_reference_links.plain | 18 +- .../without_non_functional_requirement.plain | 3 +- .../concept_validation_acceptance_tests.plain | 20 - ...lidation_acceptance_tests_nondefined.plain | 20 - ...oncept_validation_defined_nondefined.plain | 3 +- ...cept_validation_defined_nondefined_2.plain | 3 +- .../concept_validation_definition.plain | 3 +- .../concept_validation_noconcepts.plain | 3 +- .../concept_validation_nonconcept.plain | 3 +- .../concept_validation_nondefined.plain | 3 +- .../concept_validation_redefinition.plain | 3 +- .../concept_validation_several_concepts.plain | 9 +- .../concept_validation_valid.plain | 3 +- .../plainfileparser/cyclic_definitions.plain | 3 +- .../cyclic_definitions_across_import.plain | 3 +- .../cyclic_definitions_long.plain | 3 +- .../cyclic_definitions_sub_bullet.plain | 3 +- .../plainfileparser/exported_concepts.plain | 3 +- .../exported_concepts_defaults_exported.plain | 3 +- .../exported_concepts_example.plain | 3 +- .../exported_concepts_imported_exported.plain | 3 +- ...ported_concepts_inherited_reexported.plain | 3 +- .../exported_concepts_malformed.plain | 3 +- ...exported_concepts_missing_definition.plain | 3 +- ..._concepts_missing_definition_example.plain | 3 +- ...xported_concepts_multiple_per_bullet.plain | 3 +- ...ncepts_multiple_per_bullet_undefined.plain | 3 +- .../exported_concepts_nested.plain | 3 +- .../exported_concepts_nested_example.plain | 3 +- .../exported_concepts_no_definitions.plain | 3 +- .../exported_concepts_not_declared.plain | 3 +- ...ported_concepts_not_declared_example.plain | 3 +- ...exported_concepts_transitive_example.plain | 3 +- .../exported_concepts_transitive_l1.plain | 3 +- .../exported_concepts_transitive_l2.plain | 3 +- .../old_syntax_functional_specs.plain | 11 + .../plain_file_parser_with_comments.plain | 3 +- .../plain_file_with_comments_indented.plain | 3 +- .../regular_plain_source.plain | 3 +- .../required_concepts_example.plain | 3 +- .../required_concepts_missing.plain | 3 +- .../required_concepts_module.plain | 3 +- .../required_concepts_partial.plain | 3 +- .../required_concepts_partial_duplicate.plain | 3 +- ...requires_without_definitions_example.plain | 3 +- .../plainfileparser/topological_sort.plain | 9 +- .../topological_sort_not_referenced.plain | 12 +- tests/data/requires/chain_base.plain | 3 +- tests/data/requires/chain_fork_top.plain | 3 +- tests/data/requires/chain_middle.plain | 3 +- tests/data/requires/chain_top.plain | 3 +- .../requires/circular_requires_main.plain | 3 +- .../data/requires/circular_requires_sub.plain | 3 +- tests/data/requires/diamond_requires_1.plain | 3 +- tests/data/requires/diamond_requires_2.plain | 3 +- .../requires/diamond_requires_common.plain | 3 +- .../data/requires/diamond_requires_main.plain | 3 +- .../requires/independent_requires_1.plain | 3 +- .../requires/independent_requires_2.plain | 3 +- .../requires/independent_requires_main.plain | 3 +- .../data/requires/invalid_ancestor_base.plain | 3 +- .../data/requires/invalid_ancestor_top.plain | 3 +- .../data/requires/non_existent_require.plain | 3 +- tests/data/requires/normal_requires_1.plain | 3 +- tests/data/requires/normal_requires_2.plain | 3 +- .../requires/normal_requires_common.plain | 3 +- .../data/requires/normal_requires_main.plain | 3 +- tests/data/requires/shared_export_above.plain | 3 +- tests/data/requires/shared_export_base.plain | 3 +- .../data/requires/shared_export_middle.plain | 3 +- tests/data/requires/shared_export_top.plain | 3 +- tests/data/simple.plain | 3 +- .../data/templates/block_level_include.plain | 5 +- tests/data/templates/code_variables.plain | 3 +- tests/data/templates/implement.plain | 4 +- tests/data/templates/implement_2.plain | 4 +- tests/data/templates/malformed_tag.plain | 3 +- tests/data/templates/template_include.plain | 3 +- .../data/templates/unterminated_output.plain | 3 +- tests/test_cli.py | 31 +- tests/test_imports.py | 10 +- tests/test_loaders.py | 2 +- tests/test_plainfile.py | 270 ----------- tests/test_plainfileparser.py | 253 ++++++---- tests/test_plainspec.py | 2 +- tests/test_requires.py | 4 +- 115 files changed, 628 insertions(+), 836 deletions(-) delete mode 100644 tests/data/cli/acceptance_test_binary_resource.plain delete mode 100644 tests/data/cli/acceptance_test_resource.plain delete mode 100644 tests/data/plainfileparser/concept_validation_acceptance_tests.plain delete mode 100644 tests/data/plainfileparser/concept_validation_acceptance_tests_nondefined.plain create mode 100644 tests/data/plainfileparser/old_syntax_functional_specs.plain delete mode 100644 tests/test_plainfile.py diff --git a/src/plain_parser/concept_utils.py b/src/plain_parser/concept_utils.py index c08cebf..7cdf659 100644 --- a/src/plain_parser/concept_utils.py +++ b/src/plain_parser/concept_utils.py @@ -117,15 +117,6 @@ def validate_concepts(marshalled_plain_source) -> list[str]: ]: errors.extend(validate_concepts_in_spec_group(spec_group, marshalled_plain_source, tmp_tree_based_concepts)) - if plain_spec.FUNCTIONAL_REQUIREMENTS in marshalled_plain_source: - for func_spec in marshalled_plain_source[plain_spec.FUNCTIONAL_REQUIREMENTS]: - if "acceptance_tests" not in func_spec: - continue - - acceptance_tests = func_spec["acceptance_tests"] - for spec in acceptance_tests: - errors.extend(validate_concepts_in_spec(spec, tmp_tree_based_concepts, plain_spec.ACCEPTANCE_TESTS)) - return errors diff --git a/src/plain_parser/plain_file.py b/src/plain_parser/plain_file.py index c4ce1c0..88acc62 100644 --- a/src/plain_parser/plain_file.py +++ b/src/plain_parser/plain_file.py @@ -1,6 +1,6 @@ import io import os -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from typing import Iterable, Sequence, cast from urllib.parse import urlparse @@ -9,7 +9,7 @@ import mistletoe import mistletoe.block_token from liquid2 import DictLoader, Environment -from mistletoe.block_token import List, Paragraph, Quote +from mistletoe.block_token import Heading, List, Paragraph, Quote from mistletoe.markdown_renderer import Fragment, MarkdownRenderer from mistletoe.span_token import Emphasis, Link, RawText, SpanToken, Strong from mistletoe.token import Token @@ -30,6 +30,7 @@ IMPORT_DIRECTIVE = "import" REQUIRES_DIRECTIVE = "requires" +MIGRATION_HINT = "Run the migrate-plain-syntax skill to convert the project." REQUIRED_CONCEPTS_DIRECTIVE = "required_concepts" EXPORTED_CONCEPTS_DIRECTIVE = "exported_concepts" @@ -42,6 +43,17 @@ } +@dataclass +class Functionality: + """A functionality of ***functional specs***: its func-id and the block tokens of its body.""" + + id: str + line_number: int + # The statement paragraph and the lists under it, in source order. + body: list = field(default_factory=list) + linked_resources: list[dict] = field(default_factory=list) + + @dataclass class PlainFileParseResult: plain_source: dict @@ -88,9 +100,10 @@ def remove_quotes(token): token.children = tuple(new_children) -def check_section_for_linked_resources(section, template_dirs): +def find_linked_resources(tokens, template_dirs) -> list[dict]: + """The links in the tokens, each checked to be a relative path to an existing file that is not a .plain file.""" linked_resources = [] - for link in traverse(section, klass=Link): + for link in (link for token in tokens for link in traverse(token, klass=Link)): parsed_url = urlparse(link.node.target) if parsed_url.scheme != "" or os.path.isabs(link.node.target): raise PlainSyntaxError( @@ -124,19 +137,23 @@ def check_section_for_linked_resources(section, template_dirs): linked_resources.append({"text": link.node.children[0].content, "target": link.node.target}) + return linked_resources + + +def check_section_for_linked_resources(section, template_dirs): + linked_resources = find_linked_resources([section], template_dirs) if linked_resources: section.linked_resources = linked_resources def check_for_linked_resources(plain_source, template_dirs): - for specification_heading in plain_spec.ALLOWED_SPECIFICATION_HEADINGS: + for specification_heading in plain_spec.LIST_SECTION_HEADINGS: if specification_heading in plain_source and hasattr(plain_source[specification_heading], "children"): for requirement in plain_source[specification_heading].children: check_section_for_linked_resources(requirement, template_dirs) - if hasattr(requirement, plain_spec.ACCEPTANCE_TESTS): - for acceptance_test in requirement.acceptance_tests: - check_section_for_linked_resources(acceptance_test, template_dirs) + for functionality in plain_source.get(plain_spec.FUNCTIONAL_REQUIREMENTS) or []: + functionality.linked_resources = find_linked_resources(functionality.body, template_dirs) def process_section_code_variables(section, code_variables): @@ -171,8 +188,8 @@ def has_functional_specs_section(plain_source) -> bool: def count_functionalities(plain_source) -> int: """Number of functionalities under the ***functional specs*** section (0 if absent or empty).""" - section = plain_source.get(plain_spec.FUNCTIONAL_REQUIREMENTS) - return len(section.children) if section is not None else 0 + functionalities = plain_source.get(plain_spec.FUNCTIONAL_REQUIREMENTS) + return len(functionalities) if functionalities is not None else 0 def validate_functionalities_have_implementation_reqs(plain_source, module_name, has_requires=False) -> None: @@ -201,148 +218,6 @@ def validate_functionalities_have_implementation_reqs(plain_source, module_name, raise PlainSyntaxError(message) -def _is_acceptance_test_heading(token) -> tuple[bool, str | None]: - """ - Check if token is an 'Acceptance test:' heading. - - This method is going to evaluate to True when: - - - The token is a Paragraph - - The paragraph has 1 child - - The child is an Emphasis (*** ... *** marker) - - The Emphasis has 1 child (the raw text content) - - The child of the Emphasis is a Strong - - The Strong has 1 child - - The child of the Strong is a RawText - - The RawText content is equal to "Acceptance test:" - - This is expected structure for the acceptance test heading. - """ - if not isinstance(token, Paragraph): - return False, None - - # Check for the specific structure, descending one level at a time. mistletoe types - # ``children`` as ``Optional[Iterable[Token]]``, so cast each level to a concrete - # sequence before indexing/len. - children = cast("Sequence[Token]", token.children) - if len(children) != 1 or not isinstance(children[0], Emphasis): - return False, None - - emphasis_children = cast("Sequence[Token]", children[0].children) - if len(emphasis_children) != 1 or not isinstance(emphasis_children[0], Strong): - return False, None - - strong_children = cast("Sequence[Token]", emphasis_children[0].children) - if len(strong_children) != 1 or not isinstance(strong_children[0], RawText): - return False, None - - # Check the actual text content - content = strong_children[0].content.strip() - if content == plain_spec.ACCEPTANCE_TEST_HEADING: - return True, None - problem = f"Syntax error at line {token.line_number}: Invalid acceptance test heading (`{content}`). Expected: `{plain_spec.ACCEPTANCE_TEST_HEADING}`." - return False, problem - - -def _find_acceptance_test_heading(token): - """ - Recursively search a token tree for an `acceptance tests` heading. - - Returns the heading token if one is found anywhere in the subtree, otherwise None. - Used to detect acceptance tests nested under sections other than functional specs. - """ - is_acceptance_test_heading, _ = _is_acceptance_test_heading(token) - if is_acceptance_test_heading: - return token - - if getattr(token, "children", None): - for child in token.children: - found = _find_acceptance_test_heading(child) - if found is not None: - return found - - return None - - -def _process_single_acceptance_test_requirement(functional_requirement: mistletoe.block_token.ListItem): - """ - Process a single functionality to extract acceptance tests. - - Expected functional_requirement properties: - - Is a list item - - If acceptance tests are specified, it has 3 children: - - List item element with functionality instructions/text - - Paragraph with `***Acceptance test:***` heading - - List of acceptance tests - - If acceptance tests are not specified, it has 1 child: - - List item element with functionality instructions/text - """ - new_children: list = [] - functional_requirement_children = iter(functional_requirement.children or ()) - acceptance_tests_found_already = False - - for functional_requirement_child in functional_requirement_children: - is_acceptance_test_heading, acceptance_test_heading_problem = _is_acceptance_test_heading( - functional_requirement_child - ) - if acceptance_test_heading_problem: - # Handle the case when the heading is not valid. This case includes cases such as: - # - Writing `acceptance test` instead of `acceptance tests` (or any other syntax diffs). - # - Instead of specifying `acceptance tests` below the functionality, creator of the plain file - # might have specified some other building block (e.g. `implementation reqs`) - raise PlainSyntaxError(f"Plain syntax error: {acceptance_test_heading_problem}") - - if is_acceptance_test_heading: - if acceptance_tests_found_already: - # Handle edge case of duplicated ***acceptance tests*** heading - raise PlainSyntaxError( - f"Plain syntax error: Syntax error at line {functional_requirement_child.line_number}: Duplicate 'acceptance tests' heading found within the same functionality. Only one block of acceptance tests is allowed per functionality." - ) - - try: - # If there is an acceptance test heading, the next token should be a list, with children being list items - next_token = next(functional_requirement_children) - if isinstance(next_token, List): - # Found valid acceptance test list -> Assign it property acceptance_tests and append all list items to it - if not hasattr(functional_requirement, plain_spec.ACCEPTANCE_TESTS): - functional_requirement.acceptance_tests = [] - for list_item in next_token.children: - functional_requirement.acceptance_tests.append(list_item) - acceptance_tests_found_already = True - else: - # Not followed by a list, keep both tokens - new_children.append(functional_requirement_child) - new_children.append(next_token) - except StopIteration: - # No next token, keep this one - new_children.append(functional_requirement_child) - else: - # Regular token, keep it - new_children.append(functional_requirement_child) - - # Assign the children property to all the children of the functionality from previous, with exception - # of those we parsed as acceptance tests - container_type = cast(type, type(functional_requirement.children)) - functional_requirement.children = container_type(new_children) - - -def process_acceptance_tests(plain_source): - # Early returns for cases without functionalities - if plain_spec.FUNCTIONAL_REQUIREMENTS not in plain_source: - return - frs = plain_source[plain_spec.FUNCTIONAL_REQUIREMENTS] - if not hasattr(frs, "children"): - return - - # Process each functionality - for functional_requirement in frs.children: - if not hasattr(functional_requirement, "children"): - continue - - # Process each requirement to extract acceptance tests - _process_single_acceptance_test_requirement(functional_requirement) - - def get_raw_text(token): if isinstance(token, RawText): yield token.content @@ -357,16 +232,27 @@ def get_raw_text(token): raise Exception(f"Unknown token type: {type(token)}") +def render_tokens(renderer: MarkdownRenderer, tokens) -> str: + """The block tokens rendered as one markdown text, without leading and trailing blank lines.""" + document = mistletoe.Document("") + document.children = list(tokens) + return renderer.render(document).strip("\n") + + +def marshall_functionality(renderer: MarkdownRenderer, functionality: Functionality) -> dict: + return { + "id": functionality.id, + "markdown": render_tokens(renderer, functionality.body), + "linked_resources": functionality.linked_resources, + plain_spec.CONFORMANCE_TESTS: [], + plain_spec.ACCEPTANCE_TESTS: [], + } + + def marshall_plain_source(input_plain_source): plain_source = {} with PlainRenderer() as renderer: - if "ID" in input_plain_source: - plain_source["ID"] = input_plain_source["ID"] - - if "Heading" in input_plain_source: - plain_source["Heading"] = input_plain_source["Heading"] - - for specification_heading in plain_spec.ALLOWED_SPECIFICATION_HEADINGS: + for specification_heading in plain_spec.LIST_SECTION_HEADINGS: if specification_heading in input_plain_source and hasattr( input_plain_source[specification_heading], "children" ): @@ -376,18 +262,16 @@ def marshall_plain_source(input_plain_source): if hasattr(requirement, "linked_resources"): requirement_section["linked_resources"] = requirement.linked_resources - if hasattr(requirement, plain_spec.ACCEPTANCE_TESTS): - requirement_section[plain_spec.ACCEPTANCE_TESTS] = [] - for acceptance_test in requirement.acceptance_tests: - acceptance_test_section = {"markdown": renderer.render(acceptance_test).strip()} - if hasattr(acceptance_test, "linked_resources"): - acceptance_test_section["linked_resources"] = acceptance_test.linked_resources - requirement_section[plain_spec.ACCEPTANCE_TESTS].append(acceptance_test_section) - list_of_requirements.append(requirement_section) plain_source[specification_heading] = list_of_requirements + if plain_spec.FUNCTIONAL_REQUIREMENTS in input_plain_source: + plain_source[plain_spec.FUNCTIONAL_REQUIREMENTS] = [ + marshall_functionality(renderer, functionality) + for functionality in input_plain_source[plain_spec.FUNCTIONAL_REQUIREMENTS] or [] + ] + return plain_source @@ -474,6 +358,16 @@ def restore_stripped_lines(plain_source_text: str, content: str) -> str: return "\n" * stripped_line_count + content +def syntax_error_at(line_number: int | None, module_name: str | None) -> str: + """`Syntax error at line N in module 'm'`, without the parts that are not known.""" + where = "Syntax error" + if line_number is not None: + where += f" at line {line_number}" + if module_name: + where += f" in module '{module_name}'" + return where + + def read_plain_source_metadata(plain_source_text): try: plain_source_obj = frontmatter.loads(plain_source_text) @@ -502,125 +396,173 @@ def read_plain_source_metadata(plain_source_text): return plain_source_obj -def parse_plain_source( # noqa: C901 - plain_source_text: str, - code_variables: dict, - template_dirs: list[str], - imported_modules: list[str], - modules_trace: list[str], - module_name: str | None = None, -) -> PlainFileParseResult: - plain_source_text = normalize_line_endings(plain_source_text) +TEST_PLAIN_FILE_SECTION_HEADINGS = (plain_spec.CONFORMANCE_TEST_HEADING, plain_spec.ACCEPTANCE_TEST_HEADING) - plain_source_obj = read_plain_source_metadata(plain_source_text) - - plain_source = PLAIN_SOURCE_TEMPLATE.copy() - - if IMPORT_DIRECTIVE in plain_source_obj.metadata: - required_concepts = process_imports( - plain_source, - plain_source_obj.metadata[IMPORT_DIRECTIVE], - code_variables, - template_dirs, - imported_modules, - modules_trace, - ) - else: - required_concepts = list[str]() - [_, loaded_templates] = loaders.get_loaded_templates(template_dirs, plain_source_text, module_name) +def check_no_test_sections(plain_source_full_text: str, module_name: str | None) -> None: + """Raise when a `***conformance tests***` or `***acceptance tests***` marker is anywhere in a core spec.""" + for line_number, line in enumerate(plain_source_full_text.split("\n"), start=1): + for heading in TEST_PLAIN_FILE_SECTION_HEADINGS: + if line.strip() == f"***{heading}***": + raise PlainSyntaxError( + f"Plain syntax error: {syntax_error_at(line_number, module_name)}: The `{heading}` section is not " + f"allowed in a core spec; test scenarios live in test plain files. {MIGRATION_HINT}" + ) - plain_source_content = restore_stripped_lines(plain_source_text, plain_source_obj.content) - plain_source_full_text = render_plain_source(plain_source_content, loaded_templates, code_variables) +def section_marker(token: Paragraph) -> str | None: + """The name in a `***name***` paragraph, or None when the paragraph is not a section marker.""" + paragraph_children = cast("Sequence[Token]", token.children) + if len(paragraph_children) != 1 or not isinstance(paragraph_children[0], Emphasis): + return None + emphasis_children = cast("Sequence[Token]", paragraph_children[0].children) + if len(emphasis_children) != 1 or not isinstance(emphasis_children[0], Strong): + return None + strong_children = cast("Sequence[Token]", emphasis_children[0].children) + return cast(RawText, strong_children[0]).content - plain_file = mistletoe.Document(io.StringIO(plain_source_full_text)) - remove_quotes(plain_file) +def read_sections( # noqa: C901 + plain_source: dict, document: mistletoe.Document, source_lines: list[str], module_name: str | None +) -> None: + """Add the sections of the rendered core spec to ``plain_source``. + A list section holds a mistletoe Document whose children are its list items; ``functional specs`` holds a list + of Functionality items, one per `# ` heading, each with the block tokens up to the next heading or + section marker. Raise on the first structural error. + """ current_specification_heading = None + current_functionality: Functionality | None = None processed_specification_headings = set[str]() - for token in plain_file.children: - token_text = "".join(get_raw_text(token)).strip() - if isinstance(token, Paragraph): - paragraph_children = cast("Sequence[Token]", token.children) - emphasis_children = ( - cast("Sequence[Token]", paragraph_children[0].children) - if paragraph_children and isinstance(paragraph_children[0], Emphasis) - else () + + def close_functionality(): + if current_functionality is not None and not current_functionality.body: + raise PlainSyntaxError( + f"Plain syntax error: {syntax_error_at(current_functionality.line_number, module_name)}: " + f"Functionality `{current_functionality.id}` has no body." ) - if not ( - len(paragraph_children) == 1 - and isinstance(paragraph_children[0], Emphasis) - and len(emphasis_children) == 1 - and isinstance(emphasis_children[0], Strong) - ): + for token in document.children: + token_text = "".join(get_raw_text(token)).strip() + where = syntax_error_at(token.line_number, module_name) + in_functional_specs = current_specification_heading == plain_spec.FUNCTIONAL_REQUIREMENTS + if isinstance(token, Heading): + if not in_functional_specs: raise PlainSyntaxError( - f"Plain syntax error: Syntax error at line {token.line_number}: Invalid specification (`{token_text}`)" + f"Plain syntax error: {where}: A `# ` heading is allowed only in the " + f"{plain_spec.FUNCTIONAL_REQUIREMENTS} section (`{token_text}`)." ) - - strong_children = cast("Sequence[Token]", emphasis_children[0].children) - specification_heading = cast(RawText, strong_children[0]).content - - if specification_heading == plain_spec.ACCEPTANCE_TEST_HEADING: + if token.level != 1: raise PlainSyntaxError( - f"Plain syntax error: Syntax error at line {token.line_number}: {plain_spec.ACCEPTANCE_TEST_HEADING} heading should be nested under specific functional spec." + f"Plain syntax error: {where}: Invalid heading level {token.level} (`{token_text}`). " + f"A functionality heading is `# `." ) + close_functionality() + current_functionality = Functionality(token_text, token.line_number) + plain_source[plain_spec.FUNCTIONAL_REQUIREMENTS].append(current_functionality) + elif isinstance(token, Paragraph): + specification_heading = section_marker(token) + if specification_heading is None: + if not in_functional_specs: + raise PlainSyntaxError(f"Plain syntax error: {where}: Invalid specification (`{token_text}`)") + if current_functionality is None: + raise PlainSyntaxError( + f"Plain syntax error: {where}: Content before the first `# ` heading in the " + f"{plain_spec.FUNCTIONAL_REQUIREMENTS} section (`{token_text}`)." + ) + current_functionality.body.append(token) + continue if specification_heading not in plain_spec.ALLOWED_SPECIFICATION_HEADINGS: raise PlainSyntaxError( - f"Plain syntax error: Syntax error at line {token.line_number}: Invalid specification heading (`{specification_heading}`). Allowed headings: {', '.join(plain_spec.ALLOWED_SPECIFICATION_HEADINGS)}" + f"Plain syntax error: {where}: Invalid specification heading (`{specification_heading}`). " + f"Allowed headings: {', '.join(plain_spec.ALLOWED_SPECIFICATION_HEADINGS)}" ) - if ( - specification_heading == plain_spec.FUNCTIONAL_REQUIREMENTS - and specification_heading not in plain_source - ): - plain_source[specification_heading] = None - if specification_heading in processed_specification_headings: raise PlainSyntaxError( - f"Plain syntax error: Syntax error at line {token.line_number}: Duplicate specification heading (`{specification_heading}`)" + f"Plain syntax error: {where}: Duplicate specification heading (`{specification_heading}`)" ) if specification_heading == plain_spec.DEFINITIONS and current_specification_heading is not None: raise PlainSyntaxError( - f"Plain syntax error: Syntax error at line {token.line_number}: Definitions specification must be the first specification in the section (`{token_text}`)" + f"Plain syntax error: {where}: Definitions specification must be the first specification in " + f"the section (`{token_text}`)" ) + close_functionality() + current_functionality = None current_specification_heading = specification_heading - if plain_source[current_specification_heading] is None: - plain_source[current_specification_heading] = mistletoe.Document("") + if specification_heading == plain_spec.FUNCTIONAL_REQUIREMENTS: + plain_source[specification_heading] = [] + elif plain_source[specification_heading] is None: + plain_source[specification_heading] = mistletoe.Document("") - processed_specification_headings.add(current_specification_heading) + processed_specification_headings.add(specification_heading) elif isinstance(token, List): if current_specification_heading is None: + raise PlainSyntaxError(f"Plain syntax error: {where}: Missing specification heading (`{token_text}`)") + if not in_functional_specs: + plain_source[current_specification_heading].children.extend(token.children) + continue + + if not source_lines[token.line_number - 1][:1].isspace(): raise PlainSyntaxError( - f"Plain syntax error: Syntax error at line {token.line_number}: Missing specification heading (`{token_text}`)" + f"Plain syntax error: {where}: A flush-left `- ` list in the {plain_spec.FUNCTIONAL_REQUIREMENTS} " + f"section is the old syntax; a functionality is a `# ` heading followed by its body. " + f"{MIGRATION_HINT}" ) - plain_source[current_specification_heading].children.extend(token.children) - + if current_functionality is None: + raise PlainSyntaxError( + f"Plain syntax error: {where}: Content before the first `# ` heading in the " + f"{plain_spec.FUNCTIONAL_REQUIREMENTS} section (`{token_text}`)." + ) + current_functionality.body.append(token) else: - raise PlainSyntaxError( - f"Plain syntax error: Syntax error at line {token.line_number}: Invalid source structure (`{token_text}`)" - ) + raise PlainSyntaxError(f"Plain syntax error: {where}: Invalid source structure (`{token_text}`)") - # Acceptance tests are only allowed nested under functional specs. Reject them when - # they are nested under any other section (e.g. definitions, implementation reqs, test reqs). - for non_functional_heading in ( - plain_spec.DEFINITIONS, - plain_spec.NON_FUNCTIONAL_REQUIREMENTS, - plain_spec.TEST_REQUIREMENTS, - ): - section = plain_source.get(non_functional_heading) - if section is None: - continue - nested_acceptance_test_heading = _find_acceptance_test_heading(section) - if nested_acceptance_test_heading is not None: - raise PlainSyntaxError( - f"Plain syntax error: Syntax error at line {nested_acceptance_test_heading.line_number}: {plain_spec.ACCEPTANCE_TEST_HEADING} heading should be nested under specific functional spec." - ) + close_functionality() + + +def parse_plain_source( # noqa: C901 + plain_source_text: str, + code_variables: dict, + template_dirs: list[str], + imported_modules: list[str], + modules_trace: list[str], + module_name: str | None = None, +) -> PlainFileParseResult: + plain_source_text = normalize_line_endings(plain_source_text) + + plain_source_obj = read_plain_source_metadata(plain_source_text) + + plain_source = PLAIN_SOURCE_TEMPLATE.copy() + + if IMPORT_DIRECTIVE in plain_source_obj.metadata: + required_concepts = process_imports( + plain_source, + plain_source_obj.metadata[IMPORT_DIRECTIVE], + code_variables, + template_dirs, + imported_modules, + modules_trace, + ) + else: + required_concepts = list[str]() + + [_, loaded_templates] = loaders.get_loaded_templates(template_dirs, plain_source_text, module_name) + + plain_source_content = restore_stripped_lines(plain_source_text, plain_source_obj.content) + + plain_source_full_text = render_plain_source(plain_source_content, loaded_templates, code_variables) + + check_no_test_sections(plain_source_full_text, module_name) + + plain_file = mistletoe.Document(io.StringIO(plain_source_full_text)) + + remove_quotes(plain_file) + + read_sections(plain_source, plain_file, plain_source_full_text.split("\n"), module_name) defined_concepts = set[str]() if plain_source[plain_spec.DEFINITIONS] is not None: @@ -866,12 +808,10 @@ def validate_and_build_module_tree( code_variables: dict, template_dirs: list[str], ) -> dict: - """Merge exported definitions, validate acceptance tests, linked resources and concepts, and return the - marshalled plain source tree.""" + """Merge exported definitions, validate linked resources and concepts, and return the marshalled plain source + tree.""" process_exported_definitions(plain_file_parse_result.plain_source, exported_definitions) - process_acceptance_tests(plain_file_parse_result.plain_source) - check_for_linked_resources(plain_file_parse_result.plain_source, template_dirs) marshalled_plain_source = marshall_plain_source(plain_file_parse_result.plain_source) diff --git a/src/plain_parser/plain_spec.py b/src/plain_parser/plain_spec.py index 3c224e5..c6bcbd1 100644 --- a/src/plain_parser/plain_spec.py +++ b/src/plain_parser/plain_spec.py @@ -11,18 +11,17 @@ NON_FUNCTIONAL_REQUIREMENTS = "implementation reqs" TEST_REQUIREMENTS = "test reqs" FUNCTIONAL_REQUIREMENTS = "functional specs" +CONFORMANCE_TESTS = "conformance_tests" ACCEPTANCE_TESTS = "acceptance_tests" +CONFORMANCE_TEST_HEADING = "conformance tests" ACCEPTANCE_TEST_HEADING = "acceptance tests" -ALLOWED_SPECIFICATION_HEADINGS = [ - DEFINITIONS, - NON_FUNCTIONAL_REQUIREMENTS, - TEST_REQUIREMENTS, - FUNCTIONAL_REQUIREMENTS, - ACCEPTANCE_TEST_HEADING, -] +# The sections whose content is a list of `- ` items. +LIST_SECTION_HEADINGS = [DEFINITIONS, NON_FUNCTIONAL_REQUIREMENTS, TEST_REQUIREMENTS] -ALLOWED_IMPORT_SPECIFICATION_HEADINGS = [DEFINITIONS, NON_FUNCTIONAL_REQUIREMENTS, TEST_REQUIREMENTS] +ALLOWED_SPECIFICATION_HEADINGS = [*LIST_SECTION_HEADINGS, FUNCTIONAL_REQUIREMENTS] + +ALLOWED_IMPORT_SPECIFICATION_HEADINGS = LIST_SECTION_HEADINGS def has_acceptance_tests(plain_source_tree) -> bool: diff --git a/tests/data/cli/acceptance_test_binary_resource.plain b/tests/data/cli/acceptance_test_binary_resource.plain deleted file mode 100644 index 8b7aaa2..0000000 --- a/tests/data/cli/acceptance_test_binary_resource.plain +++ /dev/null @@ -1,10 +0,0 @@ -***implementation reqs*** - -- An implementation requirement. - -***functional specs*** - -- A functionality. - - ***acceptance tests*** - - Behaves as described in [the binary](binary.bin). diff --git a/tests/data/cli/acceptance_test_resource.plain b/tests/data/cli/acceptance_test_resource.plain deleted file mode 100644 index 017bbae..0000000 --- a/tests/data/cli/acceptance_test_resource.plain +++ /dev/null @@ -1,10 +0,0 @@ -***implementation reqs*** - -- An implementation requirement. - -***functional specs*** - -- A functionality. - - ***acceptance tests*** - - Behaves as described in [the notes](notes.md). diff --git a/tests/data/cli/base64_resource.plain b/tests/data/cli/base64_resource.plain index a367653..2334ce7 100644 --- a/tests/data/cli/base64_resource.plain +++ b/tests/data/cli/base64_resource.plain @@ -4,4 +4,5 @@ ***functional specs*** -- A functionality. +# a-functionality +A functionality. diff --git a/tests/data/cli/binary_resource.plain b/tests/data/cli/binary_resource.plain index 0bca357..a07c31c 100644 --- a/tests/data/cli/binary_resource.plain +++ b/tests/data/cli/binary_resource.plain @@ -4,4 +4,5 @@ ***functional specs*** -- A functionality. +# a-functionality +A functionality. diff --git a/tests/data/cli/code_variable_conflict.plain b/tests/data/cli/code_variable_conflict.plain index eb087c5..356bcec 100644 --- a/tests/data/cli/code_variable_conflict.plain +++ b/tests/data/cli/code_variable_conflict.plain @@ -4,6 +4,8 @@ ***functional specs*** -- Implement {% include "code_variable_template.plain", variable_name: "first" %} +# implement-include-code-variable-template +Implement {% include "code_variable_template.plain", variable_name: "first" %} -- Implement {% include "code_variable_template.plain", variable_name: "second" %} +# implement-include-code-variable-template-2 +Implement {% include "code_variable_template.plain", variable_name: "second" %} diff --git a/tests/data/cli/missing_resource.plain b/tests/data/cli/missing_resource.plain index e0e687c..0916c7f 100644 --- a/tests/data/cli/missing_resource.plain +++ b/tests/data/cli/missing_resource.plain @@ -4,4 +4,5 @@ ***functional specs*** -- A functionality. +# a-functionality +A functionality. diff --git a/tests/data/cli/parent_link/a/spec/top.plain b/tests/data/cli/parent_link/a/spec/top.plain index 7065c83..87bb201 100644 --- a/tests/data/cli/parent_link/a/spec/top.plain +++ b/tests/data/cli/parent_link/a/spec/top.plain @@ -4,4 +4,5 @@ ***functional specs*** -- A functionality. See [the notes](../shared/notes.md). +# a-functionality-see-the-notes +A functionality. See [the notes](../shared/notes.md). diff --git a/tests/data/cli/requires_binary_resource_base.plain b/tests/data/cli/requires_binary_resource_base.plain index 253e5a1..ad78fda 100644 --- a/tests/data/cli/requires_binary_resource_base.plain +++ b/tests/data/cli/requires_binary_resource_base.plain @@ -4,4 +4,5 @@ ***functional specs*** -- A base functionality. +# a-base-functionality +A base functionality. diff --git a/tests/data/cli/requires_binary_resource_top.plain b/tests/data/cli/requires_binary_resource_top.plain index 33ea1ac..3f6fb79 100644 --- a/tests/data/cli/requires_binary_resource_top.plain +++ b/tests/data/cli/requires_binary_resource_top.plain @@ -9,4 +9,5 @@ requires: ***functional specs*** -- A top functionality. +# a-top-functionality +A top functionality. diff --git a/tests/data/cli/resource_before_frid_walk/base.plain b/tests/data/cli/resource_before_frid_walk/base.plain index 97044b1..7afab60 100644 --- a/tests/data/cli/resource_before_frid_walk/base.plain +++ b/tests/data/cli/resource_before_frid_walk/base.plain @@ -5,4 +5,5 @@ ***functional specs*** -- :Implementation: has an entry point. +# implementation-has-an-entry-point +:Implementation: has an entry point. diff --git a/tests/data/cli/resource_before_frid_walk/top.plain b/tests/data/cli/resource_before_frid_walk/top.plain index 4ab7c68..9414b9e 100644 --- a/tests/data/cli/resource_before_frid_walk/top.plain +++ b/tests/data/cli/resource_before_frid_walk/top.plain @@ -9,6 +9,8 @@ requires: ***functional specs*** -- :Implementation: names {% include "snippet.plain", name: "first" %}. +# implementation-names-include-snippet-plain +:Implementation: names {% include "snippet.plain", name: "first" %}. -- :Implementation: names {% include "snippet.plain", name: "second" %}. +# implementation-names-include-snippet-plain-2 +:Implementation: names {% include "snippet.plain", name: "second" %}. diff --git a/tests/data/cli/shared_link_helper.plain b/tests/data/cli/shared_link_helper.plain index 61f18e7..1c0660c 100644 --- a/tests/data/cli/shared_link_helper.plain +++ b/tests/data/cli/shared_link_helper.plain @@ -4,4 +4,5 @@ ***functional specs*** -- A functionality. See [the notes](notes.md). +# a-functionality-see-the-notes +A functionality. See [the notes](notes.md). diff --git a/tests/data/cli/shared_link_top.plain b/tests/data/cli/shared_link_top.plain index 19b565d..e7d2c93 100644 --- a/tests/data/cli/shared_link_top.plain +++ b/tests/data/cli/shared_link_top.plain @@ -9,4 +9,5 @@ requires: ***functional specs*** -- A functionality. Also [the notes](notes.md). +# a-functionality-also-the-notes +A functionality. Also [the notes](notes.md). diff --git a/tests/data/cli/subdir/nested.plain b/tests/data/cli/subdir/nested.plain index 72f8371..f17036e 100644 --- a/tests/data/cli/subdir/nested.plain +++ b/tests/data/cli/subdir/nested.plain @@ -4,4 +4,5 @@ ***functional specs*** -- A nested functionality. +# a-nested-functionality +A nested functionality. diff --git a/tests/data/cli/template_link/spec/top.plain b/tests/data/cli/template_link/spec/top.plain index 61f18e7..1c0660c 100644 --- a/tests/data/cli/template_link/spec/top.plain +++ b/tests/data/cli/template_link/spec/top.plain @@ -4,4 +4,5 @@ ***functional specs*** -- A functionality. See [the notes](notes.md). +# a-functionality-see-the-notes +A functionality. See [the notes](notes.md). diff --git a/tests/data/cli/undefined_concept.plain b/tests/data/cli/undefined_concept.plain index adaa93a..53c8b1b 100644 --- a/tests/data/cli/undefined_concept.plain +++ b/tests/data/cli/undefined_concept.plain @@ -8,6 +8,8 @@ ***functional specs*** -- :App: greets the :User:. +# app-greets-the-user +:App: greets the :User:. -- :App: says goodbye to the :Visitor:. +# app-says-goodbye-to-the +:App: says goodbye to the :Visitor:. diff --git a/tests/data/cli/valid.plain b/tests/data/cli/valid.plain index 349be5a..f6dd1d5 100644 --- a/tests/data/cli/valid.plain +++ b/tests/data/cli/valid.plain @@ -8,4 +8,5 @@ ***functional specs*** -- :App: prints "hello, world". +# app-prints-hello-world +:App: prints "hello, world". diff --git a/tests/data/imports/circular_imports_main.plain b/tests/data/imports/circular_imports_main.plain index 828c0b6..b673d41 100644 --- a/tests/data/imports/circular_imports_main.plain +++ b/tests/data/imports/circular_imports_main.plain @@ -9,4 +9,5 @@ import: ***functional specs*** -- Display "hello, world" \ No newline at end of file +# display-hello-world +Display "hello, world" \ No newline at end of file diff --git a/tests/data/imports/diamond_imports_main.plain b/tests/data/imports/diamond_imports_main.plain index 9d06ea2..05f5820 100644 --- a/tests/data/imports/diamond_imports_main.plain +++ b/tests/data/imports/diamond_imports_main.plain @@ -10,4 +10,5 @@ import: ***functional specs*** -- Display "hello, world" \ No newline at end of file +# display-hello-world +Display "hello, world" \ No newline at end of file diff --git a/tests/data/imports/import_with_functionalities.plain b/tests/data/imports/import_with_functionalities.plain index 577041d..e4c01da 100644 --- a/tests/data/imports/import_with_functionalities.plain +++ b/tests/data/imports/import_with_functionalities.plain @@ -4,4 +4,5 @@ ***functional specs*** -- Display "hello, world" +# display-hello-world +Display "hello, world" diff --git a/tests/data/imports/imports_module_with_functionalities.plain b/tests/data/imports/imports_module_with_functionalities.plain index f2ff517..d497923 100644 --- a/tests/data/imports/imports_module_with_functionalities.plain +++ b/tests/data/imports/imports_module_with_functionalities.plain @@ -9,4 +9,5 @@ import: ***functional specs*** -- Display "hello, world" +# display-hello-world +Display "hello, world" diff --git a/tests/data/imports/non_existent_import.plain b/tests/data/imports/non_existent_import.plain index e3c902d..035b6d7 100644 --- a/tests/data/imports/non_existent_import.plain +++ b/tests/data/imports/non_existent_import.plain @@ -9,4 +9,5 @@ import: ***functional specs*** -- Display "hello, world" \ No newline at end of file +# display-hello-world +Display "hello, world" \ No newline at end of file diff --git a/tests/data/plainfile/missing_non_functional_requirements.plain b/tests/data/plainfile/missing_non_functional_requirements.plain index 8c4aa76..10049d6 100644 --- a/tests/data/plainfile/missing_non_functional_requirements.plain +++ b/tests/data/plainfile/missing_non_functional_requirements.plain @@ -2,4 +2,5 @@ - Test definition ***functional specs*** -- Test functionality +# test-functionality +Test functionality diff --git a/tests/data/plainfile/plain_source_with_absolute_link.plain b/tests/data/plainfile/plain_source_with_absolute_link.plain index 627d089..504afe2 100644 --- a/tests/data/plainfile/plain_source_with_absolute_link.plain +++ b/tests/data/plainfile/plain_source_with_absolute_link.plain @@ -6,4 +6,5 @@ ***functional specs*** -- Absolute links are not allowed for now [url_link](/link/to/absolute.file). +# absolute-links-are-not-allowed +Absolute links are not allowed for now [url_link](/link/to/absolute.file). diff --git a/tests/data/plainfile/plain_source_with_missing_link.plain b/tests/data/plainfile/plain_source_with_missing_link.plain index 4f06694..f6fb406 100644 --- a/tests/data/plainfile/plain_source_with_missing_link.plain +++ b/tests/data/plainfile/plain_source_with_missing_link.plain @@ -6,4 +6,5 @@ ***functional specs*** -- Links must point at an existing file [missing resource](resources/missing_resource.yaml). +# links-must-point-at-an +Links must point at an existing file [missing resource](resources/missing_resource.yaml). diff --git a/tests/data/plainfile/plain_source_with_url_link.plain b/tests/data/plainfile/plain_source_with_url_link.plain index 47efdd1..c569e65 100644 --- a/tests/data/plainfile/plain_source_with_url_link.plain +++ b/tests/data/plainfile/plain_source_with_url_link.plain @@ -6,4 +6,5 @@ ***functional specs*** -- URL links are not allowed for now [url_link](http://www.example.com). +# url-links-are-not-allowed +URL links are not allowed for now [url_link](http://www.example.com). diff --git a/tests/data/plainfile/task_manager_with_reference_links.plain b/tests/data/plainfile/task_manager_with_reference_links.plain index bbddc31..98d3423 100644 --- a/tests/data/plainfile/task_manager_with_reference_links.plain +++ b/tests/data/plainfile/task_manager_with_reference_links.plain @@ -27,14 +27,20 @@ ***functional specs*** -- Implement the entry point for :App:. +# implement-the-entry-point-for +Implement the entry point for :App:. -- Show :TaskList:. The details of the user interface are provided in the file [task_list_ui_specification.yaml](task_list_ui_specification.yaml). +# show-tasklist-the-details-of +Show :TaskList:. The details of the user interface are provided in the file [task_list_ui_specification.yaml](task_list_ui_specification.yaml). -- :User: should be able to add :Task:. Only valid :Task: items can be added. The details of the user interface are provided in the file [add_new_task_modal_specification.yaml](add_new_task_modal_specification.yaml). +# user-should-be-able-to +:User: should be able to add :Task:. Only valid :Task: items can be added. The details of the user interface are provided in the file [add_new_task_modal_specification.yaml](add_new_task_modal_specification.yaml). -- :User: should be able to delete :Task:. +# user-should-be-able-to-2 +:User: should be able to delete :Task:. -- :User: should be able to edit :Task:. +# user-should-be-able-to-3 +:User: should be able to edit :Task:. -- :User: should be able to mark :Task: as completed. +# user-should-be-able-to-4 +:User: should be able to mark :Task: as completed. diff --git a/tests/data/plainfile/without_non_functional_requirement.plain b/tests/data/plainfile/without_non_functional_requirement.plain index 1aca180..d9a44de 100644 --- a/tests/data/plainfile/without_non_functional_requirement.plain +++ b/tests/data/plainfile/without_non_functional_requirement.plain @@ -2,4 +2,5 @@ ***functional specs*** -- Simple functionality +# simple-functionality +Simple functionality diff --git a/tests/data/plainfileparser/concept_validation_acceptance_tests.plain b/tests/data/plainfileparser/concept_validation_acceptance_tests.plain deleted file mode 100644 index f9b5495..0000000 --- a/tests/data/plainfileparser/concept_validation_acceptance_tests.plain +++ /dev/null @@ -1,20 +0,0 @@ -***definitions*** - -- :concept: is a concept. - -***test reqs*** - -- You can test the :concept:. - -***implementation reqs*** - -- You cannot solve the hardest problem. -- If you do, let me know. - -***functional specs*** - -- Display "hello, world" - - ***acceptance tests*** - - - Test the :concept:. diff --git a/tests/data/plainfileparser/concept_validation_acceptance_tests_nondefined.plain b/tests/data/plainfileparser/concept_validation_acceptance_tests_nondefined.plain deleted file mode 100644 index f492c45..0000000 --- a/tests/data/plainfileparser/concept_validation_acceptance_tests_nondefined.plain +++ /dev/null @@ -1,20 +0,0 @@ -***definitions*** - -- :concept: is a concept. - -***test reqs*** - -- You can test the :concept:. - -***implementation reqs*** - -- You cannot solve the hardest problem. -- If you do, let me know. - -***functional specs*** - -- Display "hello, world" - - ***acceptance tests*** - - - Test the :nonDefinedConcept:. diff --git a/tests/data/plainfileparser/concept_validation_defined_nondefined.plain b/tests/data/plainfileparser/concept_validation_defined_nondefined.plain index 0a31671..7f51fef 100644 --- a/tests/data/plainfileparser/concept_validation_defined_nondefined.plain +++ b/tests/data/plainfileparser/concept_validation_defined_nondefined.plain @@ -15,4 +15,5 @@ ***functional specs*** -- Display "hello, world" +# display-hello-world +Display "hello, world" diff --git a/tests/data/plainfileparser/concept_validation_defined_nondefined_2.plain b/tests/data/plainfileparser/concept_validation_defined_nondefined_2.plain index b3fcb6a..9a318b7 100644 --- a/tests/data/plainfileparser/concept_validation_defined_nondefined_2.plain +++ b/tests/data/plainfileparser/concept_validation_defined_nondefined_2.plain @@ -15,4 +15,5 @@ ***functional specs*** -- Implement the :nonDefinedConcept:. +# implement-the-nondefinedconcept +Implement the :nonDefinedConcept:. diff --git a/tests/data/plainfileparser/concept_validation_definition.plain b/tests/data/plainfileparser/concept_validation_definition.plain index 1885e67..a207b4d 100644 --- a/tests/data/plainfileparser/concept_validation_definition.plain +++ b/tests/data/plainfileparser/concept_validation_definition.plain @@ -13,4 +13,5 @@ ***functional specs*** -- Display "hello, world" +# display-hello-world +Display "hello, world" diff --git a/tests/data/plainfileparser/concept_validation_noconcepts.plain b/tests/data/plainfileparser/concept_validation_noconcepts.plain index e47b786..c8c9e31 100644 --- a/tests/data/plainfileparser/concept_validation_noconcepts.plain +++ b/tests/data/plainfileparser/concept_validation_noconcepts.plain @@ -14,4 +14,5 @@ ***functional specs*** -- Display "hello, world" +# display-hello-world +Display "hello, world" diff --git a/tests/data/plainfileparser/concept_validation_nonconcept.plain b/tests/data/plainfileparser/concept_validation_nonconcept.plain index c42c5e3..e4255a1 100644 --- a/tests/data/plainfileparser/concept_validation_nonconcept.plain +++ b/tests/data/plainfileparser/concept_validation_nonconcept.plain @@ -12,4 +12,5 @@ ***functional specs*** -- Display "hello, world" +# display-hello-world +Display "hello, world" diff --git a/tests/data/plainfileparser/concept_validation_nondefined.plain b/tests/data/plainfileparser/concept_validation_nondefined.plain index b8cc98c..6f7fb05 100644 --- a/tests/data/plainfileparser/concept_validation_nondefined.plain +++ b/tests/data/plainfileparser/concept_validation_nondefined.plain @@ -15,4 +15,5 @@ ***functional specs*** -- Display "hello, world" \ No newline at end of file +# display-hello-world +Display "hello, world" \ No newline at end of file diff --git a/tests/data/plainfileparser/concept_validation_redefinition.plain b/tests/data/plainfileparser/concept_validation_redefinition.plain index 2f1793a..9e38375 100644 --- a/tests/data/plainfileparser/concept_validation_redefinition.plain +++ b/tests/data/plainfileparser/concept_validation_redefinition.plain @@ -15,4 +15,5 @@ ***functional specs*** -- Display "hello, world" +# display-hello-world +Display "hello, world" diff --git a/tests/data/plainfileparser/concept_validation_several_concepts.plain b/tests/data/plainfileparser/concept_validation_several_concepts.plain index 741576b..cf5a77d 100644 --- a/tests/data/plainfileparser/concept_validation_several_concepts.plain +++ b/tests/data/plainfileparser/concept_validation_several_concepts.plain @@ -8,6 +8,9 @@ ***functional specs*** -- Display :concept1:. -- Display :concept2:. -- Display :concept3:. +# display-concept1 +Display :concept1:. +# display-concept2 +Display :concept2:. +# display-concept3 +Display :concept3:. diff --git a/tests/data/plainfileparser/concept_validation_valid.plain b/tests/data/plainfileparser/concept_validation_valid.plain index 0878022..f153142 100644 --- a/tests/data/plainfileparser/concept_validation_valid.plain +++ b/tests/data/plainfileparser/concept_validation_valid.plain @@ -13,4 +13,5 @@ ***functional specs*** -- Display "hello, world" +# display-hello-world +Display "hello, world" diff --git a/tests/data/plainfileparser/cyclic_definitions.plain b/tests/data/plainfileparser/cyclic_definitions.plain index 154492d..7d0db92 100644 --- a/tests/data/plainfileparser/cyclic_definitions.plain +++ b/tests/data/plainfileparser/cyclic_definitions.plain @@ -6,4 +6,5 @@ - The :A: concept must be implemented. ***functional specs*** -- Implement a wrapper for the :A: concept. +# implement-a-wrapper-for-the +Implement a wrapper for the :A: concept. diff --git a/tests/data/plainfileparser/cyclic_definitions_across_import.plain b/tests/data/plainfileparser/cyclic_definitions_across_import.plain index 8c26f13..11a1732 100644 --- a/tests/data/plainfileparser/cyclic_definitions_across_import.plain +++ b/tests/data/plainfileparser/cyclic_definitions_across_import.plain @@ -10,4 +10,5 @@ import: - The :Concept: concept must be implemented. ***functional specs*** -- Implement the :Concept: concept. +# implement-the-concept-concept +Implement the :Concept: concept. diff --git a/tests/data/plainfileparser/cyclic_definitions_long.plain b/tests/data/plainfileparser/cyclic_definitions_long.plain index 9f4c071..31b8ea3 100644 --- a/tests/data/plainfileparser/cyclic_definitions_long.plain +++ b/tests/data/plainfileparser/cyclic_definitions_long.plain @@ -8,4 +8,5 @@ - The :D: concept must be implemented. ***functional specs*** -- Implement the :D: concept. +# implement-the-d-concept +Implement the :D: concept. diff --git a/tests/data/plainfileparser/cyclic_definitions_sub_bullet.plain b/tests/data/plainfileparser/cyclic_definitions_sub_bullet.plain index 08006e9..b5c6e53 100644 --- a/tests/data/plainfileparser/cyclic_definitions_sub_bullet.plain +++ b/tests/data/plainfileparser/cyclic_definitions_sub_bullet.plain @@ -8,4 +8,5 @@ - The :Receipt: concept must be implemented. ***functional specs*** -- Implement the :Receipt: concept. +# implement-the-receipt-concept +Implement the :Receipt: concept. diff --git a/tests/data/plainfileparser/exported_concepts.plain b/tests/data/plainfileparser/exported_concepts.plain index a8425c9..08ea994 100644 --- a/tests/data/plainfileparser/exported_concepts.plain +++ b/tests/data/plainfileparser/exported_concepts.plain @@ -10,4 +10,5 @@ exported_concepts: - The :Concept: concept must be implemented. ***functional specs*** -- Implement a wrapper for the :Concept: concept. \ No newline at end of file +# implement-a-wrapper-for-the +Implement a wrapper for the :Concept: concept. \ No newline at end of file diff --git a/tests/data/plainfileparser/exported_concepts_defaults_exported.plain b/tests/data/plainfileparser/exported_concepts_defaults_exported.plain index 014a009..7f986de 100644 --- a/tests/data/plainfileparser/exported_concepts_defaults_exported.plain +++ b/tests/data/plainfileparser/exported_concepts_defaults_exported.plain @@ -10,4 +10,5 @@ exported_concepts: - The :NewConcept: must be implemented. ***functional specs*** -- Implement a wrapper for the :NewConcept: concept. \ No newline at end of file +# implement-a-wrapper-for-the +Implement a wrapper for the :NewConcept: concept. \ No newline at end of file diff --git a/tests/data/plainfileparser/exported_concepts_example.plain b/tests/data/plainfileparser/exported_concepts_example.plain index 4747839..fa62d9a 100644 --- a/tests/data/plainfileparser/exported_concepts_example.plain +++ b/tests/data/plainfileparser/exported_concepts_example.plain @@ -10,4 +10,5 @@ requires: - The :DependantConcept: concept must be implemented. ***functional specs*** -- Implement a wrapper for the :DependantConcept: concept. \ No newline at end of file +# implement-a-wrapper-for-the +Implement a wrapper for the :DependantConcept: concept. \ No newline at end of file diff --git a/tests/data/plainfileparser/exported_concepts_imported_exported.plain b/tests/data/plainfileparser/exported_concepts_imported_exported.plain index 8c3e983..ce5f206 100644 --- a/tests/data/plainfileparser/exported_concepts_imported_exported.plain +++ b/tests/data/plainfileparser/exported_concepts_imported_exported.plain @@ -9,4 +9,5 @@ exported_concepts: - The :Concept: concept must be implemented. ***functional specs*** -- Implement a wrapper for the :Concept: concept. \ No newline at end of file +# implement-a-wrapper-for-the +Implement a wrapper for the :Concept: concept. \ No newline at end of file diff --git a/tests/data/plainfileparser/exported_concepts_inherited_reexported.plain b/tests/data/plainfileparser/exported_concepts_inherited_reexported.plain index 660943c..9935ce9 100644 --- a/tests/data/plainfileparser/exported_concepts_inherited_reexported.plain +++ b/tests/data/plainfileparser/exported_concepts_inherited_reexported.plain @@ -12,4 +12,5 @@ exported_concepts: - The :DependantConcept: concept must be implemented. ***functional specs*** -- Implement a wrapper for the :DependantConcept: concept. +# implement-a-wrapper-for-the +Implement a wrapper for the :DependantConcept: concept. diff --git a/tests/data/plainfileparser/exported_concepts_malformed.plain b/tests/data/plainfileparser/exported_concepts_malformed.plain index c5528ff..4cdb2bb 100644 --- a/tests/data/plainfileparser/exported_concepts_malformed.plain +++ b/tests/data/plainfileparser/exported_concepts_malformed.plain @@ -10,4 +10,5 @@ exported_concepts: - The :A: concept must be implemented. ***functional specs*** -- Implement a wrapper for the :A: concept. \ No newline at end of file +# implement-a-wrapper-for-the +Implement a wrapper for the :A: concept. \ No newline at end of file diff --git a/tests/data/plainfileparser/exported_concepts_missing_definition.plain b/tests/data/plainfileparser/exported_concepts_missing_definition.plain index 6c0ae87..0e5d781 100644 --- a/tests/data/plainfileparser/exported_concepts_missing_definition.plain +++ b/tests/data/plainfileparser/exported_concepts_missing_definition.plain @@ -11,4 +11,5 @@ exported_concepts: - The :NewConcept: must be implemented. ***functional specs*** -- Implement a wrapper for the :NewConcept: concept. \ No newline at end of file +# implement-a-wrapper-for-the +Implement a wrapper for the :NewConcept: concept. \ No newline at end of file diff --git a/tests/data/plainfileparser/exported_concepts_missing_definition_example.plain b/tests/data/plainfileparser/exported_concepts_missing_definition_example.plain index 7ad273d..7403ecc 100644 --- a/tests/data/plainfileparser/exported_concepts_missing_definition_example.plain +++ b/tests/data/plainfileparser/exported_concepts_missing_definition_example.plain @@ -10,4 +10,5 @@ requires: - :AnotherConcept: must be implemented. ***functional specs*** -- Implement a wrapper for the :AnotherConcept: concept. +# implement-a-wrapper-for-the +Implement a wrapper for the :AnotherConcept: concept. diff --git a/tests/data/plainfileparser/exported_concepts_multiple_per_bullet.plain b/tests/data/plainfileparser/exported_concepts_multiple_per_bullet.plain index fc05887..3698535 100644 --- a/tests/data/plainfileparser/exported_concepts_multiple_per_bullet.plain +++ b/tests/data/plainfileparser/exported_concepts_multiple_per_bullet.plain @@ -11,4 +11,5 @@ exported_concepts: - The :NewConcept: and :OtherConcept: concepts must be implemented. ***functional specs*** -- Implement a wrapper for the :NewConcept: and :OtherConcept: concepts. +# implement-a-wrapper-for-the +Implement a wrapper for the :NewConcept: and :OtherConcept: concepts. diff --git a/tests/data/plainfileparser/exported_concepts_multiple_per_bullet_undefined.plain b/tests/data/plainfileparser/exported_concepts_multiple_per_bullet_undefined.plain index 064ee9c..c72ecd3 100644 --- a/tests/data/plainfileparser/exported_concepts_multiple_per_bullet_undefined.plain +++ b/tests/data/plainfileparser/exported_concepts_multiple_per_bullet_undefined.plain @@ -10,4 +10,5 @@ exported_concepts: - The :NewConcept: concept must be implemented. ***functional specs*** -- Implement a wrapper for the :NewConcept: concept. +# implement-a-wrapper-for-the +Implement a wrapper for the :NewConcept: concept. diff --git a/tests/data/plainfileparser/exported_concepts_nested.plain b/tests/data/plainfileparser/exported_concepts_nested.plain index 618ad06..a479aef 100644 --- a/tests/data/plainfileparser/exported_concepts_nested.plain +++ b/tests/data/plainfileparser/exported_concepts_nested.plain @@ -12,4 +12,5 @@ exported_concepts: - The :NestedConcept: concept must be implemented. ***functional specs*** -- Implement a wrapper for the :NestedConcept: concept. \ No newline at end of file +# implement-a-wrapper-for-the +Implement a wrapper for the :NestedConcept: concept. \ No newline at end of file diff --git a/tests/data/plainfileparser/exported_concepts_nested_example.plain b/tests/data/plainfileparser/exported_concepts_nested_example.plain index bb99c9d..b3479c7 100644 --- a/tests/data/plainfileparser/exported_concepts_nested_example.plain +++ b/tests/data/plainfileparser/exported_concepts_nested_example.plain @@ -10,4 +10,5 @@ requires: - The :DependantConcept: concept must be implemented. ***functional specs*** -- Implement a wrapper for the :DependantConcept: concept. \ No newline at end of file +# implement-a-wrapper-for-the +Implement a wrapper for the :DependantConcept: concept. \ No newline at end of file diff --git a/tests/data/plainfileparser/exported_concepts_no_definitions.plain b/tests/data/plainfileparser/exported_concepts_no_definitions.plain index 5e8ec49..1985bd2 100644 --- a/tests/data/plainfileparser/exported_concepts_no_definitions.plain +++ b/tests/data/plainfileparser/exported_concepts_no_definitions.plain @@ -7,4 +7,5 @@ exported_concepts: - :Implementation: is a simple wrapper. ***functional specs*** -- :Implementation: has an entry point. \ No newline at end of file +# implementation-has-an-entry-point +:Implementation: has an entry point. \ No newline at end of file diff --git a/tests/data/plainfileparser/exported_concepts_not_declared.plain b/tests/data/plainfileparser/exported_concepts_not_declared.plain index a9e0ba5..1ebf1db 100644 --- a/tests/data/plainfileparser/exported_concepts_not_declared.plain +++ b/tests/data/plainfileparser/exported_concepts_not_declared.plain @@ -5,4 +5,5 @@ - The :Concept: concept must be implemented. ***functional specs*** -- Implement a wrapper for the :Concept: concept. \ No newline at end of file +# implement-a-wrapper-for-the +Implement a wrapper for the :Concept: concept. \ No newline at end of file diff --git a/tests/data/plainfileparser/exported_concepts_not_declared_example.plain b/tests/data/plainfileparser/exported_concepts_not_declared_example.plain index c3b77d2..30412f8 100644 --- a/tests/data/plainfileparser/exported_concepts_not_declared_example.plain +++ b/tests/data/plainfileparser/exported_concepts_not_declared_example.plain @@ -10,4 +10,5 @@ requires: - The :DependantConcept: concept must be implemented. ***functional specs*** -- Implement a wrapper for the :DependantConcept: concept. \ No newline at end of file +# implement-a-wrapper-for-the +Implement a wrapper for the :DependantConcept: concept. \ No newline at end of file diff --git a/tests/data/plainfileparser/exported_concepts_transitive_example.plain b/tests/data/plainfileparser/exported_concepts_transitive_example.plain index 781c383..c59cb60 100644 --- a/tests/data/plainfileparser/exported_concepts_transitive_example.plain +++ b/tests/data/plainfileparser/exported_concepts_transitive_example.plain @@ -10,4 +10,5 @@ requires: - The :SomeOtherDependantConcept: concept must be implemented. ***functional specs*** -- Implement a wrapper for the :SomeOtherDependantConcept: concept. \ No newline at end of file +# implement-a-wrapper-for-the +Implement a wrapper for the :SomeOtherDependantConcept: concept. \ No newline at end of file diff --git a/tests/data/plainfileparser/exported_concepts_transitive_l1.plain b/tests/data/plainfileparser/exported_concepts_transitive_l1.plain index 45e698d..d260db1 100644 --- a/tests/data/plainfileparser/exported_concepts_transitive_l1.plain +++ b/tests/data/plainfileparser/exported_concepts_transitive_l1.plain @@ -10,4 +10,5 @@ requires: - The :DependantConcept: concept must be implemented. ***functional specs*** -- Implement a wrapper for the :DependantConcept: concept. \ No newline at end of file +# implement-a-wrapper-for-the +Implement a wrapper for the :DependantConcept: concept. \ No newline at end of file diff --git a/tests/data/plainfileparser/exported_concepts_transitive_l2.plain b/tests/data/plainfileparser/exported_concepts_transitive_l2.plain index 35bfa92..046411f 100644 --- a/tests/data/plainfileparser/exported_concepts_transitive_l2.plain +++ b/tests/data/plainfileparser/exported_concepts_transitive_l2.plain @@ -10,4 +10,5 @@ exported_concepts: - The :Concept: concept must be implemented. ***functional specs*** -- Implement the :Concept: concept. \ No newline at end of file +# implement-the-concept-concept +Implement the :Concept: concept. \ No newline at end of file diff --git a/tests/data/plainfileparser/old_syntax_functional_specs.plain b/tests/data/plainfileparser/old_syntax_functional_specs.plain new file mode 100644 index 0000000..71f673c --- /dev/null +++ b/tests/data/plainfileparser/old_syntax_functional_specs.plain @@ -0,0 +1,11 @@ +***definitions*** + +***implementation reqs*** + +- First implementation requirement. + +- Second implementation requirement. + + +***functional specs*** +- Display "hello, world" diff --git a/tests/data/plainfileparser/plain_file_parser_with_comments.plain b/tests/data/plainfileparser/plain_file_parser_with_comments.plain index e1320b2..7e26161 100644 --- a/tests/data/plainfileparser/plain_file_parser_with_comments.plain +++ b/tests/data/plainfileparser/plain_file_parser_with_comments.plain @@ -5,4 +5,5 @@ - Second implementation requirement. ***functional specs*** -- Display "hello, world" +# display-hello-world +Display "hello, world" diff --git a/tests/data/plainfileparser/plain_file_with_comments_indented.plain b/tests/data/plainfileparser/plain_file_with_comments_indented.plain index 58faf5b..4876bbb 100644 --- a/tests/data/plainfileparser/plain_file_with_comments_indented.plain +++ b/tests/data/plainfileparser/plain_file_with_comments_indented.plain @@ -7,4 +7,5 @@ > This is a comment ***functional specs*** -- Display "hello, world" \ No newline at end of file +# display-hello-world +Display "hello, world" \ No newline at end of file diff --git a/tests/data/plainfileparser/regular_plain_source.plain b/tests/data/plainfileparser/regular_plain_source.plain index 71f673c..ade2231 100644 --- a/tests/data/plainfileparser/regular_plain_source.plain +++ b/tests/data/plainfileparser/regular_plain_source.plain @@ -8,4 +8,5 @@ ***functional specs*** -- Display "hello, world" +# display-hello-world +Display "hello, world" diff --git a/tests/data/plainfileparser/required_concepts_example.plain b/tests/data/plainfileparser/required_concepts_example.plain index 1bda063..1cf4e8c 100644 --- a/tests/data/plainfileparser/required_concepts_example.plain +++ b/tests/data/plainfileparser/required_concepts_example.plain @@ -10,4 +10,5 @@ import: - The :Concept: concept must be implemented. ***functional specs*** -- Implement the :Concept: concept. \ No newline at end of file +# implement-the-concept-concept +Implement the :Concept: concept. \ No newline at end of file diff --git a/tests/data/plainfileparser/required_concepts_missing.plain b/tests/data/plainfileparser/required_concepts_missing.plain index 6ef43a7..c3b4a75 100644 --- a/tests/data/plainfileparser/required_concepts_missing.plain +++ b/tests/data/plainfileparser/required_concepts_missing.plain @@ -7,4 +7,5 @@ import: - The stuff must be implemented. ***functional specs*** -- Implement stuff. \ No newline at end of file +# implement-stuff +Implement stuff. \ No newline at end of file diff --git a/tests/data/plainfileparser/required_concepts_module.plain b/tests/data/plainfileparser/required_concepts_module.plain index 1bbd136..a16cd48 100644 --- a/tests/data/plainfileparser/required_concepts_module.plain +++ b/tests/data/plainfileparser/required_concepts_module.plain @@ -8,4 +8,5 @@ import: - The :Concept: concept must be implemented. ***functional specs*** -- Implement the :Concept: concept. \ No newline at end of file +# implement-the-concept-concept +Implement the :Concept: concept. \ No newline at end of file diff --git a/tests/data/plainfileparser/required_concepts_partial.plain b/tests/data/plainfileparser/required_concepts_partial.plain index 938568a..9aef1a1 100644 --- a/tests/data/plainfileparser/required_concepts_partial.plain +++ b/tests/data/plainfileparser/required_concepts_partial.plain @@ -10,4 +10,5 @@ import: - These are nested required concepts. ***functional specs*** -- Implement the concepts. \ No newline at end of file +# implement-the-concepts +Implement the concepts. \ No newline at end of file diff --git a/tests/data/plainfileparser/required_concepts_partial_duplicate.plain b/tests/data/plainfileparser/required_concepts_partial_duplicate.plain index 5122c34..2a1b4d0 100644 --- a/tests/data/plainfileparser/required_concepts_partial_duplicate.plain +++ b/tests/data/plainfileparser/required_concepts_partial_duplicate.plain @@ -11,4 +11,5 @@ import: - These are nested required concepts. ***functional specs*** -- Implement the concepts. \ No newline at end of file +# implement-the-concepts +Implement the concepts. \ No newline at end of file diff --git a/tests/data/plainfileparser/requires_without_definitions_example.plain b/tests/data/plainfileparser/requires_without_definitions_example.plain index c63b375..1ad3b80 100644 --- a/tests/data/plainfileparser/requires_without_definitions_example.plain +++ b/tests/data/plainfileparser/requires_without_definitions_example.plain @@ -7,4 +7,5 @@ requires: - The :Concept: concept must be reused. ***functional specs*** -- Implement a second wrapper for the :Concept: concept. +# implement-a-second-wrapper-for +Implement a second wrapper for the :Concept: concept. diff --git a/tests/data/plainfileparser/topological_sort.plain b/tests/data/plainfileparser/topological_sort.plain index 5fb5972..554f140 100644 --- a/tests/data/plainfileparser/topological_sort.plain +++ b/tests/data/plainfileparser/topological_sort.plain @@ -9,6 +9,9 @@ - The :Concept3: concept must be implemented. ***functional specs*** -- Implement the :Concept1: concept. -- Implement the :Concept2: concept. -- Implement the :Concept3: concept. \ No newline at end of file +# implement-the-concept1-concept +Implement the :Concept1: concept. +# implement-the-concept2-concept +Implement the :Concept2: concept. +# implement-the-concept3-concept +Implement the :Concept3: concept. \ No newline at end of file diff --git a/tests/data/plainfileparser/topological_sort_not_referenced.plain b/tests/data/plainfileparser/topological_sort_not_referenced.plain index 7655522..e1b3ee5 100644 --- a/tests/data/plainfileparser/topological_sort_not_referenced.plain +++ b/tests/data/plainfileparser/topological_sort_not_referenced.plain @@ -16,7 +16,11 @@ - The :Concept3: concept must be implemented. ***functional specs*** -- Implement the :Concept1: concept. -- Implement the :Concept2: concept. -- Implement the :Concept3: concept. -- Implement the :NonReferencedConcept: concept. \ No newline at end of file +# implement-the-concept1-concept +Implement the :Concept1: concept. +# implement-the-concept2-concept +Implement the :Concept2: concept. +# implement-the-concept3-concept +Implement the :Concept3: concept. +# implement-the-nonreferencedconcept-concept +Implement the :NonReferencedConcept: concept. \ No newline at end of file diff --git a/tests/data/requires/chain_base.plain b/tests/data/requires/chain_base.plain index 90ebe54..4c55a85 100644 --- a/tests/data/requires/chain_base.plain +++ b/tests/data/requires/chain_base.plain @@ -4,4 +4,5 @@ ***functional specs*** -- A base functionality. +# a-base-functionality +A base functionality. diff --git a/tests/data/requires/chain_fork_top.plain b/tests/data/requires/chain_fork_top.plain index dd14f07..c1f4cb5 100644 --- a/tests/data/requires/chain_fork_top.plain +++ b/tests/data/requires/chain_fork_top.plain @@ -10,4 +10,5 @@ requires: ***functional specs*** -- A fork functionality. +# a-fork-functionality +A fork functionality. diff --git a/tests/data/requires/chain_middle.plain b/tests/data/requires/chain_middle.plain index fbd00fc..518b12d 100644 --- a/tests/data/requires/chain_middle.plain +++ b/tests/data/requires/chain_middle.plain @@ -9,4 +9,5 @@ requires: ***functional specs*** -- A middle functionality. +# a-middle-functionality +A middle functionality. diff --git a/tests/data/requires/chain_top.plain b/tests/data/requires/chain_top.plain index 9bbe7a1..a673d27 100644 --- a/tests/data/requires/chain_top.plain +++ b/tests/data/requires/chain_top.plain @@ -9,4 +9,5 @@ requires: ***functional specs*** -- A top functionality. +# a-top-functionality +A top functionality. diff --git a/tests/data/requires/circular_requires_main.plain b/tests/data/requires/circular_requires_main.plain index d0a04d0..2c88ac4 100644 --- a/tests/data/requires/circular_requires_main.plain +++ b/tests/data/requires/circular_requires_main.plain @@ -15,4 +15,5 @@ requires: ***functional specs*** -- Display "hello, world" +# display-hello-world +Display "hello, world" diff --git a/tests/data/requires/circular_requires_sub.plain b/tests/data/requires/circular_requires_sub.plain index ab921a4..a8e3834 100644 --- a/tests/data/requires/circular_requires_sub.plain +++ b/tests/data/requires/circular_requires_sub.plain @@ -9,4 +9,5 @@ requires: ***functional specs*** -- Implement the entry point for :App:. +# implement-the-entry-point-for +Implement the entry point for :App:. diff --git a/tests/data/requires/diamond_requires_1.plain b/tests/data/requires/diamond_requires_1.plain index c6b8e11..4776650 100644 --- a/tests/data/requires/diamond_requires_1.plain +++ b/tests/data/requires/diamond_requires_1.plain @@ -13,4 +13,5 @@ requires: ***functional specs*** -- Implement the entry point for :App:. +# implement-the-entry-point-for +Implement the entry point for :App:. diff --git a/tests/data/requires/diamond_requires_2.plain b/tests/data/requires/diamond_requires_2.plain index f6a4773..a5ec254 100644 --- a/tests/data/requires/diamond_requires_2.plain +++ b/tests/data/requires/diamond_requires_2.plain @@ -13,4 +13,5 @@ requires: ***functional specs*** -- :User: should be able to log in using their email and password. +# user-should-be-able-to +:User: should be able to log in using their email and password. diff --git a/tests/data/requires/diamond_requires_common.plain b/tests/data/requires/diamond_requires_common.plain index bd06f03..f971fd5 100644 --- a/tests/data/requires/diamond_requires_common.plain +++ b/tests/data/requires/diamond_requires_common.plain @@ -4,4 +4,5 @@ ***functional specs*** -- A base functionality. \ No newline at end of file +# a-base-functionality +A base functionality. \ No newline at end of file diff --git a/tests/data/requires/diamond_requires_main.plain b/tests/data/requires/diamond_requires_main.plain index c117671..351d98c 100644 --- a/tests/data/requires/diamond_requires_main.plain +++ b/tests/data/requires/diamond_requires_main.plain @@ -16,4 +16,5 @@ requires: ***functional specs*** -- Display "hello, world" +# display-hello-world +Display "hello, world" diff --git a/tests/data/requires/independent_requires_1.plain b/tests/data/requires/independent_requires_1.plain index ee05e0f..67285a9 100644 --- a/tests/data/requires/independent_requires_1.plain +++ b/tests/data/requires/independent_requires_1.plain @@ -8,4 +8,5 @@ ***functional specs*** -- Implement the entry point for :App:. +# implement-the-entry-point-for +Implement the entry point for :App:. diff --git a/tests/data/requires/independent_requires_2.plain b/tests/data/requires/independent_requires_2.plain index a844d3a..8c24d77 100644 --- a/tests/data/requires/independent_requires_2.plain +++ b/tests/data/requires/independent_requires_2.plain @@ -8,4 +8,5 @@ ***functional specs*** -- :User: should be able to log in using their email and password. +# user-should-be-able-to +:User: should be able to log in using their email and password. diff --git a/tests/data/requires/independent_requires_main.plain b/tests/data/requires/independent_requires_main.plain index a9a0c30..9803028 100644 --- a/tests/data/requires/independent_requires_main.plain +++ b/tests/data/requires/independent_requires_main.plain @@ -16,4 +16,5 @@ requires: ***functional specs*** -- Display "hello, world" +# display-hello-world +Display "hello, world" diff --git a/tests/data/requires/invalid_ancestor_base.plain b/tests/data/requires/invalid_ancestor_base.plain index ec5862a..2d2d310 100644 --- a/tests/data/requires/invalid_ancestor_base.plain +++ b/tests/data/requires/invalid_ancestor_base.plain @@ -4,4 +4,5 @@ ***functional specs*** -- A base functionality that uses :UndefinedThing:. +# a-base-functionality-that-uses +A base functionality that uses :UndefinedThing:. diff --git a/tests/data/requires/invalid_ancestor_top.plain b/tests/data/requires/invalid_ancestor_top.plain index 844d8d9..aab6e5c 100644 --- a/tests/data/requires/invalid_ancestor_top.plain +++ b/tests/data/requires/invalid_ancestor_top.plain @@ -9,4 +9,5 @@ requires: ***functional specs*** -- A top functionality. +# a-top-functionality +A top functionality. diff --git a/tests/data/requires/non_existent_require.plain b/tests/data/requires/non_existent_require.plain index f3f0bef..0d05795 100644 --- a/tests/data/requires/non_existent_require.plain +++ b/tests/data/requires/non_existent_require.plain @@ -15,4 +15,5 @@ requires: ***functional specs*** -- Display "hello, world" \ No newline at end of file +# display-hello-world +Display "hello, world" \ No newline at end of file diff --git a/tests/data/requires/normal_requires_1.plain b/tests/data/requires/normal_requires_1.plain index 816ec41..80b7608 100644 --- a/tests/data/requires/normal_requires_1.plain +++ b/tests/data/requires/normal_requires_1.plain @@ -13,4 +13,5 @@ requires: ***functional specs*** -- Implement the entry point for :App:. +# implement-the-entry-point-for +Implement the entry point for :App:. diff --git a/tests/data/requires/normal_requires_2.plain b/tests/data/requires/normal_requires_2.plain index 462cb82..c62d823 100644 --- a/tests/data/requires/normal_requires_2.plain +++ b/tests/data/requires/normal_requires_2.plain @@ -13,4 +13,5 @@ requires: ***functional specs*** -- :User: should be able to log in using their email and password. +# user-should-be-able-to +:User: should be able to log in using their email and password. diff --git a/tests/data/requires/normal_requires_common.plain b/tests/data/requires/normal_requires_common.plain index bd06f03..f971fd5 100644 --- a/tests/data/requires/normal_requires_common.plain +++ b/tests/data/requires/normal_requires_common.plain @@ -4,4 +4,5 @@ ***functional specs*** -- A base functionality. \ No newline at end of file +# a-base-functionality +A base functionality. \ No newline at end of file diff --git a/tests/data/requires/normal_requires_main.plain b/tests/data/requires/normal_requires_main.plain index 1a75e2d..3084099 100644 --- a/tests/data/requires/normal_requires_main.plain +++ b/tests/data/requires/normal_requires_main.plain @@ -16,4 +16,5 @@ requires: ***functional specs*** -- Display "hello, world" +# display-hello-world +Display "hello, world" diff --git a/tests/data/requires/shared_export_above.plain b/tests/data/requires/shared_export_above.plain index 7d03428..0ae27bc 100644 --- a/tests/data/requires/shared_export_above.plain +++ b/tests/data/requires/shared_export_above.plain @@ -9,4 +9,5 @@ requires: ***functional specs*** -- An above functionality. +# an-above-functionality +An above functionality. diff --git a/tests/data/requires/shared_export_base.plain b/tests/data/requires/shared_export_base.plain index f3b7197..3992ed2 100644 --- a/tests/data/requires/shared_export_base.plain +++ b/tests/data/requires/shared_export_base.plain @@ -13,4 +13,5 @@ exported_concepts: ***functional specs*** -- :KeyStore: stores a key. +# keystore-stores-a-key +:KeyStore: stores a key. diff --git a/tests/data/requires/shared_export_middle.plain b/tests/data/requires/shared_export_middle.plain index 60eb832..7d36fcd 100644 --- a/tests/data/requires/shared_export_middle.plain +++ b/tests/data/requires/shared_export_middle.plain @@ -9,4 +9,5 @@ requires: ***functional specs*** -- A middle functionality. +# a-middle-functionality +A middle functionality. diff --git a/tests/data/requires/shared_export_top.plain b/tests/data/requires/shared_export_top.plain index b54fcbe..914ec89 100644 --- a/tests/data/requires/shared_export_top.plain +++ b/tests/data/requires/shared_export_top.plain @@ -10,4 +10,5 @@ requires: ***functional specs*** -- A top functionality. +# a-top-functionality +A top functionality. diff --git a/tests/data/simple.plain b/tests/data/simple.plain index 81f440c..3cc3622 100644 --- a/tests/data/simple.plain +++ b/tests/data/simple.plain @@ -4,4 +4,5 @@ ***functional specs*** -- Simple functionality +# simple-functionality +Simple functionality diff --git a/tests/data/templates/block_level_include.plain b/tests/data/templates/block_level_include.plain index b91fb98..15fab01 100644 --- a/tests/data/templates/block_level_include.plain +++ b/tests/data/templates/block_level_include.plain @@ -5,8 +5,7 @@ ***functional specs*** -- Display "hello, world" - - ***acceptance tests*** +# display-hello-world +Display "hello, world" - Test {% include "test_hardest_problem.plain" %} > - Test the function "cannot_solve_hardest_problem". diff --git a/tests/data/templates/code_variables.plain b/tests/data/templates/code_variables.plain index a290ec2..1cba98f 100644 --- a/tests/data/templates/code_variables.plain +++ b/tests/data/templates/code_variables.plain @@ -7,4 +7,5 @@ - Second implementation requirement. ***functional specs*** -- Implement {% include "implement.plain", variable_name: "nice" %} +# implement +Implement {% include "implement.plain", variable_name: "nice" %} diff --git a/tests/data/templates/implement.plain b/tests/data/templates/implement.plain index b32f8d4..40f1c82 100644 --- a/tests/data/templates/implement.plain +++ b/tests/data/templates/implement.plain @@ -1,3 +1,3 @@ something nice and useful - - the nice thing should be really {{ variable_name | code_variable }} - - the useful thing should be really useful \ No newline at end of file + - the nice thing should be really {{ variable_name | code_variable }} + - the useful thing should be really useful \ No newline at end of file diff --git a/tests/data/templates/implement_2.plain b/tests/data/templates/implement_2.plain index a171ed4..6e62ffd 100644 --- a/tests/data/templates/implement_2.plain +++ b/tests/data/templates/implement_2.plain @@ -1,3 +1,3 @@ something nice and useful - - the nice thing should be really {{ variable_name_1 | code_variable }} - - the useful thing should be really useful \ No newline at end of file + - the nice thing should be really {{ variable_name_1 | code_variable }} + - the useful thing should be really useful \ No newline at end of file diff --git a/tests/data/templates/malformed_tag.plain b/tests/data/templates/malformed_tag.plain index 984ef80..8d65f1b 100644 --- a/tests/data/templates/malformed_tag.plain +++ b/tests/data/templates/malformed_tag.plain @@ -1,4 +1,5 @@ ***functional specs*** {% if %} -- A :User: can add a :Task:. +# a-user-can-add-a +A :User: can add a :Task:. diff --git a/tests/data/templates/template_include.plain b/tests/data/templates/template_include.plain index 84226d6..e6bcb72 100644 --- a/tests/data/templates/template_include.plain +++ b/tests/data/templates/template_include.plain @@ -1,4 +1,5 @@ {% include "header.plain", variable_name_1: "nice_1", variable_name_2: "nice_2" %} ***functional specs*** -- Implement {% include "implement_2.plain", variable_name_1: "nice" %} +# implement +Implement {% include "implement_2.plain", variable_name_1: "nice" %} diff --git a/tests/data/templates/unterminated_output.plain b/tests/data/templates/unterminated_output.plain index 2e4e733..9708642 100644 --- a/tests/data/templates/unterminated_output.plain +++ b/tests/data/templates/unterminated_output.plain @@ -1,3 +1,4 @@ ***functional specs*** -- A :User: can add a {{ task_name +# a-user-can-add-a +A :User: can add a {{ task_name diff --git a/tests/test_cli.py b/tests/test_cli.py index fd76d29..28606c5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -77,12 +77,15 @@ def test_check_valid_module_in_subdirectory_from_parent(cli_specs_dir, capsys, m _assert_ok((exit_code, out, err)) -def test_check_valid_module_with_linked_resource_in_acceptance_test(cli_specs_dir, capsys, monkeypatch, tmp_path): +def test_check_probe_module_without_config_prints_ok(tmp_path, capsys, monkeypatch): + # The module plain-healthcheck writes to probe whether the installed parser reads the current syntax. + (tmp_path / "probe.plain").write_text( + "***implementation reqs***\n\n- :Implementation: is written in Python 3.12.\n\n" + "***functional specs***\n\n# probe\n:Implementation: starts.\n" + ) monkeypatch.chdir(tmp_path) - exit_code, out, err = _run_check(capsys, os.path.join(cli_specs_dir, "acceptance_test_resource.plain")) - - _assert_ok((exit_code, out, err)) + assert _run_check(capsys, "probe.plain") == (0, "probe.plain: OK\n", "") def test_check_nonexistent_template_dir_is_ignored(cli_specs_dir, capsys): @@ -194,12 +197,12 @@ def test_check_wrong_extension(cli_specs_dir, capsys): def test_check_empty_template_dir_is_ignored(tmp_path, capsys, monkeypatch): # An empty --template-dir must not add the working directory to the search path (codeplain ignores it). (tmp_path / "helper.plain").write_text( - "***implementation reqs***\n\n- A req.\n\n***functional specs***\n\n- A functionality.\n" + "***implementation reqs***\n\n- A req.\n\n***functional specs***\n\n# main\nA functionality.\n" ) spec_dir = tmp_path / "spec" spec_dir.mkdir() (spec_dir / "top.plain").write_text( - "---\nrequires:\n - helper\n---\n\n***implementation reqs***\n\n- A req.\n\n***functional specs***\n\n- A functionality.\n" + "---\nrequires:\n - helper\n---\n\n***implementation reqs***\n\n- A req.\n\n***functional specs***\n\n# main\nA functionality.\n" ) monkeypatch.chdir(tmp_path) @@ -210,18 +213,6 @@ def test_check_empty_template_dir_is_ignored(tmp_path, capsys, monkeypatch): assert err == "Error: Module does not exist (helper).\n" -def test_check_binary_linked_resource_in_acceptance_test(cli_specs_dir, capsys, monkeypatch, tmp_path): - monkeypatch.chdir(tmp_path) - - exit_code, out, err = _run_check(capsys, os.path.join(cli_specs_dir, "acceptance_test_binary_resource.plain")) - - assert exit_code == 1 - assert out == "" - assert err.startswith( - "Error: Referenced resource 'binary.bin' in module 'acceptance_test_binary_resource' is a binary file." - ) - - @pytest.mark.parametrize("interrupted_function", ["check", "resolve_config_file"]) def test_check_interrupted_exits_130_without_traceback(cli_specs_dir, capsys, monkeypatch, interrupted_function): def interrupt(*_): @@ -237,7 +228,7 @@ def interrupt(*_): assert "Traceback" not in captured.err -_MODULE = "***implementation reqs***\n\n- A req.\n\n***functional specs***\n\n- A functionality.\n" +_MODULE = "***implementation reqs***\n\n- A req.\n\n***functional specs***\n\n# main\nA functionality.\n" _TOP = "---\nrequires:\n - helper\n---\n\n" + _MODULE @@ -442,7 +433,7 @@ def _write_module(path, body="", requires=None): front = "---\nrequires:\n" + "".join(f" - {name}\n" for name in requires) + "---\n\n" if requires else "" path.parent.mkdir(parents=True, exist_ok=True) path.write_text( - front + "***implementation reqs***\n\n- A req.\n\n***functional specs***\n\n- A functionality." + body + front + "***implementation reqs***\n\n- A req.\n\n***functional specs***\n\n# main\nA functionality." + body ) diff --git a/tests/test_imports.py b/tests/test_imports.py index 83816da..cf3373d 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -49,5 +49,13 @@ def test_diamond_imports(load_test_data, get_test_data_path): {"markdown": "- :Import1Def: is tested in diamond_import_1."}, {"markdown": "- :Import2Def: is tested in diamond_import_2."}, ], - "functional specs": [{"markdown": '- Display "hello, world"'}], + "functional specs": [ + { + "id": "display-hello-world", + "markdown": 'Display "hello, world"', + "linked_resources": [], + "conformance_tests": [], + "acceptance_tests": [], + } + ], } diff --git a/tests/test_loaders.py b/tests/test_loaders.py index 9ebe6eb..2c7a08d 100644 --- a/tests/test_loaders.py +++ b/tests/test_loaders.py @@ -173,7 +173,7 @@ def test_get_loaded_templates_unterminated_output_raises_plain_syntax_error(): with pytest.raises( PlainSyntaxError, - match=re.escape("Plain syntax error: Invalid Liquid at unterminated_output:4:0: "), + match=re.escape("Plain syntax error: Invalid Liquid at unterminated_output:5:0: "), ): get_loaded_templates([directory], source, "unterminated_output") diff --git a/tests/test_plainfile.py b/tests/test_plainfile.py deleted file mode 100644 index 7737a08..0000000 --- a/tests/test_plainfile.py +++ /dev/null @@ -1,270 +0,0 @@ -from unittest.mock import MagicMock, patch - -import pytest -from mistletoe.block_token import List, ListItem, Paragraph -from mistletoe.span_token import Emphasis, RawText, Strong - -from plain_parser import plain_file, plain_spec -from plain_parser.exceptions import PlainSyntaxError -from plain_parser.plain_file import process_acceptance_tests - - -class MockParseBuffer(list): - def __init__(self, *args, loose=False): - super().__init__(*args) - self.loose = loose - - -def _create_mock_fr_content(content="Implement the entry point for ...", line_number=32): - """Helper to create a mock functionality content paragraph.""" - mock_fr_content_text = MagicMock(spec=RawText) - mock_fr_content_text.content = content - - mock_fr_content = MagicMock(spec=Paragraph) - mock_fr_content.children = [mock_fr_content_text] - mock_fr_content.line_number = line_number - - return mock_fr_content - - -def _create_mock_at_heading_paragraph(line_number=34): - """Helper to create a mock acceptance test heading paragraph with proper structure.""" - mock_at_heading_raw_text = MagicMock(spec=RawText) - mock_at_heading_raw_text.content = plain_spec.ACCEPTANCE_TEST_HEADING - - mock_at_heading_strong = MagicMock(spec=Strong) - mock_at_heading_strong.children = [mock_at_heading_raw_text] - - mock_at_heading_emphasis = MagicMock(spec=Emphasis) - mock_at_heading_emphasis.children = [mock_at_heading_strong] - - mock_at_heading_paragraph = MagicMock(spec=Paragraph) - mock_at_heading_paragraph.children = [mock_at_heading_emphasis] - mock_at_heading_paragraph.line_number = line_number - - return mock_at_heading_paragraph - - -def _create_mock_at_list(content="The App shouldn't show logging...", line_number=36): - """Helper to create a mock acceptance test list with a single item.""" - mock_at_item_raw_text = MagicMock(spec=RawText) - mock_at_item_raw_text.content = content - - mock_at_item_paragraph = MagicMock(spec=Paragraph) - mock_at_item_paragraph.children = [mock_at_item_raw_text] - mock_at_item_paragraph.line_number = line_number - - mock_at_item = MagicMock(spec=ListItem) - mock_at_item.children = [mock_at_item_paragraph] - mock_at_item.line_number = line_number - - mock_at_list = MagicMock(spec=List) - mock_at_list.children = [mock_at_item] - mock_at_list.line_number = line_number - - return mock_at_list - - -@patch("plain_parser.plain_file._process_single_acceptance_test_requirement") -def test_process_acceptance_tests_no_functional_requirements_key(mock_process_single): - """Tests early return when functionality key is missing.""" - plain_source_tree = {} - - process_acceptance_tests(plain_source_tree) - - mock_process_single.assert_not_called() - - -@patch("plain_parser.plain_file._process_single_acceptance_test_requirement") -def test_process_acceptance_tests_frs_lacks_children_attr(mock_process_single): - """Tests early return when frs object lacks children attribute.""" - plain_source_tree = {plain_spec.FUNCTIONAL_REQUIREMENTS: None} - - process_acceptance_tests(plain_source_tree) - - mock_process_single.assert_not_called() - - -@patch("plain_parser.plain_file._process_single_acceptance_test_requirement") -def test_process_acceptance_tests_no_sections_direct_frs(mock_process_single): - """Tests processing of top-level functionality when no sections exist.""" - mock_fr_item1_with_children = MagicMock(name="FRItem1_WithChildren") - mock_fr_item1_with_children.children = MagicMock(name="ChildrenOfFRItem1") - - mock_fr_item2_no_children = MagicMock(spec=[], name="FRItem2_NoChildren") - - mock_fr_item3_with_children = MagicMock(name="FRItem3_WithChildren") - mock_fr_item3_with_children.children = MagicMock(name="ChildrenOfFRItem3") - - mock_frs_top_level = MagicMock(name="TopLevelFRS") - mock_frs_top_level.children = [ - mock_fr_item1_with_children, - mock_fr_item2_no_children, - mock_fr_item3_with_children, - ] - - plain_source_tree = { - plain_spec.FUNCTIONAL_REQUIREMENTS: mock_frs_top_level, - "definitions": MagicMock(name="TopLevelDefs"), - "implementation reqs": MagicMock(name="TopLevelNFRs"), - } - - process_acceptance_tests(plain_source_tree) - - assert mock_process_single.call_count == 2 - mock_process_single.assert_any_call(mock_fr_item1_with_children) - mock_process_single.assert_any_call(mock_fr_item3_with_children) - - -def test_psart_no_acceptance_tests_present(): - """Tests behavior when no 'Acceptance test:' heading is present.""" - functional_requirement_mock = MagicMock(spec=ListItem) - functional_requirement_mock.line_number = 1 - - mock_paragraph1 = MagicMock(spec=Paragraph) - mock_paragraph1.children = [] - mock_paragraph2 = MagicMock(spec=Paragraph) - mock_paragraph2.children = [] - - original_children = [mock_paragraph1, mock_paragraph2] - functional_requirement_mock.children = list(original_children) - - plain_file._process_single_acceptance_test_requirement(functional_requirement_mock) - - assert not hasattr( - functional_requirement_mock, plain_spec.ACCEPTANCE_TESTS - ), "acceptance_tests attribute should not be added if no ATs are found." - assert list(functional_requirement_mock.children) == original_children, "FR children should remain unchanged." - - -def test_psart_empty_children_list_in_fr(): - """Tests behavior when the functionality itself has no children.""" - functional_requirement_mock = MagicMock(spec=ListItem) - functional_requirement_mock.line_number = 1 - functional_requirement_mock.children = [] - - plain_file._process_single_acceptance_test_requirement(functional_requirement_mock) - - assert not hasattr(functional_requirement_mock, plain_spec.ACCEPTANCE_TESTS) - assert list(functional_requirement_mock.children) == [] - - -def test_psart_heading_not_followed_by_list(): - """Tests behavior if AT heading is present but not followed by a list token.""" - functional_requirement_mock = MagicMock(spec=ListItem) - functional_requirement_mock.line_number = 1 - - mock_heading_raw_text = MagicMock(spec=RawText) - mock_heading_raw_text.content = plain_spec.ACCEPTANCE_TEST_HEADING - mock_heading_paragraph = MagicMock(spec=Paragraph) - mock_heading_paragraph.children = [mock_heading_raw_text] - mock_heading_paragraph.line_number = 2 - - mock_not_a_list_token = MagicMock(spec=Paragraph) - mock_not_a_list_token.children = [] - - original_children = [mock_heading_paragraph, mock_not_a_list_token] - functional_requirement_mock.children = list(original_children) - - plain_file._process_single_acceptance_test_requirement(functional_requirement_mock) - - assert not hasattr( - functional_requirement_mock, plain_spec.ACCEPTANCE_TESTS - ), "acceptance_tests attribute should not be added." - assert len(functional_requirement_mock.children) == 2, "Children count should remain the same as original." - assert ( - list(functional_requirement_mock.children) == original_children - ), "Children should be unchanged as AT block was malformed." - - -def test_psart_with_valid_acceptance_tests(): - # Main item to test - OuterListItem - functional_requirement_mock = MagicMock(spec=ListItem) - functional_requirement_mock.line_number = 1 - - # Create the three children using helper methods - mock_fr_content = _create_mock_fr_content() - mock_at_heading_paragraph = _create_mock_at_heading_paragraph() - mock_at_list = _create_mock_at_list() - - # Set up children of the functionality - functional_requirement_mock.children = [mock_fr_content, mock_at_heading_paragraph, mock_at_list] - - is_acceptance_test_heading, acceptance_test_heading_problem = plain_file._is_acceptance_test_heading( - mock_at_heading_paragraph - ) - assert is_acceptance_test_heading - assert acceptance_test_heading_problem is None - - # Call the function under test - plain_file._process_single_acceptance_test_requirement(functional_requirement_mock) - - # Verify acceptance_tests were extracted correctly - assert hasattr(functional_requirement_mock, plain_spec.ACCEPTANCE_TESTS) - assert len(functional_requirement_mock.acceptance_tests) == 1 - assert mock_at_list.children[0] in functional_requirement_mock.acceptance_tests - - # Verify only the FR content paragraph remains in children - assert len(functional_requirement_mock.children) == 1 - assert mock_fr_content in functional_requirement_mock.children - - -def test_psart_with_invalid_acceptance_test_heading(): - functional_requirement_mock = MagicMock(spec=ListItem) - functional_requirement_mock.line_number = 1 - - # Create a heading paragraph with wrong content - mock_at_heading_paragraph = _create_mock_at_heading_paragraph() - - # Change the content of the raw text to be invalid - mock_at_heading_paragraph.children[0].children[0].children[0].content = "Acceptance test" # missing colon - - # Set up children of the functionality with just the invalid heading - functional_requirement_mock.children = [mock_at_heading_paragraph] - - # Verify that invalid heading is detected - is_acceptance_test_heading, acceptance_test_heading_problem = plain_file._is_acceptance_test_heading( - mock_at_heading_paragraph - ) - assert not is_acceptance_test_heading - assert acceptance_test_heading_problem is not None - - # Call the function under test and expect a PlainSyntaxError - with pytest.raises(PlainSyntaxError): - plain_file._process_single_acceptance_test_requirement(functional_requirement_mock) - - -def test_psart_with_duplicate_acceptance_test_heading(): - functional_requirement_mock = MagicMock(spec=ListItem) - functional_requirement_mock.line_number = 1 - - # Create content and list mocks - mock_fr_content = _create_mock_fr_content() - mock_at_list1 = _create_mock_at_list("First acceptance test") - mock_at_list2 = _create_mock_at_list("Second acceptance test", line_number=40) - - # Create two heading paragraphs with different line numbers - mock_at_heading_paragraph1 = _create_mock_at_heading_paragraph(line_number=34) - mock_at_heading_paragraph2 = _create_mock_at_heading_paragraph(line_number=38) - - # Set up children of the functionality with duplicate AT headings - functional_requirement_mock.children = [ - mock_fr_content, - mock_at_heading_paragraph1, - mock_at_list1, - mock_at_heading_paragraph2, - mock_at_list2, - ] - - # Call the function under test and expect a PlainSyntaxError about duplicate headings - expected_error_message = f"Plain syntax error: Syntax error at line {mock_at_heading_paragraph2.line_number}: Duplicate 'acceptance tests' heading found within the same functionality. Only one block of acceptance tests is allowed per functionality." - - with pytest.raises(PlainSyntaxError) as exc_info: - plain_file._process_single_acceptance_test_requirement(functional_requirement_mock) - - assert str(exc_info.value) == expected_error_message - - -def test_invalid_plain_file_extension(): - with pytest.raises(PlainSyntaxError): - plain_file.plain_file_parser("test.txt", []) diff --git a/tests/test_plainfileparser.py b/tests/test_plainfileparser.py index 33174cb..1fd13c6 100644 --- a/tests/test_plainfileparser.py +++ b/tests/test_plainfileparser.py @@ -21,7 +21,15 @@ def test_regular_plain_source(get_test_data_path): {"markdown": "- First implementation requirement."}, {"markdown": "- Second implementation requirement."}, ], - "functional specs": [{"markdown": '- Display "hello, world"'}], + "functional specs": [ + { + "id": "display-hello-world", + "markdown": 'Display "hello, world"', + "linked_resources": [], + "conformance_tests": [], + "acceptance_tests": [], + } + ], } @@ -34,7 +42,7 @@ def test_unknown_section(): with pytest.raises( Exception, match=re.escape( - "Syntax error at line 4: Invalid specification heading (`Unknown Section:`). Allowed headings: definitions, implementation reqs, test reqs, functional specs, acceptance tests" + "Syntax error at line 4: Invalid specification heading (`Unknown Section:`). Allowed headings: definitions, implementation reqs, test reqs, functional specs" ), ): plain_file.parse_plain_source(plain_source, {}, [], [], []) @@ -112,7 +120,15 @@ def test_plain_file_parser_with_comments(get_test_data_path): assert plain_sections == { "definitions": [], "implementation reqs": [{"markdown": "- Second implementation requirement."}], - "functional specs": [{"markdown": '- Display "hello, world"'}], + "functional specs": [ + { + "id": "display-hello-world", + "markdown": 'Display "hello, world"', + "linked_resources": [], + "conformance_tests": [], + "acceptance_tests": [], + } + ], } @@ -127,7 +143,15 @@ def test_plain_file_parser_with_comments_indented(get_test_data_path): {"markdown": "- First implementation requirement."}, {"markdown": "- Second implementation requirement."}, ], - "functional specs": [{"markdown": '- Display "hello, world"'}], + "functional specs": [ + { + "id": "display-hello-world", + "markdown": 'Display "hello, world"', + "linked_resources": [], + "conformance_tests": [], + "acceptance_tests": [], + } + ], } @@ -231,7 +255,10 @@ def test_reference_link_parsing_independent_of_working_directory(get_test_data_p def test_invalid_specification_order(get_test_data_path): with pytest.raises( Exception, - match="Syntax error at line 6: Definitions specification must be the first specification in the section.", + match=re.escape( + "Syntax error at line 6 in module 'invalid_specification_order': Definitions specification must be the " + "first specification in the section (`definitions`)" + ), ): plain_file.plain_file_parser("invalid_specification_order.plain", [get_test_data_path("data/plainfile")]) @@ -239,7 +266,10 @@ def test_invalid_specification_order(get_test_data_path): def test_duplicate_specification_heading(get_test_data_path): with pytest.raises( Exception, - match=re.escape("Syntax error at line 6: Duplicate specification heading (`definitions`)"), + match=re.escape( + "Syntax error at line 6 in module 'duplicate_specification_heading': Duplicate specification heading " + "(`definitions`)" + ), ): plain_file.plain_file_parser( "duplicate_specification_heading.plain", @@ -405,9 +435,10 @@ def test_code_variables(load_test_data, get_test_data_path): - Second implementation requirement. ***functional specs*** -- Implement something nice and useful - - the nice thing should be really {keys[0]} - - the useful thing should be really useful +# implement +Implement something nice and useful + - the nice thing should be really {keys[0]} + - the useful thing should be really useful """ assert rendered_plain_source == expected_rendered_plain_source @@ -421,7 +452,11 @@ def test_code_variables(load_test_data, get_test_data_path): ], "functional specs": [ { - "markdown": "- Implement something nice and useful\n - the nice thing should be really {{ variable_name }}\n - the useful thing should be really useful", + "id": "implement", + "markdown": "Implement something nice and useful\n - the nice thing should be really {{ variable_name }}\n - the useful thing should be really useful", + "linked_resources": [], + "conformance_tests": [], + "acceptance_tests": [], "code_variables": [{"name": "variable_name", "value": "nice"}], } ], @@ -447,9 +482,10 @@ def test_code_variables(load_test_data, get_test_data_path): - Second implementation requirement {keys[1]}. ***functional specs*** -- Implement something nice and useful - - the nice thing should be really {keys[2]} - - the useful thing should be really useful +# implement +Implement something nice and useful + - the nice thing should be really {keys[2]} + - the useful thing should be really useful """ assert rendered_plain_source == expected_rendered_plain_source @@ -469,7 +505,11 @@ def test_code_variables(load_test_data, get_test_data_path): ], "functional specs": [ { - "markdown": "- Implement something nice and useful\n - the nice thing should be really {{ variable_name_1 }}\n - the useful thing should be really useful", + "id": "implement", + "markdown": "Implement something nice and useful\n - the nice thing should be really {{ variable_name_1 }}\n - the useful thing should be really useful", + "linked_resources": [], + "conformance_tests": [], + "acceptance_tests": [], "code_variables": [{"name": "variable_name_1", "value": "nice"}], } ], @@ -478,9 +518,9 @@ def test_code_variables(load_test_data, get_test_data_path): assert plain_source == expected_plain_source -def test_acceptance_tests_block_include_with_trailing_newline_keeps_structure_and_ignores_quote(get_test_data_path): +def test_block_include_with_trailing_newline_keeps_structure_and_ignores_quote(get_test_data_path): """ - Ensures that a block-level include inside ***acceptance tests*** whose template ends + Ensures that a block-level include inside a functionality body whose template ends with a trailing newline does not terminate the list, and that a following '> ...' line is treated as a comment and ignored. The parser should not raise a syntax error. It's an error we encountered while implementing custom rendering and should break in case of regression. @@ -488,112 +528,156 @@ def test_acceptance_tests_block_include_with_trailing_newline_keeps_structure_an plain_file.plain_file_parser("block_level_include.plain", [get_test_data_path("data/templates")]) -def test_acceptance_tests_top_level_rejected(): - plain_source = """ -***acceptance tests*** +_SPEC_HEAD = "***definitions***\n\n- :Task: is a thing to do.\n\n***implementation reqs***\n\n- A req.\n\n" -- Test something. -""" - with pytest.raises( - PlainSyntaxError, - match=re.escape( - "Syntax error at line 2: acceptance tests heading should be nested under specific functional spec." - ), - ): - plain_file.parse_plain_source(plain_source, {}, [], [], []) +def _parse(plain_source): + return plain_file.marshall_plain_source(plain_file.parse_plain_source(plain_source, {}, [], [], []).plain_source) -def test_acceptance_tests_top_level_after_other_headings_rejected(): - plain_source = """***definitions*** -- :concept: is a concept. +def test_functionality_is_its_heading_id_and_its_body(): + plain_source = _SPEC_HEAD + ( + "***functional specs***\n\n" + "# add-task\n" + "A :Task: can be added.\n" + "It gets an id.\n" + " - Only a :Task: with a name is added.\n" + " - The name is trimmed.\n" + "\n" + "# list-tasks\n" + "> A comment between the heading and the body.\n" + "\n" + "Every :Task: is listed.\n" + ) -***acceptance tests*** + assert _parse(plain_source)["functional specs"] == [ + { + "id": "add-task", + "markdown": "A :Task: can be added.\nIt gets an id.\n - Only a :Task: with a name is added.\n - The name is trimmed.", + "linked_resources": [], + "conformance_tests": [], + "acceptance_tests": [], + }, + { + "id": "list-tasks", + "markdown": "Every :Task: is listed.", + "linked_resources": [], + "conformance_tests": [], + "acceptance_tests": [], + }, + ] -- Test something. -""" - with pytest.raises( - PlainSyntaxError, - match=re.escape( - "Syntax error at line 5: acceptance tests heading should be nested under specific functional spec." - ), - ): + +def test_functionality_body_may_start_with_a_list(): + plain_source = _SPEC_HEAD + "***functional specs***\n\n# add-task\n - A :Task: can be added.\n" + + assert _parse(plain_source)["functional specs"][0]["markdown"] == " - A :Task: can be added." + + +def test_flush_left_list_in_functional_specs_is_the_old_syntax(get_test_data_path): + with pytest.raises(PlainSyntaxError) as exc_info: + plain_file.plain_file_parser("old_syntax_functional_specs.plain", [get_test_data_path("data/plainfileparser")]) + + assert str(exc_info.value) == ( + "Plain syntax error: Syntax error at line 11 in module 'old_syntax_functional_specs': A flush-left `- ` list " + "in the functional specs section is the old syntax; a functionality is a `# ` heading followed by " + "its body. Run the migrate-plain-syntax skill to convert the project." + ) + + +def test_flush_left_list_after_a_heading_is_the_old_syntax(): + plain_source = ( + _SPEC_HEAD + "***functional specs***\n\n# add-task\nA :Task: can be added.\n\n- A :Task: has a name.\n" + ) + + with pytest.raises(PlainSyntaxError, match=re.escape("Syntax error at line 14: A flush-left `- ` list")): plain_file.parse_plain_source(plain_source, {}, [], [], []) -def test_acceptance_tests_nested_under_definitions_rejected(): - plain_source = """***definitions*** +@pytest.mark.parametrize("heading", ["acceptance tests", "conformance tests"]) +def test_test_section_in_core_spec_is_rejected(heading): + plain_source = f"***definitions***\n\n- :Task: is a thing to do.\n\n***{heading}***\n\n# add\nAdding works.\n" -- :concept: is a concept. + with pytest.raises(PlainSyntaxError) as exc_info: + plain_file.parse_plain_source(plain_source, {}, [], [], []) - ***acceptance tests*** + assert str(exc_info.value) == ( + f"Plain syntax error: Syntax error at line 5: The `{heading}` section is not allowed in a core spec; " + "test scenarios live in test plain files. Run the migrate-plain-syntax skill to convert the project." + ) - - Test the :concept:. -***functional specs*** +def test_nested_acceptance_tests_section_is_rejected(): + plain_source = _SPEC_HEAD + ( + "***functional specs***\n\n# add-task\nA :Task: can be added.\n\n" + " ***acceptance tests***\n\n - Adding a :Task: shows it.\n" + ) + + with pytest.raises(PlainSyntaxError, match=re.escape("Syntax error at line 14: The `acceptance tests` section")): + plain_file.parse_plain_source(plain_source, {}, [], [], []) + + +def test_heading_outside_functional_specs_is_rejected(): + plain_source = "***implementation reqs***\n\n# add-task\n- A req.\n" -- Display "hello, world" -""" with pytest.raises( PlainSyntaxError, match=re.escape( - "Syntax error at line 5: acceptance tests heading should be nested under specific functional spec." + "Syntax error at line 3: A `# ` heading is allowed only in the functional specs section (`add-task`)." ), ): plain_file.parse_plain_source(plain_source, {}, [], [], []) -def test_acceptance_tests_nested_under_implementation_reqs_rejected(): - plain_source = """***implementation reqs*** +def test_heading_of_another_level_is_rejected(): + plain_source = _SPEC_HEAD + "***functional specs***\n\n## add-task\nA :Task: can be added.\n" -- First implementation requirement. - - ***acceptance tests*** - - - Test something. -""" with pytest.raises( PlainSyntaxError, match=re.escape( - "Syntax error at line 5: acceptance tests heading should be nested under specific functional spec." + "Syntax error at line 11: Invalid heading level 2 (`add-task`). A functionality heading is `# `." ), ): plain_file.parse_plain_source(plain_source, {}, [], [], []) -def test_acceptance_tests_nested_under_test_reqs_rejected(): - plain_source = """***test reqs*** - -- First test requirement. - - ***acceptance tests*** +@pytest.mark.parametrize("content", ["A :Task: can be added.", " - A :Task: can be added."]) +def test_content_before_the_first_heading_is_rejected(content): + plain_source = _SPEC_HEAD + f"***functional specs***\n\n{content}\n\n# add-task\nA :Task: can be added.\n" - - Test something. -""" with pytest.raises( PlainSyntaxError, match=re.escape( - "Syntax error at line 5: acceptance tests heading should be nested under specific functional spec." + "Syntax error at line 11: Content before the first `# ` heading in the functional specs section " + "(`A :Task: can be added.`)." ), ): plain_file.parse_plain_source(plain_source, {}, [], [], []) -def test_acceptance_tests_nested_under_functional_spec_allowed(): - plain_source = """***definitions*** +@pytest.mark.parametrize( + "tail", + ["\n# list-tasks\nEvery :Task: is listed.\n", "\n***test reqs***\n\n- A test req.\n", "", "\n> Only a comment.\n"], +) +def test_functionality_without_body_is_rejected(tail): + plain_source = _SPEC_HEAD + "***functional specs***\n\n# add-task\n" + tail -- :concept: is a concept. + with pytest.raises( + PlainSyntaxError, match=re.escape("Syntax error at line 11: Functionality `add-task` has no body.") + ): + plain_file.parse_plain_source(plain_source, {}, [], [], []) -***functional specs*** -- Display "hello, world" +def test_code_block_in_functionality_body_is_rejected(): + plain_source = _SPEC_HEAD + "***functional specs***\n\n# add-task\nA :Task: can be added.\n\n```\ntodo add\n```\n" - ***acceptance tests*** + with pytest.raises(PlainSyntaxError, match=re.escape("Syntax error at line 14: Invalid source structure")): + plain_file.parse_plain_source(plain_source, {}, [], [], []) - - Test the :concept:. -""" - result = plain_file.parse_plain_source(plain_source, {}, [], [], []) - assert plain_spec.FUNCTIONAL_REQUIREMENTS in result.plain_source + +def test_invalid_plain_file_extension(): + with pytest.raises(PlainSyntaxError): + plain_file.plain_file_parser("test.txt", []) def test_concept_validation_definitions(get_test_data_path): @@ -656,19 +740,6 @@ def test_concept_validation_several_concepts(get_test_data_path): ) -def test_concept_validation_acceptance_tests(get_test_data_path): - plain_file.plain_file_parser( - "concept_validation_acceptance_tests.plain", - [get_test_data_path("data/plainfileparser")], - ) - - with pytest.raises(PlainSyntaxError): - plain_file.plain_file_parser( - "concept_validation_acceptance_tests_nondefined.plain", - [get_test_data_path("data/plainfileparser")], - ) - - def test_concept_validation_cyclic_definitions(get_test_data_path): with pytest.raises(PlainSyntaxError, match="cycle\\(s\\) in concept definitions"): plain_file.plain_file_parser("cyclic_definitions.plain", [get_test_data_path("data/plainfileparser")]) @@ -816,6 +887,6 @@ def test_topological_sort(get_test_data_path): def test_liquid_syntax_error_names_module(get_test_data_path): with pytest.raises( PlainSyntaxError, - match=re.escape("Plain syntax error: Invalid Liquid at unterminated_output:4:0: "), + match=re.escape("Plain syntax error: Invalid Liquid at unterminated_output:5:0: "), ): plain_file.plain_file_parser("unterminated_output.plain", [get_test_data_path("data/templates")]) diff --git a/tests/test_plainspec.py b/tests/test_plainspec.py index a92a7d0..c94fe03 100644 --- a/tests/test_plainspec.py +++ b/tests/test_plainspec.py @@ -98,7 +98,7 @@ def test_get_specifications_simple(get_test_data_path): "definitions": [], "implementation reqs": ["- Simple implementation requirement"], "test reqs": [], - "functional specs": ["- Simple functionality"], + "functional specs": ["Simple functionality"], } diff --git a/tests/test_requires.py b/tests/test_requires.py index 0166b01..73641c8 100644 --- a/tests/test_requires.py +++ b/tests/test_requires.py @@ -72,7 +72,7 @@ def write_module(directory, module_name, exported_concepts, required_modules, de "***definitions***\n\n" + "".join(f"- {definition}\n\n" for definition in definitions) + "***implementation reqs***\n\n- An implementation requirement.\n" - + (f"\n***functional specs***\n\n- {functional_spec}\n" if functional_spec is not None else "") + + (f"\n***functional specs***\n\n# main\n{functional_spec}\n" if functional_spec is not None else "") ) @@ -296,7 +296,7 @@ def test_top_module_shape_is_checked_before_required_modules_are_validated(tmp_p def test_parse_errors_of_required_modules_come_before_top_module_concept_errors(tmp_path): (tmp_path / "Base.plain").write_text( - "***implementation reqs***\n\n- {% if %}\n\n***functional specs***\n\n- Base works.\n" + "***implementation reqs***\n\n- {% if %}\n\n***functional specs***\n\n# main\nBase works.\n" ) write_module(tmp_path, "Top", [], ["Base"], [], "Top uses :TopMissing:.") From f2cd66ec74afde391a13f2f2a6eb8aebeef50848 Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Fri, 16 Oct 2026 12:13:49 +0200 Subject: [PATCH 02/15] feat: check func-id syntax and uniqueness A func-id has only lowercase letters, digits and hyphens and is unique in its module. The errors name the line, the module and, for a duplicate, the line of the first use. --- src/plain_parser/plain_file.py | 12 ++++++++ src/plain_parser/plain_spec.py | 8 ++++++ .../plainfileparser/func_id_duplicate.plain | 14 ++++++++++ .../plainfileparser/func_id_invalid.plain | 11 ++++++++ tests/test_plainfileparser.py | 28 +++++++++++++++++++ 5 files changed, 73 insertions(+) create mode 100644 tests/data/plainfileparser/func_id_duplicate.plain create mode 100644 tests/data/plainfileparser/func_id_invalid.plain diff --git a/src/plain_parser/plain_file.py b/src/plain_parser/plain_file.py index 88acc62..b81707f 100644 --- a/src/plain_parser/plain_file.py +++ b/src/plain_parser/plain_file.py @@ -434,6 +434,7 @@ def read_sections( # noqa: C901 current_specification_heading = None current_functionality: Functionality | None = None processed_specification_headings = set[str]() + functionality_lines: dict[str, int] = {} # func-id to the line of its heading def close_functionality(): if current_functionality is not None and not current_functionality.body: @@ -457,7 +458,18 @@ def close_functionality(): f"Plain syntax error: {where}: Invalid heading level {token.level} (`{token_text}`). " f"A functionality heading is `# `." ) + if not plain_spec.is_semantic_id(token_text): + raise PlainSyntaxError( + f"Plain syntax error: {where}: Invalid func-id (`{token_text}`). A func-id has only lowercase " + f"letters, digits and hyphens." + ) + if token_text in functionality_lines: + raise PlainSyntaxError( + f"Plain syntax error: {where}: Duplicate func-id `{token_text}`; it is first used at line " + f"{functionality_lines[token_text]}." + ) close_functionality() + functionality_lines[token_text] = token.line_number current_functionality = Functionality(token_text, token.line_number) plain_source[plain_spec.FUNCTIONAL_REQUIREMENTS].append(current_functionality) elif isinstance(token, Paragraph): diff --git a/src/plain_parser/plain_spec.py b/src/plain_parser/plain_spec.py index c6bcbd1..8262208 100644 --- a/src/plain_parser/plain_spec.py +++ b/src/plain_parser/plain_spec.py @@ -1,5 +1,6 @@ import hashlib import json +import re import uuid from typing import Optional @@ -23,6 +24,13 @@ ALLOWED_IMPORT_SPECIFICATION_HEADINGS = LIST_SECTION_HEADINGS +# A func-id or scenario-id: lowercase letters, digits and hyphens. +SEMANTIC_ID_PATTERN = re.compile(r"[a-z0-9-]+") + + +def is_semantic_id(text: str) -> bool: + return SEMANTIC_ID_PATTERN.fullmatch(text) is not None + def has_acceptance_tests(plain_source_tree) -> bool: """Return True if any functional requirement in the plain source defines acceptance tests.""" diff --git a/tests/data/plainfileparser/func_id_duplicate.plain b/tests/data/plainfileparser/func_id_duplicate.plain new file mode 100644 index 0000000..a58b654 --- /dev/null +++ b/tests/data/plainfileparser/func_id_duplicate.plain @@ -0,0 +1,14 @@ +***implementation reqs*** + +- An implementation requirement. + +***functional specs*** + +# add-task +A task can be added. + +# list-tasks +Every task is listed. + +# add-task +A task can be added again. diff --git a/tests/data/plainfileparser/func_id_invalid.plain b/tests/data/plainfileparser/func_id_invalid.plain new file mode 100644 index 0000000..82dda9c --- /dev/null +++ b/tests/data/plainfileparser/func_id_invalid.plain @@ -0,0 +1,11 @@ +***implementation reqs*** + +- An implementation requirement. + +***functional specs*** + +# add-task +A task can be added. + +# Add Task 2 +A second task can be added. diff --git a/tests/test_plainfileparser.py b/tests/test_plainfileparser.py index 1fd13c6..2ce053c 100644 --- a/tests/test_plainfileparser.py +++ b/tests/test_plainfileparser.py @@ -641,6 +641,34 @@ def test_heading_of_another_level_is_rejected(): plain_file.parse_plain_source(plain_source, {}, [], [], []) +def test_func_id_with_other_characters_is_rejected(get_test_data_path): + with pytest.raises(PlainSyntaxError) as exc_info: + plain_file.plain_file_parser("func_id_invalid.plain", [get_test_data_path("data/plainfileparser")]) + + assert str(exc_info.value) == ( + "Plain syntax error: Syntax error at line 10 in module 'func_id_invalid': Invalid func-id (`Add Task 2`). " + "A func-id has only lowercase letters, digits and hyphens." + ) + + +@pytest.mark.parametrize("heading", ["#", "# ", "# add_task", "# add-task!", "# Ädd"]) +def test_func_id_syntax_is_checked(heading): + plain_source = _SPEC_HEAD + f"***functional specs***\n\n{heading}\nA :Task: can be added.\n" + + with pytest.raises(PlainSyntaxError, match="Syntax error at line 11: Invalid func-id"): + plain_file.parse_plain_source(plain_source, {}, [], [], []) + + +def test_duplicate_func_id_is_rejected(get_test_data_path): + with pytest.raises(PlainSyntaxError) as exc_info: + plain_file.plain_file_parser("func_id_duplicate.plain", [get_test_data_path("data/plainfileparser")]) + + assert str(exc_info.value) == ( + "Plain syntax error: Syntax error at line 13 in module 'func_id_duplicate': Duplicate func-id `add-task`; " + "it is first used at line 7." + ) + + @pytest.mark.parametrize("content", ["A :Task: can be added.", " - A :Task: can be added."]) def test_content_before_the_first_heading_is_rejected(content): plain_source = _SPEC_HEAD + f"***functional specs***\n\n{content}\n\n# add-task\nA :Task: can be added.\n" From 449c9557d521d33d4efef7a270a9ee7ad757a959 Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Fri, 16 Oct 2026 12:23:39 +0200 Subject: [PATCH 03/15] feat: walk functionalities by func-id in plain_spec get_frids yields the func-ids in file order, and the helpers that take a frid take a func-id. get_specifications_for_frid returns the conformance and acceptance scenarios of the functionality, and collect_linked_resources covers the links in them. The legacy tree walk over sections, ID and Heading is removed. --- src/plain_parser/plain_spec.py | 265 +++++--------------- tests/data/cli/code_variable_conflict.plain | 4 +- tests/test_plainspec.py | 160 +++++++++--- 3 files changed, 188 insertions(+), 241 deletions(-) diff --git a/src/plain_parser/plain_spec.py b/src/plain_parser/plain_spec.py index 8262208..bccaffc 100644 --- a/src/plain_parser/plain_spec.py +++ b/src/plain_parser/plain_spec.py @@ -2,7 +2,6 @@ import json import re import uuid -from typing import Optional from liquid2.filter import with_context @@ -76,104 +75,59 @@ def collect_specification_linked_resources(specification, specification_heading, ) -def collect_linked_resources_in_section( - section, linked_resources_list, specifications_list, include_acceptance_tests, frid=Optional[str] -): - # When should we collect specification headings in the current section. Either when one of the following holds true: - # - frid wasn't specified - # - section has no ID (and that's exactly when it's the root section) - # - section has ID, frid was specified and the specified frid inside the current section tree - should_collect_specification_headings_in_current_section = ( - frid is None or "ID" not in section or frid.startswith(section["ID"]) - ) - if should_collect_specification_headings_in_current_section: - specifications_to_collect = list(set([DEFINITIONS, NON_FUNCTIONAL_REQUIREMENTS, TEST_REQUIREMENTS])) - if specifications_list: - specifications_to_collect = list(set(specifications_to_collect) & set(specifications_list)) - for specification_heading in specifications_to_collect: - if specification_heading in section: - for requirement in section[specification_heading]: - collect_specification_linked_resources(requirement, specification_heading, linked_resources_list) - - if FUNCTIONAL_REQUIREMENTS in section and ( - not specifications_list or FUNCTIONAL_REQUIREMENTS in specifications_list - ): - functional_requirement_count = 0 - for requirement in section[FUNCTIONAL_REQUIREMENTS]: - collect_specification_linked_resources(requirement, FUNCTIONAL_REQUIREMENTS, linked_resources_list) - - functional_requirement_count += 1 - section_id = section.get("ID", None) - current_frid = get_current_frid(section_id, functional_requirement_count) - - if ACCEPTANCE_TESTS in requirement and include_acceptance_tests and (frid is None or frid == current_frid): - for acceptance_test in requirement[ACCEPTANCE_TESTS]: - collect_specification_linked_resources( - acceptance_test, FUNCTIONAL_REQUIREMENTS, linked_resources_list - ) - - if frid is not None and current_frid == frid: - # here, we rely on the fact that FRIDs are incrementing. And if we have reached the current FRID, we should - # not collect any FRIDs after the current one. - return True - - if "sections" in section: - for subsection in section["sections"]: - if collect_linked_resources_in_section( - subsection, linked_resources_list, specifications_list, include_acceptance_tests, frid - ): - return True - - return False - - -# TODO: check if this function can be refactored to return the list of linked resources instead of modifying it in place def collect_linked_resources( - plain_source_tree, linked_resources_list, specifications_list, include_acceptance_tests, frid=None + plain_source_tree, linked_resources_list, specifications_list, include_test_scenarios, frid=None ): + """Add the linked resources of the tree to ``linked_resources_list`` and sort the list by link text. + ``specifications_list`` limits the sections read; None reads every section. The functional specs are read up to + and including ``frid``, or all of them when ``frid`` is None. With ``include_test_scenarios`` the links of the + conformance and acceptance scenarios are added too, for ``frid`` alone when one is given. Returns True when + ``frid`` was reached. + """ if not isinstance(plain_source_tree, dict): raise ValueError("plain_source_tree must be a dictionary.") - if frid is not None: - functional_requirements = get_frids(plain_source_tree) - if frid not in functional_requirements: - raise ValueError(f"frid {frid} does not exist.") + if frid is not None and frid not in get_frids(plain_source_tree): + raise ValueError(f"frid {frid} does not exist.") + + sections_to_collect = [ + heading + for heading in ALLOWED_SPECIFICATION_HEADINGS + if heading in plain_source_tree and (not specifications_list or heading in specifications_list) + ] + frid_reached = False + for specification_heading in sections_to_collect: + for specification in plain_source_tree[specification_heading]: + collect_specification_linked_resources(specification, specification_heading, linked_resources_list) + + if specification_heading != FUNCTIONAL_REQUIREMENTS: + continue + + if include_test_scenarios and (frid is None or frid == specification["id"]): + scenarios = [*specification.get(CONFORMANCE_TESTS, []), *specification.get(ACCEPTANCE_TESTS, [])] + for scenario in scenarios: + collect_specification_linked_resources(scenario, FUNCTIONAL_REQUIREMENTS, linked_resources_list) - result = collect_linked_resources_in_section( - plain_source_tree, linked_resources_list, specifications_list, include_acceptance_tests, frid - ) + if specification["id"] == frid: + frid_reached = True + break - # Sort linked_resources_list by the "text" field linked_resources_list.sort(key=lambda x: x["text"]) - return result + return frid_reached def get_frids(plain_source_tree): - if FUNCTIONAL_REQUIREMENTS in plain_source_tree: - for functional_requirement_id in range(1, len(plain_source_tree[FUNCTIONAL_REQUIREMENTS]) + 1): - if "ID" in plain_source_tree: - yield plain_source_tree["ID"] + "." + str(functional_requirement_id) - else: - yield str(functional_requirement_id) - - if "sections" in plain_source_tree: - for section in plain_source_tree["sections"]: - yield from get_frids(section) + """The func-ids of the tree's functionalities, in file order.""" + for functionality in plain_source_tree.get(FUNCTIONAL_REQUIREMENTS) or []: + yield functionality["id"] def get_first_frid(plain_source_tree): return next(get_frids(plain_source_tree), None) -def get_current_frid(section_id: Optional[str], functional_requirement_count: int) -> str: - if section_id is None: - return str(functional_requirement_count) - else: - return section_id + "." + str(functional_requirement_count) - - def get_next_frid(plain_source_tree, frid): functional_requirements = get_frids(plain_source_tree) for temp_frid in functional_requirements: @@ -195,16 +149,7 @@ def get_previous_frid(plain_source_tree, frid): def get_frids_before(plain_source_tree, target_frid: str) -> list[str]: - """ - Get all FRIDs that appear before the target FRID in the specification. - - Args: - plain_source_tree: The plain source tree - target_frid: The FRID to find predecessors for - - Returns: - List of FRIDs that appear before target_frid, in order - """ + """The func-ids that come before ``target_frid`` in the specification, in order.""" previous_frids = [] for frid in get_frids(plain_source_tree): if frid == target_frid: @@ -214,8 +159,10 @@ def get_frids_before(plain_source_tree, target_frid: str) -> list[str]: return previous_frids -def get_specification_item_markdown(specification_item, code_variables, replace_code_variables): - markdown = specification_item["markdown"] +def get_specification_item_markdown(specification_item, code_variables, replace_code_variables, key="markdown"): + """The text under ``key`` of the item, with its code variables recorded in ``code_variables`` and, when + ``replace_code_variables`` is set, their values put in place of the `{{ name }}` placeholders.""" + markdown = specification_item[key] if "code_variables" in specification_item: for code_variable in specification_item["code_variables"]: if code_variable["name"] in code_variables: @@ -232,120 +179,37 @@ def get_specification_item_markdown(specification_item, code_variables, replace_ return markdown -def get_specifications_from_plain_source_tree( - frid, - plain_source_tree, - definitions, - non_functional_requirements, - test_requirements, - functional_requirements, - acceptance_tests, - code_variables, - replace_code_variables, - section_id=None, -): - return_frid = None - if FUNCTIONAL_REQUIREMENTS in plain_source_tree and len(plain_source_tree[FUNCTIONAL_REQUIREMENTS]) > 0: - functional_requirement_count = 0 - for functional_requirement in plain_source_tree[FUNCTIONAL_REQUIREMENTS]: - functional_requirement_count += 1 - if section_id is None: - current_frid = str(functional_requirement_count) - else: - current_frid = section_id + "." + str(functional_requirement_count) - - functional_requirements.append( - get_specification_item_markdown(functional_requirement, code_variables, replace_code_variables) - ) - - if current_frid == frid: - if ACCEPTANCE_TESTS in functional_requirement: - for acceptance_test in functional_requirement[ACCEPTANCE_TESTS]: - acceptance_tests.append( - get_specification_item_markdown(acceptance_test, code_variables, replace_code_variables) - ) - - return_frid = current_frid - break - - if "sections" in plain_source_tree: - for section in plain_source_tree["sections"]: - sub_frid = get_specifications_from_plain_source_tree( - frid, - section, - definitions, - non_functional_requirements, - test_requirements, - functional_requirements, - acceptance_tests, - code_variables, - replace_code_variables, - section["ID"], - ) - if sub_frid is not None: - return_frid = sub_frid - break - - if return_frid is not None: - if DEFINITIONS in plain_source_tree and plain_source_tree[DEFINITIONS] is not None: - definitions[0:0] = [ - get_specification_item_markdown(specification, code_variables, replace_code_variables) - for specification in plain_source_tree[DEFINITIONS] - ] - if ( - NON_FUNCTIONAL_REQUIREMENTS in plain_source_tree - and plain_source_tree[NON_FUNCTIONAL_REQUIREMENTS] is not None - ): - non_functional_requirements[0:0] = [ - get_specification_item_markdown(specification, code_variables, replace_code_variables) - for specification in plain_source_tree[NON_FUNCTIONAL_REQUIREMENTS] - ] - if TEST_REQUIREMENTS in plain_source_tree and plain_source_tree[TEST_REQUIREMENTS] is not None: - test_requirements[0:0] = [ - get_specification_item_markdown(specification, code_variables, replace_code_variables) - for specification in plain_source_tree[TEST_REQUIREMENTS] - ] - - return return_frid - - def get_specifications_for_frid(plain_source_tree, frid, replace_code_variables=True): - definitions = [] - non_functional_requirements = [] - test_requirements = [] - functional_requirements = [] - acceptance_tests = [] + """The specifications that build ``frid``: every list section, the functional specs up to and including + ``frid``, and the conformance and acceptance scenarios of ``frid`` as `{"id", "text"}` entries. + + Returns the specifications and the code variables they use (None when there are none). Raise when ``frid`` + is not in the tree. + """ + functionalities = plain_source_tree.get(FUNCTIONAL_REQUIREMENTS) or [] + position = next((index for index, item in enumerate(functionalities) if item["id"] == frid), None) + if position is None: + raise Exception(f"Functionality {frid} does not exist.") code_variables = {} - result = get_specifications_from_plain_source_tree( - frid, - plain_source_tree, - definitions, - non_functional_requirements, - test_requirements, - functional_requirements, - acceptance_tests, - code_variables, - replace_code_variables, - ) - if result is None: - raise Exception(f"Functionality {frid} does not exist.") + def texts(items, key="markdown"): + return [get_specification_item_markdown(item, code_variables, replace_code_variables, key) for item in items] + + def scenarios(items): + return [{"id": item["id"], "text": text} for item, text in zip(items, texts(items, "text"))] + functionality = functionalities[position] specifications = { - DEFINITIONS: definitions, - NON_FUNCTIONAL_REQUIREMENTS: non_functional_requirements, - TEST_REQUIREMENTS: test_requirements, - FUNCTIONAL_REQUIREMENTS: functional_requirements, + DEFINITIONS: texts(plain_source_tree.get(DEFINITIONS) or []), + NON_FUNCTIONAL_REQUIREMENTS: texts(plain_source_tree.get(NON_FUNCTIONAL_REQUIREMENTS) or []), + TEST_REQUIREMENTS: texts(plain_source_tree.get(TEST_REQUIREMENTS) or []), + FUNCTIONAL_REQUIREMENTS: texts(functionalities[: position + 1]), + CONFORMANCE_TESTS: scenarios(functionality.get(CONFORMANCE_TESTS, [])), + ACCEPTANCE_TESTS: scenarios(functionality.get(ACCEPTANCE_TESTS, [])), } - if acceptance_tests: - specifications[ACCEPTANCE_TESTS] = acceptance_tests - - if code_variables: - return specifications, code_variables - else: - return specifications, None + return specifications, (code_variables or None) @with_context @@ -397,16 +261,13 @@ def get_hash_value(specifications): def get_frids_range(plain_source, start, end=None): - """Return the FRIDs from ``start`` through ``end`` inclusive, or through the last FRID when ``end`` is None.""" + """The func-ids from ``start`` through ``end`` inclusive, or through the last one when ``end`` is None.""" frids = list(get_frids(plain_source)) - start = str(start) - if start not in frids: raise InvalidFridArgument(f"Invalid start functionality ID: {start}. Valid IDs are: {frids}.") if end is not None: - end = str(end) if end not in frids: raise InvalidFridArgument(f"Invalid end functionality ID: {end}. Valid IDs are: {frids}.") diff --git a/tests/data/cli/code_variable_conflict.plain b/tests/data/cli/code_variable_conflict.plain index 356bcec..c637306 100644 --- a/tests/data/cli/code_variable_conflict.plain +++ b/tests/data/cli/code_variable_conflict.plain @@ -4,8 +4,8 @@ ***functional specs*** -# implement-include-code-variable-template +# implement-first Implement {% include "code_variable_template.plain", variable_name: "first" %} -# implement-include-code-variable-template-2 +# implement-second Implement {% include "code_variable_template.plain", variable_name: "second" %} diff --git a/tests/test_plainspec.py b/tests/test_plainspec.py index c94fe03..57bfb82 100644 --- a/tests/test_plainspec.py +++ b/tests/test_plainspec.py @@ -3,6 +3,24 @@ from plain_parser import plain_file, plain_spec +def _functionality(id_, markdown, conformance_tests=(), acceptance_tests=(), linked_resources=()): + return { + "id": id_, + "markdown": markdown, + "linked_resources": list(linked_resources), + plain_spec.CONFORMANCE_TESTS: list(conformance_tests), + plain_spec.ACCEPTANCE_TESTS: list(acceptance_tests), + } + + +def _scenario(id_, text, linked_resources=()): + return {"id": id_, "text": text, "linked_resources": list(linked_resources)} + + +def _link(target): + return {"text": target, "target": target} + + def test_get_linked_resources_invalid_input(): with pytest.raises(ValueError, match="plain_source_tree must be a dictionary."): plain_spec.collect_linked_resources([], [], None, True) @@ -11,11 +29,8 @@ def test_get_linked_resources_invalid_input(): def test_has_acceptance_tests_true(): plain_source = { plain_spec.FUNCTIONAL_REQUIREMENTS: [ - {"markdown": "- First functionality."}, - { - "markdown": "- Second functionality.", - plain_spec.ACCEPTANCE_TESTS: [{"markdown": "- Test the second functionality."}], - }, + _functionality("first", "First functionality."), + _functionality("second", "Second functionality.", acceptance_tests=[_scenario("works", "It works.")]), ] } assert plain_spec.has_acceptance_tests(plain_source) is True @@ -24,17 +39,8 @@ def test_has_acceptance_tests_true(): def test_has_acceptance_tests_false_when_no_acceptance_tests(): plain_source = { plain_spec.FUNCTIONAL_REQUIREMENTS: [ - {"markdown": "- First functionality."}, - {"markdown": "- Second functionality."}, - ] - } - assert plain_spec.has_acceptance_tests(plain_source) is False - - -def test_has_acceptance_tests_false_when_empty_acceptance_tests(): - plain_source = { - plain_spec.FUNCTIONAL_REQUIREMENTS: [ - {"markdown": "- First functionality.", plain_spec.ACCEPTANCE_TESTS: []}, + _functionality("first", "First functionality."), + _functionality("second", "Second functionality."), ] } assert plain_spec.has_acceptance_tests(plain_source) is False @@ -49,39 +55,39 @@ def test_get_frids_simple(get_test_data_path): frids = list(plain_spec.get_frids(plain_source)) - assert frids == ["1"] + assert frids == ["simple-functionality"] def test_get_first_functional_requirement_simple(get_test_data_path): _, plain_source, _ = plain_file.plain_file_parser("simple.plain", [get_test_data_path("data/")]) - assert plain_spec.get_first_frid(plain_source) == "1" + assert plain_spec.get_first_frid(plain_source) == "simple-functionality" def test_get_next_frid_not_exists(get_test_data_path): _, plain_source, _ = plain_file.plain_file_parser("simple.plain", [get_test_data_path("data/")]) - with pytest.raises(Exception, match="Functionality 2 does not exist."): - plain_spec.get_next_frid(plain_source, "2") + with pytest.raises(Exception, match="Functionality missing does not exist."): + plain_spec.get_next_frid(plain_source, "missing") def test_get_next_frid_simple(get_test_data_path): _, plain_source, _ = plain_file.plain_file_parser("simple.plain", [get_test_data_path("data/")]) - assert plain_spec.get_next_frid(plain_source, "1") is None + assert plain_spec.get_next_frid(plain_source, "simple-functionality") is None def test_get_previous_frid_not_exists(get_test_data_path): _, plain_source, _ = plain_file.plain_file_parser("simple.plain", [get_test_data_path("data/")]) - with pytest.raises(Exception, match="Functionality 2 does not exist."): - plain_spec.get_previous_frid(plain_source, "2") + with pytest.raises(Exception, match="Functionality missing does not exist."): + plain_spec.get_previous_frid(plain_source, "missing") def test_get_previous_frid_no_previous_frid(get_test_data_path): _, plain_source, _ = plain_file.plain_file_parser("simple.plain", [get_test_data_path("data/")]) - assert plain_spec.get_previous_frid(plain_source, "1") is None + assert plain_spec.get_previous_frid(plain_source, "simple-functionality") is None def test_get_specifications_simple(get_test_data_path): @@ -92,42 +98,122 @@ def test_get_specifications_simple(get_test_data_path): frid = plain_spec.get_first_frid(plain_source) - specifications, _ = plain_spec.get_specifications_for_frid(plain_source, frid) + specifications, code_variables = plain_spec.get_specifications_for_frid(plain_source, frid) assert specifications == { "definitions": [], "implementation reqs": ["- Simple implementation requirement"], "test reqs": [], "functional specs": ["Simple functionality"], + "conformance_tests": [], + "acceptance_tests": [], } + assert code_variables is None def _four_functionalities(): - return {plain_spec.FUNCTIONAL_REQUIREMENTS: [{"markdown": f"- Functionality {i}."} for i in range(1, 5)]} + return { + plain_spec.DEFINITIONS: [{"markdown": "- :Thing: is a thing."}], + plain_spec.FUNCTIONAL_REQUIREMENTS: [ + _functionality( + f"f{i}", + f"Functionality {i}.", + conformance_tests=[_scenario(f"f{i}-works", f"Functionality {i} works.", [_link(f"c{i}.md")])], + acceptance_tests=[_scenario(f"f{i}-flow", f"Functionality {i} flows.", [_link(f"a{i}.md")])], + linked_resources=[_link(f"r{i}.md")], + ) + for i in range(1, 5) + ], + } -def test_get_frids_range_start_and_end_inclusive(): - assert plain_spec.get_frids_range(_four_functionalities(), "2", "3") == ["2", "3"] +def test_get_frids_yields_ids_in_file_order(): + assert list(plain_spec.get_frids(_four_functionalities())) == ["f1", "f2", "f3", "f4"] -def test_get_frids_range_without_end_runs_to_last_frid(): - assert plain_spec.get_frids_range(_four_functionalities(), "3") == ["3", "4"] +def test_get_frids_before(): + assert plain_spec.get_frids_before(_four_functionalities(), "f3") == ["f1", "f2"] -def test_get_frids_range_accepts_integer_frids(): - assert plain_spec.get_frids_range(_four_functionalities(), 1, 1) == ["1"] +def test_get_specifications_for_frid_takes_functional_specs_up_to_the_frid_and_its_scenarios(): + specifications, _ = plain_spec.get_specifications_for_frid(_four_functionalities(), "f2") + + assert specifications == { + "definitions": ["- :Thing: is a thing."], + "implementation reqs": [], + "test reqs": [], + "functional specs": ["Functionality 1.", "Functionality 2."], + "conformance_tests": [{"id": "f2-works", "text": "Functionality 2 works."}], + "acceptance_tests": [{"id": "f2-flow", "text": "Functionality 2 flows."}], + } + + +def test_get_specifications_for_frid_replaces_code_variables_in_scenarios(): + tree = _four_functionalities() + tree[plain_spec.FUNCTIONAL_REQUIREMENTS][0][plain_spec.CONFORMANCE_TESTS] = [ + { + "id": "greets", + "text": "It prints {{ greeting }}.", + "linked_resources": [], + "code_variables": [{"name": "greeting", "value": "hello"}], + } + ] + + specifications, code_variables = plain_spec.get_specifications_for_frid(tree, "f1") + + assert specifications["conformance_tests"] == [{"id": "greets", "text": "It prints hello."}] + assert code_variables == {"greeting": "hello"} + + +def _collected_targets(tree, include_test_scenarios, frid=None): + linked_resources = [] + reached = plain_spec.collect_linked_resources(tree, linked_resources, None, include_test_scenarios, frid) + return reached, [resource["target"] for resource in linked_resources] + + +def test_collect_linked_resources_stops_at_the_frid_and_takes_only_its_scenarios(): + reached, targets = _collected_targets(_four_functionalities(), True, "f2") + + assert reached is True + assert targets == ["a2.md", "c2.md", "r1.md", "r2.md"] + + +def test_collect_linked_resources_without_frid_takes_every_functionality_and_scenario(): + reached, targets = _collected_targets(_four_functionalities(), True) + + assert reached is False + assert targets == sorted(f"{kind}{i}.md" for kind in "acr" for i in range(1, 5)) + + +def test_collect_linked_resources_can_leave_out_the_scenarios(): + _, targets = _collected_targets(_four_functionalities(), False) + + assert targets == ["r1.md", "r2.md", "r3.md", "r4.md"] + + +def test_collect_linked_resources_unknown_frid_raises(): + with pytest.raises(ValueError, match="frid f9 does not exist."): + plain_spec.collect_linked_resources(_four_functionalities(), [], None, True, "f9") + + +def test_get_frids_range_start_and_end_inclusive(): + assert plain_spec.get_frids_range(_four_functionalities(), "f2", "f3") == ["f2", "f3"] + + +def test_get_frids_range_without_end_runs_to_last_frid(): + assert plain_spec.get_frids_range(_four_functionalities(), "f3") == ["f3", "f4"] def test_get_frids_range_unknown_start_raises(): - with pytest.raises(plain_spec.InvalidFridArgument, match="Invalid start functionality ID: 9"): - plain_spec.get_frids_range(_four_functionalities(), "9") + with pytest.raises(plain_spec.InvalidFridArgument, match="Invalid start functionality ID: f9"): + plain_spec.get_frids_range(_four_functionalities(), "f9") def test_get_frids_range_unknown_end_raises(): - with pytest.raises(plain_spec.InvalidFridArgument, match="Invalid end functionality ID: 9"): - plain_spec.get_frids_range(_four_functionalities(), "1", "9") + with pytest.raises(plain_spec.InvalidFridArgument, match="Invalid end functionality ID: f9"): + plain_spec.get_frids_range(_four_functionalities(), "f1", "f9") def test_get_frids_range_start_after_end_raises(): with pytest.raises(plain_spec.InvalidFridArgument, match="must be before"): - plain_spec.get_frids_range(_four_functionalities(), "3", "2") + plain_spec.get_frids_range(_four_functionalities(), "f3", "f2") From 113184860fbc585453e614c1b7c1f8c9e67e4875 Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Fri, 16 Oct 2026 12:29:39 +0200 Subject: [PATCH 04/15] feat: parse test plain files A test plain file binds to one functionality with the frontmatter keys module and func_id and holds a conformance tests section followed by an acceptance tests section. A scenario is a `# ` heading and its text, kept verbatim. Liquid and linked resources work as in a core spec; the concepts of the scenarios are checked once the bound module is known. --- src/plain_parser/__init__.py | 3 +- src/plain_parser/plain_file.py | 28 ++- src/plain_parser/test_plain_file.py | 227 ++++++++++++++++++ .../content_before_scenario.plain | 13 + .../content_before_section.plain | 10 + .../testplainfile/duplicate_scenario_id.plain | 14 ++ .../testplainfile/duplicate_section.plain | 13 + tests/data/testplainfile/empty_scenario.plain | 14 ++ tests/data/testplainfile/empty_sections.plain | 8 + .../testplainfile/extra_frontmatter_key.plain | 9 + .../testplainfile/invalid_scenario_id.plain | 11 + .../testplainfile/link_to_plain_file.plain | 11 + .../missing_acceptance_section.plain | 9 + .../data/testplainfile/missing_func_id.plain | 7 + tests/data/testplainfile/missing_link.plain | 11 + .../testplainfile/module_not_a_string.plain | 8 + tests/data/testplainfile/notes.md | 1 + .../scenario_before_section.plain | 11 + .../testplainfile/sections_out_of_order.plain | 11 + .../data/testplainfile/unknown_section.plain | 11 + tests/data/testplainfile/valid.plain | 40 +++ tests/test_test_plain_file.py | 215 +++++++++++++++++ 22 files changed, 673 insertions(+), 12 deletions(-) create mode 100644 src/plain_parser/test_plain_file.py create mode 100644 tests/data/testplainfile/content_before_scenario.plain create mode 100644 tests/data/testplainfile/content_before_section.plain create mode 100644 tests/data/testplainfile/duplicate_scenario_id.plain create mode 100644 tests/data/testplainfile/duplicate_section.plain create mode 100644 tests/data/testplainfile/empty_scenario.plain create mode 100644 tests/data/testplainfile/empty_sections.plain create mode 100644 tests/data/testplainfile/extra_frontmatter_key.plain create mode 100644 tests/data/testplainfile/invalid_scenario_id.plain create mode 100644 tests/data/testplainfile/link_to_plain_file.plain create mode 100644 tests/data/testplainfile/missing_acceptance_section.plain create mode 100644 tests/data/testplainfile/missing_func_id.plain create mode 100644 tests/data/testplainfile/missing_link.plain create mode 100644 tests/data/testplainfile/module_not_a_string.plain create mode 100644 tests/data/testplainfile/notes.md create mode 100644 tests/data/testplainfile/scenario_before_section.plain create mode 100644 tests/data/testplainfile/sections_out_of_order.plain create mode 100644 tests/data/testplainfile/unknown_section.plain create mode 100644 tests/data/testplainfile/valid.plain create mode 100644 tests/test_test_plain_file.py diff --git a/src/plain_parser/__init__.py b/src/plain_parser/__init__.py index c1e8724..b27dbbf 100644 --- a/src/plain_parser/__init__.py +++ b/src/plain_parser/__init__.py @@ -1,6 +1,6 @@ """Parser for ***plain specification files.""" -from plain_parser import concept_utils, exceptions, loaders, plain_file, plain_spec +from plain_parser import concept_utils, exceptions, loaders, plain_file, plain_spec, test_plain_file from plain_parser.plain_file import ( PlainFileParseResult, get_filename_from_module_name, @@ -26,4 +26,5 @@ "plain_file", "plain_file_parser", "plain_spec", + "test_plain_file", ] diff --git a/src/plain_parser/plain_file.py b/src/plain_parser/plain_file.py index b81707f..8fdc686 100644 --- a/src/plain_parser/plain_file.py +++ b/src/plain_parser/plain_file.py @@ -100,37 +100,42 @@ def remove_quotes(token): token.children = tuple(new_children) -def find_linked_resources(tokens, template_dirs) -> list[dict]: - """The links in the tokens, each checked to be a relative path to an existing file that is not a .plain file.""" +def find_linked_resources(tokens, template_dirs, where: str = "") -> list[dict]: + """The links in the tokens, each checked to be a relative path to an existing file that is not a .plain file. + + ``where`` names the place of the links in error messages, e.g. " in test plain file 'tests/m/f.plain'". + """ linked_resources = [] for link in (link for token in tokens for link in traverse(token, klass=Link)): parsed_url = urlparse(link.node.target) if parsed_url.scheme != "" or os.path.isabs(link.node.target): raise PlainSyntaxError( - f"Plain syntax error: Only relative links are allowed (text: {link.node.children[0].content}, target: {link.node.target})." + f"Plain syntax error: Only relative links are allowed{where} (text: {link.node.children[0].content}, target: {link.node.target})." ) resolved_target = loaders.resolve_linked_resource(template_dirs, link.node.target) if resolved_target is None: searched_dirs = "\n".join(f" {position}. {dir}" for position, dir in enumerate(template_dirs, start=1)) raise PlainSyntaxError( - f"Plain syntax error: Link {link.node.target} does not exist. " + f"Plain syntax error: Link {link.node.target}{where} does not exist. " f"Linked resources are looked up relative to the following directories " f"(highest to lowest precedence):\n{searched_dirs}" ) if not os.path.isfile(resolved_target): raise PlainSyntaxError( - f"Plain syntax error: Link {link.node.target} must be a file (resolved to {resolved_target})." + f"Plain syntax error: Link {link.node.target}{where} must be a file (resolved to {resolved_target})." ) if len(link.node.children) != 1: - raise PlainSyntaxError(f"Plain syntax error: Link must have text specified (link: {link.node.target}).") + raise PlainSyntaxError( + f"Plain syntax error: Link{where} must have text specified (link: {link.node.target})." + ) linked_resource_file_extension = os.path.splitext(os.path.basename(link.node.target))[1] if linked_resource_file_extension == ".plain": raise PlainSyntaxError( - f"Referenced resource '{link.node.target}' is a .plain file. " + f"Referenced resource '{link.node.target}'{where} is a .plain file. " f"Referencing .plain files through Linked Resources is not supported. " "Please use the import section to include definitions or implementation/test requirements from another module." ) @@ -156,17 +161,18 @@ def check_for_linked_resources(plain_source, template_dirs): functionality.linked_resources = find_linked_resources(functionality.body, template_dirs) -def process_section_code_variables(section, code_variables): - if "markdown" not in section: +def process_section_code_variables(section, code_variables, text_key="markdown"): + """Put `{{ name }}` placeholders back into the section text and record the code variables it uses.""" + if text_key not in section: return code_variables_array = [] for key, value in code_variables.items(): - if key not in section["markdown"]: + if key not in section[text_key]: continue variable_name = next(iter(value)) - section["markdown"] = section["markdown"].replace(key, f"{{{{ {variable_name} }}}}") + section[text_key] = section[text_key].replace(key, f"{{{{ {variable_name} }}}}") code_variables_array.append({"name": variable_name, "value": value[variable_name]}) diff --git a/src/plain_parser/test_plain_file.py b/src/plain_parser/test_plain_file.py new file mode 100644 index 0000000..df334eb --- /dev/null +++ b/src/plain_parser/test_plain_file.py @@ -0,0 +1,227 @@ +"""Test plain files: the conformance and acceptance scenarios of one functionality. + +A test plain file binds to a functionality with the frontmatter keys ``module`` and ``func_id``. It holds a +``***conformance tests***`` section followed by an ``***acceptance tests***`` section; each scenario in them is a +`# ` heading followed by free text, which is kept verbatim. +""" + +import io +from dataclasses import dataclass, field + +import frontmatter +import mistletoe + +from plain_parser import concept_utils, loaders, plain_file, plain_spec +from plain_parser.exceptions import PlainSyntaxError + +MODULE_KEY = "module" +FUNC_ID_KEY = "func_id" +FRONTMATTER_KEYS = (MODULE_KEY, FUNC_ID_KEY) +SECTION_HEADINGS = (plain_spec.CONFORMANCE_TEST_HEADING, plain_spec.ACCEPTANCE_TEST_HEADING) +TEST_SCENARIOS_HINT = "Run the add-test-scenarios skill to update the file." + + +@dataclass +class Scenario: + id: str + line_number: int + # The body as written, without `>` comment lines and without leading and trailing blank lines. + text: str = "" + linked_resources: list[dict] = field(default_factory=list) + code_variables: list[dict] = field(default_factory=list) + + def to_dict(self) -> dict: + entry = {"id": self.id, "text": self.text, "linked_resources": self.linked_resources} + if self.code_variables: + entry["code_variables"] = self.code_variables + return entry + + +@dataclass +class TestPlainFile: + name: str # The path of the file as shown in messages. + module: str + func_id: str + conformance_tests: list[Scenario] + acceptance_tests: list[Scenario] + + @property + def scenarios(self) -> list[Scenario]: + return [*self.conformance_tests, *self.acceptance_tests] + + +def is_test_plain_file(metadata: dict) -> bool: + """Whether a frontmatter binds its file to a functionality, which makes the file a test plain file.""" + return all(key in metadata for key in FRONTMATTER_KEYS) + + +def _error(message: str) -> PlainSyntaxError: + return PlainSyntaxError(f"Plain syntax error: {message}") + + +def _at(line_number: int, name: str) -> str: + return f"Syntax error at line {line_number} in test plain file '{name}'" + + +def read_metadata(plain_source_text: str, name: str) -> frontmatter.Post: + """The frontmatter and content of a test plain file; raise unless the keys are exactly module and func_id.""" + try: + post = frontmatter.loads(plain_source_text) + except Exception as e: + raise _error(f"Test plain file '{name}' has invalid frontmatter: {e}") + + for key in FRONTMATTER_KEYS: + if key not in post.metadata: + raise _error(f"Test plain file '{name}' has no `{key}:` key in its frontmatter. {TEST_SCENARIOS_HINT}") + value = post.metadata[key] + if not isinstance(value, str) or not value.strip(): + raise _error(f"Test plain file '{name}': the frontmatter key `{key}:` must be a non-empty string.") + + for key in post.metadata: + if key not in FRONTMATTER_KEYS: + raise _error( + f"Test plain file '{name}' has the frontmatter key `{key}:`; only `module:` and `func_id:` are allowed." + ) + + return post + + +def _trim_blank_lines(lines: list[str]) -> list[str]: + start, end = 0, len(lines) + while start < end and not lines[start].strip(): + start += 1 + while end > start and not lines[end - 1].strip(): + end -= 1 + return lines[start:end] + + +def read_scenarios(rendered_text: str, name: str) -> dict[str, list[Scenario]]: # noqa: C901 + """The scenarios of the rendered content, keyed by section heading; raise on the first structural error.""" + sections: dict[str, list[Scenario]] = {} + current_section: str | None = None + current: Scenario | None = None + body: list[str] = [] + heading_lines: dict[str, int] = {} # scenario-id to the line of its heading + + def close_scenario() -> None: + if current is None: + return + lines = _trim_blank_lines(body) + if not lines: + raise _error(f"{_at(current.line_number, name)}: Scenario `{current.id}` has no text.") + current.text = "\n".join(lines) + + for line_number, line in enumerate(rendered_text.split("\n"), start=1): + where = _at(line_number, name) + if line.startswith("***"): + heading = line.strip()[3:-3] if line.strip().endswith("***") and len(line.strip()) > 6 else None + if heading not in SECTION_HEADINGS: + raise _error( + f"{where}: Invalid section (`{line.strip()}`). A test plain file has the sections " + f"`***{SECTION_HEADINGS[0]}***` and `***{SECTION_HEADINGS[1]}***`, in this order." + ) + if heading in sections: + raise _error(f"{where}: Duplicate section `***{heading}***`.") + if heading != SECTION_HEADINGS[len(sections)]: + raise _error(f"{where}: The `***{heading}***` section must come after `***{SECTION_HEADINGS[0]}***`.") + close_scenario() + current = None + current_section = heading + sections[heading] = [] + elif line.startswith("# ") or line == "#": + scenario_id = line[1:].strip() + if current_section is None: + raise _error( + f"{where}: Scenario `{scenario_id}` comes before the first section; a test plain file starts " + f"with `***{SECTION_HEADINGS[0]}***`." + ) + if not plain_spec.is_semantic_id(scenario_id): + raise _error( + f"{where}: Invalid scenario-id (`{scenario_id}`). A scenario-id has only lowercase letters, " + f"digits and hyphens." + ) + if scenario_id in heading_lines: + raise _error( + f"{where}: Duplicate scenario-id `{scenario_id}`; it is first used at line " + f"{heading_lines[scenario_id]}." + ) + close_scenario() + current = Scenario(scenario_id, line_number) + body = [] + heading_lines[scenario_id] = line_number + sections[current_section].append(current) + elif line.lstrip().startswith(">"): + continue + elif current is not None: + body.append(line) + elif line.strip(): + if current_section is None: + raise _error( + f"{where}: Content before the first section (`{line.strip()}`); a test plain file starts with " + f"`***{SECTION_HEADINGS[0]}***`." + ) + raise _error( + f"{where}: Content before the first `# ` heading in the `{current_section}` section " + f"(`{line.strip()}`)." + ) + + close_scenario() + + for heading in SECTION_HEADINGS: + if heading not in sections: + raise _error(f"Test plain file '{name}' has no `***{heading}***` section. {TEST_SCENARIOS_HINT}") + + return sections + + +def find_scenario_linked_resources(scenario: Scenario, template_dirs: list[str], name: str) -> list[dict]: + document = mistletoe.Document(io.StringIO(scenario.text)) + where = f" in test plain file '{name}', scenario `{scenario.id}`" + return plain_file.find_linked_resources(document.children, template_dirs, where) + + +def parse_test_plain_file_source( + plain_source_text: str, name: str, template_dirs: list[str], code_variables: dict +) -> TestPlainFile: + """Parse the text of a test plain file. Liquid runs as on a core spec, with the same template directories; + every link in a scenario is resolved against them. Concepts are not checked here, see + validate_scenario_concepts.""" + plain_source_text = plain_file.normalize_line_endings(plain_source_text) + + post = read_metadata(plain_source_text, name) + + _, loaded_templates = loaders.get_loaded_templates(template_dirs, plain_source_text, name) + + content = plain_file.restore_stripped_lines(plain_source_text, post.content) + + rendered_text = plain_file.render_plain_source(content, loaded_templates, code_variables) + + sections = read_scenarios(rendered_text, name) + + for scenario in [*sections[SECTION_HEADINGS[0]], *sections[SECTION_HEADINGS[1]]]: + scenario.linked_resources = find_scenario_linked_resources(scenario, template_dirs, name) + entry = {"text": scenario.text} + plain_file.process_section_code_variables(entry, code_variables, "text") + scenario.text = entry["text"] + scenario.code_variables = entry.get("code_variables", []) + + return TestPlainFile( + name, + str(post.metadata[MODULE_KEY]), + str(post.metadata[FUNC_ID_KEY]), + sections[SECTION_HEADINGS[0]], + sections[SECTION_HEADINGS[1]], + ) + + +def validate_scenario_concepts(test_plain_file: TestPlainFile, concepts: set[str]) -> list[str]: + """One message per concept used in a scenario that is neither in ``concepts`` nor predefined.""" + errors = [] + for scenario in test_plain_file.scenarios: + for concept in dict.fromkeys(concept_utils.extract_concepts_from_spec_text(scenario.text)): + if concept not in concepts and concept not in concept_utils.DEFAULT_CONCEPTS: + errors.append( + f"{_at(scenario.line_number, test_plain_file.name)}: Concept {concept} used in scenario " + f"`{scenario.id}` is not defined in module '{test_plain_file.module}'." + ) + return errors diff --git a/tests/data/testplainfile/content_before_scenario.plain b/tests/data/testplainfile/content_before_scenario.plain new file mode 100644 index 0000000..75bb3f2 --- /dev/null +++ b/tests/data/testplainfile/content_before_scenario.plain @@ -0,0 +1,13 @@ +--- +module: task-manager +func_id: add-task +--- + +***conformance tests*** + +It works. + +# works +It works. + +***acceptance tests*** diff --git a/tests/data/testplainfile/content_before_section.plain b/tests/data/testplainfile/content_before_section.plain new file mode 100644 index 0000000..e8b879f --- /dev/null +++ b/tests/data/testplainfile/content_before_section.plain @@ -0,0 +1,10 @@ +--- +module: task-manager +func_id: add-task +--- + +It works. + +***conformance tests*** + +***acceptance tests*** diff --git a/tests/data/testplainfile/duplicate_scenario_id.plain b/tests/data/testplainfile/duplicate_scenario_id.plain new file mode 100644 index 0000000..7a64a60 --- /dev/null +++ b/tests/data/testplainfile/duplicate_scenario_id.plain @@ -0,0 +1,14 @@ +--- +module: task-manager +func_id: add-task +--- + +***conformance tests*** + +# works +It works. + +***acceptance tests*** + +# works +It still works. diff --git a/tests/data/testplainfile/duplicate_section.plain b/tests/data/testplainfile/duplicate_section.plain new file mode 100644 index 0000000..f5877ec --- /dev/null +++ b/tests/data/testplainfile/duplicate_section.plain @@ -0,0 +1,13 @@ +--- +module: task-manager +func_id: add-task +--- + +***conformance tests*** + +# works +It works. + +***conformance tests*** + +***acceptance tests*** diff --git a/tests/data/testplainfile/empty_scenario.plain b/tests/data/testplainfile/empty_scenario.plain new file mode 100644 index 0000000..bf35b8b --- /dev/null +++ b/tests/data/testplainfile/empty_scenario.plain @@ -0,0 +1,14 @@ +--- +module: task-manager +func_id: add-task +--- + +***conformance tests*** + +# works +> Only a comment. + +# works-too +It works. + +***acceptance tests*** diff --git a/tests/data/testplainfile/empty_sections.plain b/tests/data/testplainfile/empty_sections.plain new file mode 100644 index 0000000..1799d34 --- /dev/null +++ b/tests/data/testplainfile/empty_sections.plain @@ -0,0 +1,8 @@ +--- +module: task-manager +func_id: add-task +--- + +***conformance tests*** + +***acceptance tests*** diff --git a/tests/data/testplainfile/extra_frontmatter_key.plain b/tests/data/testplainfile/extra_frontmatter_key.plain new file mode 100644 index 0000000..b13a654 --- /dev/null +++ b/tests/data/testplainfile/extra_frontmatter_key.plain @@ -0,0 +1,9 @@ +--- +module: task-manager +func_id: add-task +description: Scenarios of add-task. +--- + +***conformance tests*** + +***acceptance tests*** diff --git a/tests/data/testplainfile/invalid_scenario_id.plain b/tests/data/testplainfile/invalid_scenario_id.plain new file mode 100644 index 0000000..656234a --- /dev/null +++ b/tests/data/testplainfile/invalid_scenario_id.plain @@ -0,0 +1,11 @@ +--- +module: task-manager +func_id: add-task +--- + +***conformance tests*** + +# Works Well +It works. + +***acceptance tests*** diff --git a/tests/data/testplainfile/link_to_plain_file.plain b/tests/data/testplainfile/link_to_plain_file.plain new file mode 100644 index 0000000..ec9f92f --- /dev/null +++ b/tests/data/testplainfile/link_to_plain_file.plain @@ -0,0 +1,11 @@ +--- +module: task-manager +func_id: add-task +--- + +***conformance tests*** + +# works +See [the spec](valid.plain). + +***acceptance tests*** diff --git a/tests/data/testplainfile/missing_acceptance_section.plain b/tests/data/testplainfile/missing_acceptance_section.plain new file mode 100644 index 0000000..f823f9b --- /dev/null +++ b/tests/data/testplainfile/missing_acceptance_section.plain @@ -0,0 +1,9 @@ +--- +module: task-manager +func_id: add-task +--- + +***conformance tests*** + +# works +It works. diff --git a/tests/data/testplainfile/missing_func_id.plain b/tests/data/testplainfile/missing_func_id.plain new file mode 100644 index 0000000..6bcf562 --- /dev/null +++ b/tests/data/testplainfile/missing_func_id.plain @@ -0,0 +1,7 @@ +--- +module: task-manager +--- + +***conformance tests*** + +***acceptance tests*** diff --git a/tests/data/testplainfile/missing_link.plain b/tests/data/testplainfile/missing_link.plain new file mode 100644 index 0000000..39cf4ee --- /dev/null +++ b/tests/data/testplainfile/missing_link.plain @@ -0,0 +1,11 @@ +--- +module: task-manager +func_id: add-task +--- + +***conformance tests*** + +# works +See [the missing notes](missing.md). + +***acceptance tests*** diff --git a/tests/data/testplainfile/module_not_a_string.plain b/tests/data/testplainfile/module_not_a_string.plain new file mode 100644 index 0000000..b3fe4ea --- /dev/null +++ b/tests/data/testplainfile/module_not_a_string.plain @@ -0,0 +1,8 @@ +--- +module: [task-manager] +func_id: add-task +--- + +***conformance tests*** + +***acceptance tests*** diff --git a/tests/data/testplainfile/notes.md b/tests/data/testplainfile/notes.md new file mode 100644 index 0000000..a6c631c --- /dev/null +++ b/tests/data/testplainfile/notes.md @@ -0,0 +1 @@ +The expected output. diff --git a/tests/data/testplainfile/scenario_before_section.plain b/tests/data/testplainfile/scenario_before_section.plain new file mode 100644 index 0000000..289c79d --- /dev/null +++ b/tests/data/testplainfile/scenario_before_section.plain @@ -0,0 +1,11 @@ +--- +module: task-manager +func_id: add-task +--- + +# works +It works. + +***conformance tests*** + +***acceptance tests*** diff --git a/tests/data/testplainfile/sections_out_of_order.plain b/tests/data/testplainfile/sections_out_of_order.plain new file mode 100644 index 0000000..15df80b --- /dev/null +++ b/tests/data/testplainfile/sections_out_of_order.plain @@ -0,0 +1,11 @@ +--- +module: task-manager +func_id: add-task +--- + +***acceptance tests*** + +# works +It works. + +***conformance tests*** diff --git a/tests/data/testplainfile/unknown_section.plain b/tests/data/testplainfile/unknown_section.plain new file mode 100644 index 0000000..f96844b --- /dev/null +++ b/tests/data/testplainfile/unknown_section.plain @@ -0,0 +1,11 @@ +--- +module: task-manager +func_id: add-task +--- + +***conformance tests*** + +# works +It works. + +***definitions*** diff --git a/tests/data/testplainfile/valid.plain b/tests/data/testplainfile/valid.plain new file mode 100644 index 0000000..d793a8f --- /dev/null +++ b/tests/data/testplainfile/valid.plain @@ -0,0 +1,40 @@ +--- +module: task-manager +func_id: add-task +--- + +> A comment before the first section. + +***conformance tests*** + +# add-first-task +Adding a :Task: named "Buy milk" to an empty :TaskList: shows one :Task: named "Buy milk". + - :App: prints `Added task 1: Buy milk`. + +# add-without-name + +given: + - an empty :TaskList: +when: + - :User: runs `todo add` +then: + - stderr is `Error: name is required` +> A comment inside a body is dropped. + - exit status is 1 + + +# gherkin-shape +Scenario: adding a task with a note + Given an empty :TaskList: + When :User: adds "Buy milk" + + Then :TaskList: shows it + +# links-the-notes +The output matches [the notes](notes.md). + +***acceptance tests*** + +# add-then-list +- :User: adds a :Task: named "Buy milk". +- `todo list` shows `1 Buy milk`. diff --git a/tests/test_test_plain_file.py b/tests/test_test_plain_file.py new file mode 100644 index 0000000..ab9c98e --- /dev/null +++ b/tests/test_test_plain_file.py @@ -0,0 +1,215 @@ +import os +import re + +import pytest + +from plain_parser import test_plain_file +from plain_parser.exceptions import PlainSyntaxError + + +@pytest.fixture +def fixtures_dir(get_test_data_path): + return get_test_data_path("data/testplainfile") + + +def _parse(fixtures_dir, file_name, code_variables=None): + with open(os.path.join(fixtures_dir, file_name)) as f: + text = f.read() + return test_plain_file.parse_test_plain_file_source(text, file_name, [fixtures_dir], code_variables or {}) + + +def test_valid_file_binds_to_its_functionality_and_keeps_the_scenarios_verbatim(fixtures_dir): + parsed = _parse(fixtures_dir, "valid.plain") + + assert (parsed.name, parsed.module, parsed.func_id) == ("valid.plain", "task-manager", "add-task") + assert [scenario.to_dict() for scenario in parsed.conformance_tests] == [ + { + "id": "add-first-task", + "text": 'Adding a :Task: named "Buy milk" to an empty :TaskList: shows one :Task: named "Buy milk".\n' + " - :App: prints `Added task 1: Buy milk`.", + "linked_resources": [], + }, + { + "id": "add-without-name", + "text": "given:\n - an empty :TaskList:\nwhen:\n - :User: runs `todo add`\nthen:\n" + " - stderr is `Error: name is required`\n - exit status is 1", + "linked_resources": [], + }, + { + "id": "gherkin-shape", + "text": 'Scenario: adding a task with a note\n Given an empty :TaskList:\n When :User: adds "Buy milk"\n\n' + " Then :TaskList: shows it", + "linked_resources": [], + }, + { + "id": "links-the-notes", + "text": "The output matches [the notes](notes.md).", + "linked_resources": [{"text": "the notes", "target": "notes.md"}], + }, + ] + assert [scenario.to_dict() for scenario in parsed.acceptance_tests] == [ + { + "id": "add-then-list", + "text": '- :User: adds a :Task: named "Buy milk".\n- `todo list` shows `1 Buy milk`.', + "linked_resources": [], + } + ] + assert [scenario.line_number for scenario in parsed.scenarios] == [10, 14, 26, 33, 38] + + +def test_both_sections_may_be_empty(fixtures_dir): + parsed = _parse(fixtures_dir, "empty_sections.plain") + + assert (parsed.conformance_tests, parsed.acceptance_tests) == ([], []) + + +@pytest.mark.parametrize( + ("file_name", "message"), + [ + ( + "missing_acceptance_section.plain", + "Plain syntax error: Test plain file 'missing_acceptance_section.plain' has no `***acceptance tests***` " + "section. Run the add-test-scenarios skill to update the file.", + ), + ( + "sections_out_of_order.plain", + "Plain syntax error: Syntax error at line 6 in test plain file 'sections_out_of_order.plain': The " + "`***acceptance tests***` section must come after `***conformance tests***`.", + ), + ( + "duplicate_section.plain", + "Plain syntax error: Syntax error at line 11 in test plain file 'duplicate_section.plain': Duplicate " + "section `***conformance tests***`.", + ), + ( + "unknown_section.plain", + "Plain syntax error: Syntax error at line 11 in test plain file 'unknown_section.plain': Invalid section " + "(`***definitions***`). A test plain file has the sections `***conformance tests***` and " + "`***acceptance tests***`, in this order.", + ), + ( + "extra_frontmatter_key.plain", + "Plain syntax error: Test plain file 'extra_frontmatter_key.plain' has the frontmatter key " + "`description:`; only `module:` and `func_id:` are allowed.", + ), + ( + "missing_func_id.plain", + "Plain syntax error: Test plain file 'missing_func_id.plain' has no `func_id:` key in its frontmatter. " + "Run the add-test-scenarios skill to update the file.", + ), + ( + "module_not_a_string.plain", + "Plain syntax error: Test plain file 'module_not_a_string.plain': the frontmatter key `module:` must be " + "a non-empty string.", + ), + ( + "duplicate_scenario_id.plain", + "Plain syntax error: Syntax error at line 13 in test plain file 'duplicate_scenario_id.plain': Duplicate " + "scenario-id `works`; it is first used at line 8.", + ), + ( + "invalid_scenario_id.plain", + "Plain syntax error: Syntax error at line 8 in test plain file 'invalid_scenario_id.plain': Invalid " + "scenario-id (`Works Well`). A scenario-id has only lowercase letters, digits and hyphens.", + ), + ( + "content_before_section.plain", + "Plain syntax error: Syntax error at line 6 in test plain file 'content_before_section.plain': Content " + "before the first section (`It works.`); a test plain file starts with `***conformance tests***`.", + ), + ( + "content_before_scenario.plain", + "Plain syntax error: Syntax error at line 8 in test plain file 'content_before_scenario.plain': Content " + "before the first `# ` heading in the `conformance tests` section (`It works.`).", + ), + ( + "scenario_before_section.plain", + "Plain syntax error: Syntax error at line 6 in test plain file 'scenario_before_section.plain': Scenario " + "`works` comes before the first section; a test plain file starts with `***conformance tests***`.", + ), + ( + "empty_scenario.plain", + "Plain syntax error: Syntax error at line 8 in test plain file 'empty_scenario.plain': Scenario `works` " + "has no text.", + ), + ], +) +def test_invalid_file_is_rejected_with_its_place(fixtures_dir, file_name, message): + with pytest.raises(PlainSyntaxError) as exc_info: + _parse(fixtures_dir, file_name) + + assert str(exc_info.value) == message + + +def test_missing_link_names_the_file_and_the_scenario(fixtures_dir): + with pytest.raises(PlainSyntaxError) as exc_info: + _parse(fixtures_dir, "missing_link.plain") + + assert str(exc_info.value).startswith( + "Plain syntax error: Link missing.md in test plain file 'missing_link.plain', scenario `works` does not " + "exist. Linked resources are looked up relative to the following directories" + ) + + +def test_link_to_a_plain_file_is_rejected(fixtures_dir): + with pytest.raises(PlainSyntaxError, match=re.escape("Referenced resource 'valid.plain' in test plain file")): + _parse(fixtures_dir, "link_to_plain_file.plain") + + +def test_invalid_frontmatter_is_rejected(): + with pytest.raises(PlainSyntaxError, match="Test plain file 'broken.plain' has invalid frontmatter"): + test_plain_file.parse_test_plain_file_source("---\nmodule: [\n---\n", "broken.plain", [], {}) + + +def test_liquid_runs_with_the_template_directories(tmp_path): + (tmp_path / "shape.plain").write_text(" - exit status is {{ status | code_variable }}") + text = ( + "---\nmodule: m\nfunc_id: f\n---\n\n***conformance tests***\n\n# works\n" + 'It works.\n{% include "shape.plain", status: "0" %}\n\n***acceptance tests***\n' + ) + code_variables = {} + + parsed = test_plain_file.parse_test_plain_file_source(text, "f.plain", [str(tmp_path)], code_variables) + + assert parsed.conformance_tests[0].to_dict() == { + "id": "works", + "text": "It works.\n - exit status is {{ status }}", + "linked_resources": [], + "code_variables": [{"name": "status", "value": "0"}], + } + + +def test_liquid_error_names_the_file(): + text = "---\nmodule: m\nfunc_id: f\n---\n\n***conformance tests***\n\n# works\nIt {{ works\n" + + with pytest.raises(PlainSyntaxError, match=re.escape("Invalid Liquid at f.plain:")): + test_plain_file.parse_test_plain_file_source(text, "f.plain", [], {}) + + +def test_scenario_concepts_are_checked_against_the_visible_concepts(fixtures_dir): + parsed = _parse(fixtures_dir, "valid.plain") + + assert test_plain_file.validate_scenario_concepts(parsed, {":Task:", ":TaskList:", ":App:", ":User:"}) == [] + assert test_plain_file.validate_scenario_concepts(parsed, {":Task:", ":App:", ":User:"}) == [ + "Syntax error at line 10 in test plain file 'valid.plain': Concept :TaskList: used in scenario " + "`add-first-task` is not defined in module 'task-manager'.", + "Syntax error at line 14 in test plain file 'valid.plain': Concept :TaskList: used in scenario " + "`add-without-name` is not defined in module 'task-manager'.", + "Syntax error at line 26 in test plain file 'valid.plain': Concept :TaskList: used in scenario " + "`gherkin-shape` is not defined in module 'task-manager'.", + ] + + +def test_predefined_concepts_need_no_definition(): + scenario = test_plain_file.Scenario("works", 8, ":ConformanceTests: start :Implementation:.") + parsed = test_plain_file.TestPlainFile("f.plain", "m", "f", [scenario], []) + + assert test_plain_file.validate_scenario_concepts(parsed, set()) == [] + + +@pytest.mark.parametrize( + ("metadata", "expected"), + [({"module": "m", "func_id": "f"}, True), ({"module": "m"}, False), ({}, False), ({"description": "x"}, False)], +) +def test_is_test_plain_file(metadata, expected): + assert test_plain_file.is_test_plain_file(metadata) is expected From 6b4314e84721315319be232b1d37e8fbdf9ddc44 Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Fri, 16 Oct 2026 12:36:15 +0200 Subject: [PATCH 05/15] feat: bind test plain files to functionalities Every .plain file under the test folders is read as a test plain file, bound to a module of the chain and one of its func-ids, checked against the concepts of that module, and merged into the functionality in discovery order. With conformance-tests-script in the config, every functionality needs a conformance scenario; the error names it as : and the add-test-scenarios skill. The config keys test-dir and conformance-tests-script are read like template-dir. --- src/plain_parser/__init__.py | 3 +- src/plain_parser/cli.py | 115 ++++++-- src/plain_parser/exceptions.py | 6 + src/plain_parser/project.py | 154 ++++++++++ tests/data/cli/project/config.yaml | 2 + .../cli/project/tests/todo/add-task.plain | 17 ++ .../cli/project/tests/todo/list-tasks.plain | 11 + tests/data/cli/project/todo.plain | 15 + tests/test_cli.py | 100 +++++++ tests/test_project.py | 268 ++++++++++++++++++ 10 files changed, 660 insertions(+), 31 deletions(-) create mode 100644 src/plain_parser/project.py create mode 100644 tests/data/cli/project/config.yaml create mode 100644 tests/data/cli/project/tests/todo/add-task.plain create mode 100644 tests/data/cli/project/tests/todo/list-tasks.plain create mode 100644 tests/data/cli/project/todo.plain create mode 100644 tests/test_project.py diff --git a/src/plain_parser/__init__.py b/src/plain_parser/__init__.py index b27dbbf..a37fbfe 100644 --- a/src/plain_parser/__init__.py +++ b/src/plain_parser/__init__.py @@ -1,6 +1,6 @@ """Parser for ***plain specification files.""" -from plain_parser import concept_utils, exceptions, loaders, plain_file, plain_spec, test_plain_file +from plain_parser import concept_utils, exceptions, loaders, plain_file, plain_spec, project, test_plain_file from plain_parser.plain_file import ( PlainFileParseResult, get_filename_from_module_name, @@ -26,5 +26,6 @@ "plain_file", "plain_file_parser", "plain_spec", + "project", "test_plain_file", ] diff --git a/src/plain_parser/cli.py b/src/plain_parser/cli.py index 24a9a48..990c2a1 100644 --- a/src/plain_parser/cli.py +++ b/src/plain_parser/cli.py @@ -4,14 +4,17 @@ import json import os import sys -from dataclasses import dataclass +from dataclasses import dataclass, field import yaml -from plain_parser import loaders, plain_file, plain_spec +from plain_parser import loaders, plain_spec, project DEFAULT_CONFIG_NAME = "config.yaml" TEMPLATE_DIR_CONFIG_KEYS = ("template_dir", "template-dir") +TEST_DIR_CONFIG_KEYS = ("test_dir", "test-dir") +CONFORMANCE_TESTS_SCRIPT_CONFIG_KEYS = ("conformance_tests_script", "conformance-tests-script") +DEFAULT_TEST_DIR = "tests" PARSE_FORMAT_VERSION = 1 @@ -71,17 +74,37 @@ def resolve_config_file(config_name: str, plain_file_path: str) -> str | None: return None -def template_dir_from_config(config_file: str) -> str | None: - """The template-dir value of the config file, resolved against the file's directory. Other keys are ignored.""" +@dataclass +class Config: + template_dir: str | None = None + test_dirs: list[str] | None = None # None when the config does not set test-dir. + conformance_tests_enabled: bool = False # Whether the config names a conformance-tests-script. + + +def _config_value(config: dict, keys: tuple[str, ...]): + """The first value set under one of the key spellings, or None.""" + return next((config[key] for key in keys if config.get(key)), None) + + +def read_config(config_file: str) -> Config: + """The values plain-parser reads from the config file, paths resolved against the file's directory. Other keys + are ignored.""" with open(config_file) as f: config = yaml.safe_load(f) or {} - for key in TEMPLATE_DIR_CONFIG_KEYS: - value = config.get(key) - if value: - value = os.path.expanduser(str(value)) - return value if os.path.isabs(value) else os.path.join(os.path.dirname(config_file), value) - return None + def resolve(value) -> str: + value = os.path.expanduser(str(value)) + return value if os.path.isabs(value) else os.path.join(os.path.dirname(config_file), value) + + result = Config() + template_dir = _config_value(config, TEMPLATE_DIR_CONFIG_KEYS) + if template_dir: + result.template_dir = resolve(template_dir) + test_dir = _config_value(config, TEST_DIR_CONFIG_KEYS) + if test_dir: + result.test_dirs = [resolve(value) for value in (test_dir if isinstance(test_dir, list) else [test_dir])] + result.conformance_tests_enabled = bool(_config_value(config, CONFORMANCE_TESTS_SCRIPT_CONFIG_KEYS)) + return result @dataclass @@ -92,20 +115,37 @@ class ParseResult: # Each link target in the chain, as written, to the path it resolves to, relative to the entry module's dir # with `/` as separator (loaders.relative_resource_path). resources: dict[str, str] - - -def parse_spec(plain_file_path: str, template_dir: str | None, load_resources: bool) -> ParseResult: - """Parse the module and its requires chain, resolve its links, and walk the entry's FRIDs; raise on the first error. - - With ``load_resources``, each module's linked files are also read and checked (text only, no large base64 blob) right - after that module's links are collected, so a resource error is reported before a FRID-walk error. + # Each test plain file, by its path relative to the entry module's dir with `/`, to the functionality it binds to. + test_plain_files: dict[str, dict] = field(default_factory=dict) + + +def parse_spec( + plain_file_path: str, + template_dir: str | None, + load_resources: bool, + test_dirs: list[str] | None = None, + require_conformance_scenarios: bool = False, +) -> ParseResult: + """Parse the module, its requires chain and its test plain files, resolve the links, and walk the entry's + FRIDs; raise on the first error. + + ``test_dirs`` defaults to ``tests`` next to the module. With ``load_resources``, each module's linked files are + also read and checked (text only, no large base64 blob) right after that module's links are collected, so a + resource error is reported before a FRID-walk error. """ # Absolute, so the chain parse and the resource paths below search the same directories. - template_dirs = [os.path.dirname(os.path.abspath(plain_file_path))] + entry_dir = os.path.dirname(os.path.abspath(plain_file_path)) + template_dirs = [entry_dir] if template_dir: template_dirs.append(os.path.abspath(template_dir)) + if test_dirs is None: + test_dirs = [os.path.join(entry_dir, DEFAULT_TEST_DIR)] + test_dirs = [os.path.abspath(test_dir) for test_dir in test_dirs] - chain = plain_file.parse_module_chain(os.path.basename(plain_file_path), template_dirs) + parsed = project.parse_project( + os.path.basename(plain_file_path), template_dirs, test_dirs, require_conformance_scenarios + ) + chain = parsed.chain # The target joined as written onto its template dir (`..` kept, symlinks not resolved), made relative to the # entry module's dir so the result does not depend on where the project sits on the machine. @@ -124,12 +164,21 @@ def parse_spec(plain_file_path: str, template_dir: str | None, load_resources: b for frid in plain_spec.get_frids(top_plain_source_tree): plain_spec.get_specifications_for_frid(top_plain_source_tree, frid) - return ParseResult(chain, resources) + test_plain_files = { + bound.name: {"module": bound.parsed.module, "func_id": bound.parsed.func_id} + for bound in parsed.test_plain_files + } + return ParseResult(chain, resources, test_plain_files) -def check(plain_file_path: str, template_dir: str | None) -> None: - """Raise if the module, its requires chain, or its linked resources are invalid.""" - parse_spec(plain_file_path, template_dir, load_resources=True) +def check( + plain_file_path: str, + template_dir: str | None, + test_dirs: list[str] | None = None, + require_conformance_scenarios: bool = False, +) -> None: + """Raise if the module, its requires chain, its test plain files, or its linked resources are invalid.""" + parse_spec(plain_file_path, template_dir, True, test_dirs, require_conformance_scenarios) def _error_output(type_name: str, message: str) -> dict: @@ -146,11 +195,16 @@ def _encode_output(output: dict) -> bytes: return (json.dumps(output, indent=2, ensure_ascii=False) + "\n").encode("utf-8") -def parse(plain_file_path: str, template_dir: str | None) -> int: +def parse( + plain_file_path: str, + template_dir: str | None, + test_dirs: list[str] | None = None, + require_conformance_scenarios: bool = False, +) -> int: """Print the parse result, or the parser error, as one UTF-8 JSON document on stdout; return the exit code.""" output: dict try: - result = parse_spec(plain_file_path, template_dir, load_resources=False) + result = parse_spec(plain_file_path, template_dir, False, test_dirs, require_conformance_scenarios) output = { "format_version": PARSE_FORMAT_VERSION, "modules": [{"module": module_name, "tree": tree} for module_name, tree in result.modules], @@ -209,10 +263,11 @@ def main(argv: list[str] | None = None) -> int: try: # The config file is resolved even when --template-dir is given, so an ambiguous config is always an error. - config_template_dir = _template_dir_from_config(parser, args) + config = _read_config(parser, args) + template_dir = args.template_dir or config.template_dir if args.command == "parse": - return parse(args.plain_file, args.template_dir or config_template_dir) - check(args.plain_file, args.template_dir or config_template_dir) + return parse(args.plain_file, template_dir, config.test_dirs, config.conformance_tests_enabled) + check(args.plain_file, template_dir, config.test_dirs, config.conformance_tests_enabled) except KeyboardInterrupt: return 130 except Exception as e: @@ -223,10 +278,10 @@ def main(argv: list[str] | None = None) -> int: return 0 -def _template_dir_from_config(parser: argparse.ArgumentParser, args: argparse.Namespace) -> str | None: +def _read_config(parser: argparse.ArgumentParser, args: argparse.Namespace) -> Config: try: config_file = resolve_config_file(args.config_name, args.plain_file) - return template_dir_from_config(config_file) if config_file is not None else None + return read_config(config_file) if config_file is not None else Config() except AmbiguousConfigFileError as e: parser.error(str(e)) except Exception as e: diff --git a/src/plain_parser/exceptions.py b/src/plain_parser/exceptions.py index 85b408d..9ef40e7 100644 --- a/src/plain_parser/exceptions.py +++ b/src/plain_parser/exceptions.py @@ -37,3 +37,9 @@ class ImportedModuleWithFunctionalitiesError(Exception): """ pass + + +class MissingConformanceScenariosError(Exception): + """Raised when conformance tests are enabled and a functionality has no conformance scenario.""" + + pass diff --git a/src/plain_parser/project.py b/src/plain_parser/project.py new file mode 100644 index 0000000..6bb4338 --- /dev/null +++ b/src/plain_parser/project.py @@ -0,0 +1,154 @@ +"""A module with its requires chain and the test plain files bound to its functionalities.""" + +import os +from dataclasses import dataclass + +import frontmatter + +from plain_parser import concept_utils, loaders, plain_file, plain_spec, test_plain_file +from plain_parser.exceptions import MissingConformanceScenariosError, PlainSyntaxError, UnsupportedBase64Content + + +@dataclass +class BoundTestPlainFile: + path: str # The file on disk. + name: str # The path relative to the entry module's directory, with `/` as separator. + parsed: test_plain_file.TestPlainFile + + +@dataclass +class ProjectParseResult: + # As parse_module_chain returns it, with the scenarios of the test plain files merged into the functionalities. + chain: list[tuple[str, dict]] + test_plain_files: list[BoundTestPlainFile] + + +def discover_test_plain_files(test_dirs: list[str], entry_dir: str) -> list[tuple[str, str]]: + """(path, name) of every .plain file under the test dirs: test dir by test dir, and within one by path. A + test dir that does not exist is skipped.""" + found = [] + for test_dir in test_dirs: + if not os.path.isdir(test_dir): + continue + for root, dirs, files in os.walk(test_dir): + dirs.sort() + for file_name in sorted(files): + if file_name.endswith(plain_file.PLAIN_SOURCE_FILE_EXTENSION): + found.append( + (os.path.join(root, file_name), loaders.relative_resource_path(entry_dir, root, file_name)) + ) + return found + + +def read_test_plain_file_text(path: str, name: str) -> str: + with open(path, "rb") as f: + raw = f.read() + try: + text = raw.decode("utf-8") + except UnicodeDecodeError: + raise PlainSyntaxError(f"Plain syntax error: File '{name}' under a test folder is not a text file.") + + blob = loaders.find_large_base64_blob(text) + if blob is not None: + raise UnsupportedBase64Content( + f"Test plain file '{name}' contains a base64-encoded blob ({len(blob)} characters) inlined in the " + "scenarios. This is not supported. Remove the base64 data from the file." + ) + return text + + +def bind_test_plain_files( + chain: list[tuple[str, dict]], test_dirs: list[str], template_dirs: list[str], entry_dir: str +) -> list[BoundTestPlainFile]: + """Read every test plain file under the test dirs and add its scenarios to the functionality it binds to. + + A `.plain` file under a test dir without the `module:` and `func_id:` keys is an error, as is a file that binds to + a module outside the chain or to a func-id the module does not have. The concepts of the scenarios must be + visible to the bound module. Files bound to one functionality are merged in discovery order; a scenario-id is + unique across that merged set. + """ + trees = dict(chain) + bound = [] + scenario_places: dict[tuple[str, str], dict[str, str]] = {} # (module, func-id) -> scenario-id -> place + for path, name in discover_test_plain_files(test_dirs, entry_dir): + text = read_test_plain_file_text(path, name) + try: + metadata = frontmatter.loads(text).metadata + except Exception as e: + raise PlainSyntaxError( + f"Plain syntax error: File '{name}' under a test folder has invalid frontmatter: {e}" + ) + if not test_plain_file.is_test_plain_file(metadata): + raise PlainSyntaxError( + f"Plain syntax error: File '{name}' is under a test folder but is not a test plain file: its " + f"frontmatter needs the keys `module:` and `func_id:`." + ) + + parsed = test_plain_file.parse_test_plain_file_source(text, name, template_dirs, {}) + if parsed.module not in trees: + raise PlainSyntaxError( + f"Plain syntax error: Test plain file '{name}' binds to module '{parsed.module}', which is not the " + f"entry module or one of its required modules ({', '.join(trees)})." + ) + tree = trees[parsed.module] + functionalities = tree.get(plain_spec.FUNCTIONAL_REQUIREMENTS) or [] + functionality = next((item for item in functionalities if item["id"] == parsed.func_id), None) + if functionality is None: + raise PlainSyntaxError( + f"Plain syntax error: Test plain file '{name}' binds to functionality " + f"'{parsed.module}:{parsed.func_id}', which module '{parsed.module}' does not have. " + f"{test_plain_file.TEST_SCENARIOS_HINT}" + ) + + concepts, _ = concept_utils.collect_concepts(tree) + errors = test_plain_file.validate_scenario_concepts(parsed, set(concepts)) + if errors: + raise PlainSyntaxError( + f"Plain syntax error: Found {len(errors)} errors in test plain file '{name}':\n" + "\n".join(errors) + ) + + places = scenario_places.setdefault((parsed.module, parsed.func_id), {}) + for key, scenarios in ( + (plain_spec.CONFORMANCE_TESTS, parsed.conformance_tests), + (plain_spec.ACCEPTANCE_TESTS, parsed.acceptance_tests), + ): + for scenario in scenarios: + if scenario.id in places: + raise PlainSyntaxError( + f"Plain syntax error: Syntax error at line {scenario.line_number} in test plain file " + f"'{name}': Duplicate scenario-id `{scenario.id}` for functionality " + f"'{parsed.module}:{parsed.func_id}'; it is first used {places[scenario.id]}." + ) + places[scenario.id] = f"at line {scenario.line_number} in test plain file '{name}'" + functionality[key].append(scenario.to_dict()) + + bound.append(BoundTestPlainFile(path, name, parsed)) + + return bound + + +def check_conformance_scenarios(chain: list[tuple[str, dict]]) -> None: + """Raise for the first functionality of the chain, in render order, that has no conformance scenario.""" + for module_name, tree in chain: + for functionality in tree.get(plain_spec.FUNCTIONAL_REQUIREMENTS) or []: + if not functionality[plain_spec.CONFORMANCE_TESTS]: + func_id = functionality["id"] + raise MissingConformanceScenariosError( + f"Functionality {module_name}:{func_id} has no conformance scenario. Add one in a test plain file " + f"(by convention tests/{module_name}/{func_id}.plain) with the add-test-scenarios skill." + ) + + +def parse_project( + plain_file_name: str, template_dirs: list[str], test_dirs: list[str], require_conformance_scenarios: bool +) -> ProjectParseResult: + """Parse a module with its requires chain and bind the test plain files under ``test_dirs`` to its functionalities. + + ``template_dirs[0]`` is the entry module's directory; test plain files are named relative to it. With + ``require_conformance_scenarios``, every functionality of every module in the chain needs a conformance scenario. + """ + chain = plain_file.parse_module_chain(plain_file_name, template_dirs) + bound = bind_test_plain_files(chain, test_dirs, template_dirs, template_dirs[0]) + if require_conformance_scenarios: + check_conformance_scenarios(chain) + return ProjectParseResult(chain, bound) diff --git a/tests/data/cli/project/config.yaml b/tests/data/cli/project/config.yaml new file mode 100644 index 0000000..909167b --- /dev/null +++ b/tests/data/cli/project/config.yaml @@ -0,0 +1,2 @@ +unittests-script: test_scripts/run_unittests.sh +conformance-tests-script: test_scripts/run_conformance_tests.sh diff --git a/tests/data/cli/project/tests/todo/add-task.plain b/tests/data/cli/project/tests/todo/add-task.plain new file mode 100644 index 0000000..bb85002 --- /dev/null +++ b/tests/data/cli/project/tests/todo/add-task.plain @@ -0,0 +1,17 @@ +--- +module: todo +func_id: add-task +--- + +***conformance tests*** + +# add-first-task +Running `todo add Buy milk` on an empty list prints `Added task 1: Buy milk` and exits with status 0. + +# add-without-name +Running `todo add` prints `Error: name is required` to stderr and exits with status 1. + +***acceptance tests*** + +# add-then-list +Running `todo add Buy milk` and then `todo list` prints `1 Buy milk`. diff --git a/tests/data/cli/project/tests/todo/list-tasks.plain b/tests/data/cli/project/tests/todo/list-tasks.plain new file mode 100644 index 0000000..121a9ef --- /dev/null +++ b/tests/data/cli/project/tests/todo/list-tasks.plain @@ -0,0 +1,11 @@ +--- +module: todo +func_id: list-tasks +--- + +***conformance tests*** + +# list-two-tasks +With two :Task: items added, `todo list` prints two lines, lowest id first, and exits with status 0. + +***acceptance tests*** diff --git a/tests/data/cli/project/todo.plain b/tests/data/cli/project/todo.plain new file mode 100644 index 0000000..8167187 --- /dev/null +++ b/tests/data/cli/project/todo.plain @@ -0,0 +1,15 @@ +***definitions*** + +- :Task: is a thing to do. + +***implementation reqs*** + +- :Implementation: is written in Python 3.12. + +***functional specs*** + +# add-task +A :Task: can be added with `todo add `. + +# list-tasks +Every :Task: is listed by `todo list`, one per line. diff --git a/tests/test_cli.py b/tests/test_cli.py index 28606c5..228f354 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -337,6 +337,106 @@ def test_check_valid_module_prints_ok_line_with_file_as_given(cli_specs_dir, cap assert (exit_code, out, err) == (0, "valid.plain: OK\n", "") +@pytest.fixture +def project_dir(cli_specs_dir): + # todo.plain with two functionalities, a config.yaml naming a conformance-tests-script, and one test plain file + # per functionality under tests/todo/. + return os.path.join(cli_specs_dir, "project") + + +def _copy_project(project_dir, tmp_path): + copy = tmp_path / "project" + shutil.copytree(project_dir, copy) + return copy + + +def test_check_project_with_test_plain_files_prints_ok(project_dir, capsys, monkeypatch): + monkeypatch.chdir(project_dir) + + assert _run_check(capsys, "todo.plain") == (0, "todo.plain: OK\n", "") + + +def test_check_missing_conformance_scenario_names_the_functionality_and_the_skill(project_dir, tmp_path, capsys): + copy = _copy_project(project_dir, tmp_path) + (copy / "tests" / "todo" / "list-tasks.plain").unlink() + + exit_code, out, err = _run_check(capsys, str(copy / "todo.plain")) + + assert (exit_code, out) == (1, "") + assert err == ( + "Error: Functionality todo:list-tasks has no conformance scenario. Add one in a test plain file " + "(by convention tests/todo/list-tasks.plain) with the add-test-scenarios skill.\n" + ) + + +def test_check_without_conformance_tests_script_needs_no_scenarios(project_dir, tmp_path, capsys): + copy = _copy_project(project_dir, tmp_path) + shutil.rmtree(copy / "tests") + (copy / "config.yaml").write_text("unittests-script: test_scripts/run_unittests.sh\n") + + _assert_ok(_run_check(capsys, str(copy / "todo.plain"))) + + +def test_check_test_plain_file_error_is_reported_with_its_file(project_dir, tmp_path, capsys): + copy = _copy_project(project_dir, tmp_path) + (copy / "tests" / "todo" / "list-tasks.plain").write_text( + "---\nmodule: todo\nfunc_id: list-tasks\n---\n\n***conformance tests***\n\n# lists\nIt lists a :Note:.\n\n" + "***acceptance tests***\n" + ) + + exit_code, out, err = _run_check(capsys, str(copy / "todo.plain")) + + assert (exit_code, out) == (1, "") + assert err == ( + "Error: Plain syntax error: Found 1 errors in test plain file 'tests/todo/list-tasks.plain':\n" + "Syntax error at line 8 in test plain file 'tests/todo/list-tasks.plain': Concept :Note: used in scenario " + "`lists` is not defined in module 'todo'.\n" + ) + + +@pytest.mark.parametrize( + "config_text", + [ + "conformance-tests-script: run.sh\ntest-dir: scenarios\n", + "conformance_tests_script: run.sh\ntest_dir: scenarios\n", + "conformance-tests-script: run.sh\ntest-dir:\n - scenarios\n - more\n", + ], +) +def test_check_reads_test_dir_from_config(project_dir, tmp_path, capsys, monkeypatch, config_text): + copy = _copy_project(project_dir, tmp_path) + (copy / "tests").rename(copy / "scenarios") + (copy / "config.yaml").write_text(config_text) + monkeypatch.chdir(tmp_path) # not the project dir: the relative value must resolve against the config file + + _assert_ok(_run_check(capsys, str(copy / "todo.plain"))) + (copy / "scenarios" / "todo" / "add-task.plain").unlink() + assert "todo:add-task has no conformance scenario" in _run_check(capsys, str(copy / "todo.plain"))[2] + + +def test_check_config_test_dir_replaces_the_default(project_dir, tmp_path, capsys): + copy = _copy_project(project_dir, tmp_path) + (copy / "config.yaml").write_text("conformance-tests-script: run.sh\ntest-dir: scenarios\n") + + exit_code, _, err = _run_check(capsys, str(copy / "todo.plain")) + + assert exit_code == 1 + assert "todo:add-task has no conformance scenario" in err + + +def test_check_link_in_scenario_is_read(project_dir, tmp_path, capsys): + copy = _copy_project(project_dir, tmp_path) + (copy / "tests" / "todo" / "list-tasks.plain").write_text( + "---\nmodule: todo\nfunc_id: list-tasks\n---\n\n***conformance tests***\n\n# lists\n" + "The output is [the listing](listing.bin).\n\n***acceptance tests***\n" + ) + (copy / "listing.bin").write_bytes(b"\x00\xff\xfe") + + exit_code, _, err = _run_check(capsys, str(copy / "todo.plain")) + + assert exit_code == 1 + assert err.startswith("Error: Referenced resource 'listing.bin' in module 'todo' is a binary file.") + + def test_check_reports_ancestor_resource_before_frid_walk_error(cli_specs_dir, capsys): # base.plain links a binary file; top.plain also has a code-variable conflict only the FRID walk finds. spec = os.path.join(cli_specs_dir, "resource_before_frid_walk", "top.plain") diff --git a/tests/test_project.py b/tests/test_project.py new file mode 100644 index 0000000..4f30dd9 --- /dev/null +++ b/tests/test_project.py @@ -0,0 +1,268 @@ +import pytest + +from plain_parser import plain_spec, project +from plain_parser.exceptions import MissingConformanceScenariosError, PlainSyntaxError + +MODULE = ( + "***definitions***\n\n- :Task: is a thing to do.\n\n***implementation reqs***\n\n- A req.\n\n" + "***functional specs***\n\n# add-task\nA :Task: can be added.\n\n# list-tasks\nEvery :Task: is listed.\n" +) + + +def write(path, text): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + + +def scenario_file(module, func_id, conformance="", acceptance=""): + return ( + f"---\nmodule: {module}\nfunc_id: {func_id}\n---\n\n***conformance tests***\n\n{conformance}" + f"\n***acceptance tests***\n\n{acceptance}" + ) + + +def parse(tmp_path, require_conformance_scenarios=False, test_dirs=None): + test_dirs = [str(tmp_path / "tests")] if test_dirs is None else test_dirs + return project.parse_project("todo.plain", [str(tmp_path)], test_dirs, require_conformance_scenarios) + + +def functionalities(result, module="todo"): + return {item["id"]: item for item in dict(result.chain)[module][plain_spec.FUNCTIONAL_REQUIREMENTS]} + + +def scenario_ids(functionality, key): + return [scenario["id"] for scenario in functionality[key]] + + +def test_scenarios_are_merged_into_the_bound_functionality(tmp_path): + write(tmp_path / "todo.plain", MODULE) + write( + tmp_path / "tests" / "todo" / "add-task.plain", + scenario_file( + "todo", "add-task", "# add-one\nAdding one :Task: shows it.\n", "# add-then-list\nAdd, then list.\n" + ), + ) + + result = parse(tmp_path) + + assert [(bound.name, bound.parsed.module, bound.parsed.func_id) for bound in result.test_plain_files] == [ + ("tests/todo/add-task.plain", "todo", "add-task") + ] + assert functionalities(result)["add-task"][plain_spec.CONFORMANCE_TESTS] == [ + {"id": "add-one", "text": "Adding one :Task: shows it.", "linked_resources": []} + ] + assert functionalities(result)["add-task"][plain_spec.ACCEPTANCE_TESTS] == [ + {"id": "add-then-list", "text": "Add, then list.", "linked_resources": []} + ] + assert functionalities(result)["list-tasks"][plain_spec.CONFORMANCE_TESTS] == [] + + +def test_location_and_name_of_a_test_plain_file_are_convention_only(tmp_path): + write(tmp_path / "todo.plain", MODULE) + write(tmp_path / "tests" / "anything.plain", scenario_file("todo", "list-tasks", "# lists\nIt lists.\n")) + + result = parse(tmp_path) + + assert scenario_ids(functionalities(result)["list-tasks"], plain_spec.CONFORMANCE_TESTS) == ["lists"] + + +def test_files_bound_to_one_functionality_merge_in_path_order(tmp_path): + write(tmp_path / "todo.plain", MODULE) + write(tmp_path / "tests" / "b.plain", scenario_file("todo", "add-task", "# from-b\nFrom b.\n")) + write(tmp_path / "tests" / "a.plain", scenario_file("todo", "add-task", "# from-a\nFrom a.\n")) + write(tmp_path / "tests" / "sub" / "c.plain", scenario_file("todo", "add-task", "# from-c\nFrom c.\n")) + + result = parse(tmp_path) + + assert scenario_ids(functionalities(result)["add-task"], plain_spec.CONFORMANCE_TESTS) == [ + "from-a", + "from-b", + "from-c", + ] + assert [bound.name for bound in result.test_plain_files] == [ + "tests/a.plain", + "tests/b.plain", + "tests/sub/c.plain", + ] + + +def test_test_dirs_are_read_in_the_given_order(tmp_path): + write(tmp_path / "todo.plain", MODULE) + write(tmp_path / "more" / "b.plain", scenario_file("todo", "add-task", "# from-b\nFrom b.\n")) + write(tmp_path / "tests" / "a.plain", scenario_file("todo", "add-task", "# from-a\nFrom a.\n")) + + result = parse(tmp_path, test_dirs=[str(tmp_path / "more"), str(tmp_path / "tests")]) + + assert scenario_ids(functionalities(result)["add-task"], plain_spec.CONFORMANCE_TESTS) == ["from-b", "from-a"] + assert [bound.name for bound in result.test_plain_files] == ["more/b.plain", "tests/a.plain"] + + +def test_missing_test_dir_is_not_an_error(tmp_path): + write(tmp_path / "todo.plain", MODULE) + + result = parse(tmp_path) + + assert result.test_plain_files == [] + + +def test_duplicate_scenario_id_across_files_is_rejected(tmp_path): + write(tmp_path / "todo.plain", MODULE) + write(tmp_path / "tests" / "a.plain", scenario_file("todo", "add-task", "# works\nIt works.\n")) + write(tmp_path / "tests" / "b.plain", scenario_file("todo", "add-task", "", "# works\nIt works again.\n")) + + with pytest.raises(PlainSyntaxError) as exc_info: + parse(tmp_path) + + assert str(exc_info.value) == ( + "Plain syntax error: Syntax error at line 11 in test plain file 'tests/b.plain': Duplicate scenario-id " + "`works` for functionality 'todo:add-task'; it is first used at line 8 in test plain file 'tests/a.plain'." + ) + + +def test_same_scenario_id_for_two_functionalities_is_allowed(tmp_path): + write(tmp_path / "todo.plain", MODULE) + write(tmp_path / "tests" / "a.plain", scenario_file("todo", "add-task", "# works\nIt works.\n")) + write(tmp_path / "tests" / "b.plain", scenario_file("todo", "list-tasks", "# works\nIt works.\n")) + + parse(tmp_path) + + +def test_unknown_module_is_rejected(tmp_path): + write(tmp_path / "todo.plain", MODULE) + write(tmp_path / "tests" / "a.plain", scenario_file("tasks", "add-task", "# works\nIt works.\n")) + + with pytest.raises(PlainSyntaxError) as exc_info: + parse(tmp_path) + + assert str(exc_info.value) == ( + "Plain syntax error: Test plain file 'tests/a.plain' binds to module 'tasks', which is not the entry " + "module or one of its required modules (todo)." + ) + + +def test_unknown_func_id_is_rejected(tmp_path): + write(tmp_path / "todo.plain", MODULE) + write(tmp_path / "tests" / "a.plain", scenario_file("todo", "delete-task", "# works\nIt works.\n")) + + with pytest.raises(PlainSyntaxError) as exc_info: + parse(tmp_path) + + assert str(exc_info.value) == ( + "Plain syntax error: Test plain file 'tests/a.plain' binds to functionality 'todo:delete-task', which " + "module 'todo' does not have. Run the add-test-scenarios skill to update the file." + ) + + +def test_plain_file_under_test_dir_without_binding_keys_is_rejected(tmp_path): + write(tmp_path / "todo.plain", MODULE) + write(tmp_path / "tests" / "helper.plain", "***definitions***\n\n- :Helper: helps.\n") + + with pytest.raises(PlainSyntaxError) as exc_info: + parse(tmp_path) + + assert str(exc_info.value) == ( + "Plain syntax error: File 'tests/helper.plain' is under a test folder but is not a test plain file: its " + "frontmatter needs the keys `module:` and `func_id:`." + ) + + +def test_file_under_test_dir_with_invalid_frontmatter_is_rejected(tmp_path): + write(tmp_path / "todo.plain", MODULE) + write(tmp_path / "tests" / "broken.plain", "---\nmodule: [\n---\n") + + with pytest.raises(PlainSyntaxError, match="File 'tests/broken.plain' under a test folder has invalid frontmatter"): + parse(tmp_path) + + +def test_test_plain_file_errors_name_their_file(tmp_path): + write(tmp_path / "todo.plain", MODULE) + write(tmp_path / "tests" / "a.plain", "---\nmodule: todo\nfunc_id: add-task\n---\n\n***conformance tests***\n") + + with pytest.raises(PlainSyntaxError, match="Test plain file 'tests/a.plain' has no `\\*\\*\\*acceptance tests"): + parse(tmp_path) + + +def test_scenario_concepts_are_checked_against_the_bound_module(tmp_path): + write(tmp_path / "todo.plain", MODULE) + write( + tmp_path / "tests" / "a.plain", scenario_file("todo", "add-task", "# works\nA :Task: and a :Note: are added.\n") + ) + + with pytest.raises(PlainSyntaxError) as exc_info: + parse(tmp_path) + + assert str(exc_info.value) == ( + "Plain syntax error: Found 1 errors in test plain file 'tests/a.plain':\n" + "Syntax error at line 8 in test plain file 'tests/a.plain': Concept :Note: used in scenario `works` is not " + "defined in module 'todo'." + ) + + +def test_scenarios_see_imported_and_exported_concepts(tmp_path): + write(tmp_path / "shared.plain", "***definitions***\n\n- :Clock: tells the time.\n") + write( + tmp_path / "core.plain", + '---\nexported_concepts: [":Task:"]\n---\n\n***definitions***\n\n- :Task: is a thing to do.\n\n' + "***implementation reqs***\n\n- A req.\n\n***functional specs***\n\n# store-task\nA :Task: is stored.\n", + ) + write( + tmp_path / "todo.plain", + "---\nrequires:\n - core\nimport:\n - shared\n---\n\n***implementation reqs***\n\n- A req.\n\n" + "***functional specs***\n\n# add-task\nA :Task: can be added at a :Clock: time.\n", + ) + write( + tmp_path / "tests" / "todo" / "add-task.plain", + scenario_file("todo", "add-task", "# works\nA :Task: at :Clock:.\n"), + ) + write( + tmp_path / "tests" / "core" / "store-task.plain", + scenario_file("core", "store-task", "# stores\nA :Task: is kept.\n"), + ) + + result = parse(tmp_path) + + assert scenario_ids(functionalities(result, "core")["store-task"], plain_spec.CONFORMANCE_TESTS) == ["stores"] + assert scenario_ids(functionalities(result)["add-task"], plain_spec.CONFORMANCE_TESTS) == ["works"] + + +def test_required_module_scenarios_do_not_see_the_top_modules_concepts(tmp_path): + write( + tmp_path / "core.plain", + "***implementation reqs***\n\n- A req.\n\n***functional specs***\n\n# store-task\nIt stores.\n", + ) + write(tmp_path / "todo.plain", "---\nrequires:\n - core\n---\n\n" + MODULE) + write( + tmp_path / "tests" / "core" / "store-task.plain", + scenario_file("core", "store-task", "# stores\nA :Task: is kept.\n"), + ) + + with pytest.raises( + PlainSyntaxError, match="Concept :Task: used in scenario `stores` is not defined in module 'core'" + ): + parse(tmp_path) + + +def test_every_functionality_needs_a_conformance_scenario_when_required(tmp_path): + write(tmp_path / "todo.plain", MODULE) + write(tmp_path / "tests" / "a.plain", scenario_file("todo", "add-task", "# works\nIt works.\n")) + write(tmp_path / "tests" / "b.plain", scenario_file("todo", "list-tasks", "", "# flows\nIt flows.\n")) + + parse(tmp_path) + with pytest.raises(MissingConformanceScenariosError) as exc_info: + parse(tmp_path, require_conformance_scenarios=True) + + assert str(exc_info.value) == ( + "Functionality todo:list-tasks has no conformance scenario. Add one in a test plain file (by convention " + "tests/todo/list-tasks.plain) with the add-test-scenarios skill." + ) + + +def test_completeness_covers_the_required_modules_first(tmp_path): + write( + tmp_path / "core.plain", + "***implementation reqs***\n\n- A req.\n\n***functional specs***\n\n# store-task\nIt stores.\n", + ) + write(tmp_path / "todo.plain", "---\nrequires:\n - core\n---\n\n" + MODULE) + + with pytest.raises(MissingConformanceScenariosError, match="Functionality core:store-task has no conformance"): + parse(tmp_path, require_conformance_scenarios=True) From 9d2c4b13fe52da6c8bbb6e5101a430f540a5e74a Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Fri, 16 Oct 2026 12:38:22 +0200 Subject: [PATCH 06/15] feat: add --test-dir and print test plain files in parse output check and parse take --test-dir, repeatable, which overrides the test-dir config key and the default tests folder. The parse output is format version 2: the functionality trees carry the merged scenarios, resources covers the links in them, and test_plain_files maps each file to the functionality it binds to. The README describes the new syntax. --- README.md | 73 ++++++++++++++++++++++++-------------- src/plain_parser/cli.py | 28 ++++++++++----- tests/test_cli.py | 78 +++++++++++++++++++++++++++++++++++++++-- 3 files changed, 140 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 38645e4..6fa0c08 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ Python parser for [`***plain`](https://www.plainlang.org/docs/) specification files. -Reads a `.plain` module and its `import` / `requires` chain, resolves Liquid templates, -validates concepts and linked resources, and returns the marshalled specification tree -plus the ordered list of functionalities (FRIDs). +Reads a `.plain` module with its `import` / `requires` chain and its test plain files, resolves Liquid templates, +validates concepts and linked resources, and returns the marshalled specification tree of every module in the +chain, with the test scenarios of each functionality merged in. ## Install @@ -15,24 +15,29 @@ pip install plain-parser ## Command line ``` -plain-parser check [--template-dir DIR] [--config-name NAME] -plain-parser parse [--template-dir DIR] [--config-name NAME] +plain-parser check [--template-dir DIR] [--test-dir DIR]... [--config-name NAME] +plain-parser parse [--template-dir DIR] [--test-dir DIR]... [--config-name NAME] ``` -Both commands parse the module and its `requires` chain, check that every linked resource is an existing -file (not a directory), and walk the module's functionalities. They stop at the first error. +Both commands parse the module and its `requires` chain, bind the test plain files under the test directories to the +functionalities, check that every linked resource is an existing file (not a directory), and walk the module's +functionalities. They stop at the first error. ### `check` Validates the module. In addition to the steps above it reads every linked file and rejects binary files and -files holding a large base64 blob. +files holding a large base64 blob. When the config file names a `conformance-tests-script`, every functionality +of every module in the chain needs at least one conformance scenario. ``` $ plain-parser check my_spec.plain my_spec.plain: OK ``` -Exit `0` when the module is valid. On an error, `Error: …` is written to stderr and the exit code is `1`. +Exit `0` when the module is valid. On an error, `Error: …` is written to stderr and the exit code is `1`. The +message names the file, the line where known, and the skill that fixes the problem: the old syntax (a flush-left +`- ` list in `***functional specs***`, an `***acceptance tests***` section in a core spec) names +`migrate-plain-syntax`, and a functionality without a conformance scenario names `add-test-scenarios`. ### `parse` @@ -41,23 +46,27 @@ Prints the parse result as one UTF-8 JSON document on stdout. Linked files are l ``` $ plain-parser parse my_spec.plain { - "format_version": 1, + "format_version": 2, "modules": [{"module": "...", "tree": {...}}, ...], - "resources": {...} + "resources": {...}, + "test_plain_files": {...} } ``` -- `format_version`: `1`. +- `format_version`: `2`. - `modules`: every module in the `requires` chain, in build order: deepest ancestor first, the module itself last. Each entry has the module name and its specification tree, in the shape described under *Python* below. -- `resources`: every linked-resource target in the chain, as written in the spec, mapped to the path it resolves - to, relative to the entry module's directory, with `/` as separator: `notes.md` when found next to the module, - `../tpl/notes.md` when found in a template directory. The directory part is lexical (symlinks not resolved) and - the target is appended as written (`..` kept), so joining the value onto the entry module's directory names the - file the parser matched. When no relative path exists (another drive on Windows) the value is the absolute path, - still with `/`. Two spellings of one file (`notes.md`, `./notes.md`) are two keys. - -Exit `0` on success. On a parser error the JSON is `{"format_version": 1, "type": "", +- `resources`: every linked-resource target in the chain and its test plain files, as written in the spec, mapped + to the path it resolves to, relative to the entry module's directory, with `/` as separator: `notes.md` when + found next to the module, `../tpl/notes.md` when found in a template directory. The directory part is lexical + (symlinks not resolved) and the target is appended as written (`..` kept), so joining the value onto the entry + module's directory names the file the parser matched. When no relative path exists (another drive on Windows) + the value is the absolute path, still with `/`. Two spellings of one file (`notes.md`, `./notes.md`) are two + keys. +- `test_plain_files`: every test plain file read, by its path relative to the entry module's directory with `/` + as separator, mapped to `{"module": ..., "func_id": ...}`, the functionality it binds to. + +Exit `0` on success. On a parser error the JSON is `{"format_version": 2, "type": "", "message": "…"}` and the exit code is `1`. ### Options and exit codes @@ -66,27 +75,37 @@ Modules and `{% include %}` templates are looked up in the `.plain` file's direc When `--template-dir` is not given, it is read from the `template-dir` key of `config.yaml` (or the file named by `--config-name`), found next to the `.plain` file or in the working directory. Finding it in both is a usage error. The config file is read even when `--template-dir` is given, so a malformed one fails the run either way. -Other config keys are ignored. + +Test plain files are the `.plain` files under the test directories: those given with `--test-dir` (repeat the +option for more than one), else the `test-dir` key of the config file (a string or a list), else `tests` next to +the `.plain` file. A test directory that does not exist is skipped. Linked resources in a scenario are looked up +as in a module. The `conformance-tests-script` key of the config file turns on the check that every +functionality has a conformance scenario. Other config keys are ignored. | Exit code | Meaning | |---|---| | `0` | Success | -| `1` | The module, its chain, or a linked resource is invalid | +| `1` | The module, its chain, a test plain file, or a linked resource is invalid | | `2` | Usage error, including an ambiguous or malformed config file | | `130` | Interrupted | ## Python ```python -from plain_parser import plain_file_parser +from plain_parser import plain_file_parser, project module_name, plain_source_tree, required_modules = plain_file_parser("my_spec.plain", ["."]) + +result = project.parse_project("my_spec.plain", ["."], ["tests"], require_conformance_scenarios=True) +chain = result.chain # (module name, plain source tree) pairs, deepest ancestor first, the module itself last ``` -`plain_source_tree` is a dict keyed by section (`definitions`, `implementation reqs`, -`test reqs`, `functional specs`), each a list of `{"markdown": ..., "linked_resources": [...]}` -entries. `plain_parser.plain_spec` has helpers for walking functionalities -(`get_frids`, `get_specifications_for_frid`, `collect_linked_resources`). +`plain_source_tree` is a dict keyed by section. `definitions`, `implementation reqs` and `test reqs` are lists +of `{"markdown": ..., "linked_resources": [...]}` entries. `functional specs` is a list of +`{"id", "markdown", "linked_resources", "conformance_tests", "acceptance_tests"}` entries, one per `# ` +heading in file order; each test entry is `{"id", "text", "linked_resources"}` with the scenario text as written. +`plain_parser.plain_spec` has helpers for walking functionalities by func-id (`get_frids`, +`get_specifications_for_frid`, `collect_linked_resources`). ## Development diff --git a/src/plain_parser/cli.py b/src/plain_parser/cli.py index 990c2a1..eac0b26 100644 --- a/src/plain_parser/cli.py +++ b/src/plain_parser/cli.py @@ -1,4 +1,5 @@ -"""Command-line entry point: ``plain-parser {check,parse} [--template-dir DIR] [--config-name NAME]``.""" +"""Command-line entry point: ``plain-parser {check,parse} [--template-dir DIR] [--test-dir DIR]... +[--config-name NAME]``.""" import argparse import json @@ -15,7 +16,7 @@ TEST_DIR_CONFIG_KEYS = ("test_dir", "test-dir") CONFORMANCE_TESTS_SCRIPT_CONFIG_KEYS = ("conformance_tests_script", "conformance-tests-script") DEFAULT_TEST_DIR = "tests" -PARSE_FORMAT_VERSION = 1 +PARSE_FORMAT_VERSION = 2 class AmbiguousConfigFileError(Exception): @@ -28,13 +29,13 @@ def build_parser() -> argparse.ArgumentParser: check_parser = subparsers.add_parser( "check", - help="Validate a .plain module and its requires chain, and read every linked file: " + help="Validate a .plain module, its requires chain and its test plain files, and read every linked file: " "binary files and large base64 blobs are rejected.", ) parse_parser = subparsers.add_parser( "parse", - help="Print the parse result of a .plain module and its requires chain as JSON. Linked files are " - "located but never read.", + help="Print the parse result of a .plain module, its requires chain and its test plain files as JSON. " + "Linked files are located but never read.", ) for subparser in (check_parser, parse_parser): subparser.add_argument("plain_file", help="Path to the .plain module.") @@ -43,11 +44,18 @@ def build_parser() -> argparse.ArgumentParser: help="Directory searched for modules and templates after the .plain file's own directory. " "Overrides the template-dir value of the config file.", ) + subparser.add_argument( + "--test-dir", + action="append", + metavar="DIR", + help="Directory scanned for test plain files; repeat the option for more than one. Overrides the " + f"test-dir value of the config file. Defaults to {DEFAULT_TEST_DIR} next to the .plain file.", + ) subparser.add_argument( "--config-name", default=DEFAULT_CONFIG_NAME, - help="Name of the config file to read template-dir from. Looked up in the .plain file's directory " - "and the current working directory. Defaults to %(default)s.", + help="Name of the config file to read template-dir, test-dir and conformance-tests-script from. " + "Looked up in the .plain file's directory and the current working directory. Defaults to %(default)s.", ) return parser @@ -209,6 +217,7 @@ def parse( "format_version": PARSE_FORMAT_VERSION, "modules": [{"module": module_name, "tree": tree} for module_name, tree in result.modules], "resources": result.resources, + "test_plain_files": result.test_plain_files, } exit_code = 0 except Exception as e: @@ -265,9 +274,10 @@ def main(argv: list[str] | None = None) -> int: # The config file is resolved even when --template-dir is given, so an ambiguous config is always an error. config = _read_config(parser, args) template_dir = args.template_dir or config.template_dir + test_dirs = args.test_dir or config.test_dirs if args.command == "parse": - return parse(args.plain_file, template_dir, config.test_dirs, config.conformance_tests_enabled) - check(args.plain_file, template_dir, config.test_dirs, config.conformance_tests_enabled) + return parse(args.plain_file, template_dir, test_dirs, config.conformance_tests_enabled) + check(args.plain_file, template_dir, test_dirs, config.conformance_tests_enabled) except KeyboardInterrupt: return 130 except Exception as e: diff --git a/tests/test_cli.py b/tests/test_cli.py index 228f354..c1e234e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -437,6 +437,29 @@ def test_check_link_in_scenario_is_read(project_dir, tmp_path, capsys): assert err.startswith("Error: Referenced resource 'listing.bin' in module 'todo' is a binary file.") +def test_check_test_dir_option_overrides_the_config_and_the_default(project_dir, tmp_path, capsys, monkeypatch): + copy = _copy_project(project_dir, tmp_path) + (copy / "tests").rename(copy / "scenarios") + (copy / "config.yaml").write_text("conformance-tests-script: run.sh\ntest-dir: does_not_exist\n") + monkeypatch.chdir(tmp_path) # the option is relative to the working directory + + assert "todo:add-task has no conformance scenario" in _run_check(capsys, "project/todo.plain")[2] + _assert_ok(_run_check(capsys, "project/todo.plain", "--test-dir", "project/scenarios")) + + +def test_check_test_dir_option_can_be_repeated(project_dir, tmp_path, capsys): + copy = _copy_project(project_dir, tmp_path) + (copy / "more").mkdir() + (copy / "tests" / "todo" / "list-tasks.plain").rename(copy / "more" / "list-tasks.plain") + + assert "todo:list-tasks has no conformance scenario" in _run_check(capsys, str(copy / "todo.plain"))[2] + _assert_ok( + _run_check( + capsys, str(copy / "todo.plain"), "--test-dir", str(copy / "tests"), "--test-dir", str(copy / "more") + ) + ) + + def test_check_reports_ancestor_resource_before_frid_walk_error(cli_specs_dir, capsys): # base.plain links a binary file; top.plain also has a code-variable conflict only the FRID walk finds. spec = os.path.join(cli_specs_dir, "resource_before_frid_walk", "top.plain") @@ -473,9 +496,10 @@ def test_parse_valid_module_prints_success_json(cli_specs_dir, capsys): assert (exit_code, err) == (0, "") result = _parse_output(out) - assert set(result) == {"format_version", "modules", "resources"} - assert result["format_version"] == 1 + assert set(result) == {"format_version", "modules", "resources", "test_plain_files"} + assert result["format_version"] == 2 assert result["resources"] == {} + assert result["test_plain_files"] == {} _, tree = plain_file.parse_module_chain("valid.plain", [cli_specs_dir])[-1] assert result["modules"] == [{"module": "valid", "tree": json.loads(json.dumps(tree))}] @@ -488,7 +512,7 @@ def test_parse_invalid_module_prints_error_json(cli_specs_dir, capsys): assert (exit_code, err) == (1, "") result = _parse_output(out) assert set(result) == {"format_version", "type", "message"} - assert result["format_version"] == 1 + assert result["format_version"] == 2 assert result["type"] == "PlainSyntaxError" _, _, check_err = _run_check(capsys, spec) assert check_err == f"Error: {result['message']}\n" @@ -572,6 +596,54 @@ def test_parse_lists_a_target_linked_from_two_modules_once(cli_specs_dir, capsys assert result["resources"] == {"notes.md": "notes.md"} +def test_parse_lists_the_test_plain_files_and_merges_their_scenarios(project_dir, capsys, monkeypatch): + monkeypatch.chdir(project_dir) + + exit_code, out, err = _run_parse(capsys, "todo.plain") + + assert (exit_code, err) == (0, "") + result = _parse_output(out) + assert result["test_plain_files"] == { + "tests/todo/add-task.plain": {"module": "todo", "func_id": "add-task"}, + "tests/todo/list-tasks.plain": {"module": "todo", "func_id": "list-tasks"}, + } + functionalities = {item["id"]: item for item in result["modules"][0]["tree"]["functional specs"]} + assert [scenario["id"] for scenario in functionalities["add-task"]["conformance_tests"]] == [ + "add-first-task", + "add-without-name", + ] + assert [scenario["id"] for scenario in functionalities["add-task"]["acceptance_tests"]] == ["add-then-list"] + assert functionalities["list-tasks"]["conformance_tests"][0]["text"] == ( + "With two :Task: items added, `todo list` prints two lines, lowest id first, and exits with status 0." + ) + + +def test_parse_resources_include_the_links_of_the_scenarios(project_dir, tmp_path, capsys): + copy = _copy_project(project_dir, tmp_path) + (copy / "tests" / "todo" / "list-tasks.plain").write_text( + "---\nmodule: todo\nfunc_id: list-tasks\n---\n\n***conformance tests***\n\n# lists\n" + "The output is [the listing](listing.txt).\n\n***acceptance tests***\n" + ) + (copy / "listing.txt").write_text("1 Buy milk\n") + + exit_code, out, _ = _run_parse(capsys, str(copy / "todo.plain")) + + assert exit_code == 0 + assert _parse_output(out)["resources"] == {"listing.txt": "listing.txt"} + + +def test_parse_missing_conformance_scenario_is_a_parser_error(project_dir, tmp_path, capsys): + copy = _copy_project(project_dir, tmp_path) + (copy / "tests" / "todo" / "list-tasks.plain").unlink() + + exit_code, out, _ = _run_parse(capsys, str(copy / "todo.plain")) + + assert exit_code == 1 + result = _parse_output(out) + assert result["type"] == "MissingConformanceScenariosError" + assert "add-test-scenarios" in result["message"] + + def test_parse_without_file_argument_is_usage_error(capsys): with pytest.raises(SystemExit) as exc_info: cli.main(["parse"]) From 793872a979e05e5bb0be11c768e30ea98c1d2113 Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Fri, 23 Oct 2026 15:08:17 +0200 Subject: [PATCH 07/15] feat: accept free text as the body of a functionality Every block after a `# ` heading, up to the next heading or section marker, is the body: prose, lists at any indentation, code blocks, tables. The blank lines between the blocks are kept. A list before the first heading is content before the first heading, like any other block there. --- src/plain_parser/plain_file.py | 36 ++++++++++++++++------------------ tests/test_plainfileparser.py | 36 ++++++++++++++++++++++++---------- 2 files changed, 43 insertions(+), 29 deletions(-) diff --git a/src/plain_parser/plain_file.py b/src/plain_parser/plain_file.py index 8fdc686..5fb4191 100644 --- a/src/plain_parser/plain_file.py +++ b/src/plain_parser/plain_file.py @@ -10,7 +10,7 @@ import mistletoe.block_token from liquid2 import DictLoader, Environment from mistletoe.block_token import Heading, List, Paragraph, Quote -from mistletoe.markdown_renderer import Fragment, MarkdownRenderer +from mistletoe.markdown_renderer import BlankLine, Fragment, MarkdownRenderer from mistletoe.span_token import Emphasis, Link, RawText, SpanToken, Strong from mistletoe.token import Token from mistletoe.utils import traverse @@ -428,13 +428,11 @@ def section_marker(token: Paragraph) -> str | None: return cast(RawText, strong_children[0]).content -def read_sections( # noqa: C901 - plain_source: dict, document: mistletoe.Document, source_lines: list[str], module_name: str | None -) -> None: +def read_sections(plain_source: dict, document: mistletoe.Document, module_name: str | None) -> None: # noqa: C901 """Add the sections of the rendered core spec to ``plain_source``. A list section holds a mistletoe Document whose children are its list items; ``functional specs`` holds a list - of Functionality items, one per `# ` heading, each with the block tokens up to the next heading or + of Functionality items, one per `# ` heading, each with every block token up to the next heading or section marker. Raise on the first structural error. """ current_specification_heading = None @@ -443,13 +441,18 @@ def read_sections( # noqa: C901 functionality_lines: dict[str, int] = {} # func-id to the line of its heading def close_functionality(): - if current_functionality is not None and not current_functionality.body: + if current_functionality is not None and all(isinstance(t, BlankLine) for t in current_functionality.body): raise PlainSyntaxError( f"Plain syntax error: {syntax_error_at(current_functionality.line_number, module_name)}: " f"Functionality `{current_functionality.id}` has no body." ) for token in document.children: + if isinstance(token, BlankLine): + # Kept inside a functionality body, so its paragraphs stay apart; meaningless anywhere else. + if current_functionality is not None: + current_functionality.body.append(token) + continue token_text = "".join(get_raw_text(token)).strip() where = syntax_error_at(token.line_number, module_name) in_functional_specs = current_specification_heading == plain_spec.FUNCTIONAL_REQUIREMENTS @@ -517,19 +520,12 @@ def close_functionality(): plain_source[specification_heading] = mistletoe.Document("") processed_specification_headings.add(specification_heading) - elif isinstance(token, List): + elif isinstance(token, List) and not in_functional_specs: if current_specification_heading is None: raise PlainSyntaxError(f"Plain syntax error: {where}: Missing specification heading (`{token_text}`)") - if not in_functional_specs: - plain_source[current_specification_heading].children.extend(token.children) - continue - - if not source_lines[token.line_number - 1][:1].isspace(): - raise PlainSyntaxError( - f"Plain syntax error: {where}: A flush-left `- ` list in the {plain_spec.FUNCTIONAL_REQUIREMENTS} " - f"section is the old syntax; a functionality is a `# ` heading followed by its body. " - f"{MIGRATION_HINT}" - ) + plain_source[current_specification_heading].children.extend(token.children) + elif in_functional_specs: + # The body of a functionality is free text: a list at any indentation, a code block, or any other block. if current_functionality is None: raise PlainSyntaxError( f"Plain syntax error: {where}: Content before the first `# ` heading in the " @@ -576,11 +572,13 @@ def parse_plain_source( # noqa: C901 check_no_test_sections(plain_source_full_text, module_name) - plain_file = mistletoe.Document(io.StringIO(plain_source_full_text)) + # Parsed inside a renderer context, so blank lines become BlankLine tokens and survive in functionality bodies. + with PlainRenderer(): + plain_file = mistletoe.Document(io.StringIO(plain_source_full_text)) remove_quotes(plain_file) - read_sections(plain_source, plain_file, plain_source_full_text.split("\n"), module_name) + read_sections(plain_source, plain_file, module_name) defined_concepts = set[str]() if plain_source[plain_spec.DEFINITIONS] is not None: diff --git a/tests/test_plainfileparser.py b/tests/test_plainfileparser.py index 2ce053c..bac86a9 100644 --- a/tests/test_plainfileparser.py +++ b/tests/test_plainfileparser.py @@ -574,24 +574,25 @@ def test_functionality_body_may_start_with_a_list(): assert _parse(plain_source)["functional specs"][0]["markdown"] == " - A :Task: can be added." -def test_flush_left_list_in_functional_specs_is_the_old_syntax(get_test_data_path): +def test_bullets_without_a_heading_are_the_previous_syntax(get_test_data_path): with pytest.raises(PlainSyntaxError) as exc_info: plain_file.plain_file_parser("old_syntax_functional_specs.plain", [get_test_data_path("data/plainfileparser")]) assert str(exc_info.value) == ( - "Plain syntax error: Syntax error at line 11 in module 'old_syntax_functional_specs': A flush-left `- ` list " - "in the functional specs section is the old syntax; a functionality is a `# ` heading followed by " - "its body. Run the migrate-plain-syntax skill to convert the project." + "Plain syntax error: Syntax error at line 11 in module 'old_syntax_functional_specs': Content before the " + 'first `# ` heading in the functional specs section (`Display "hello, world"`).' ) -def test_flush_left_list_after_a_heading_is_the_old_syntax(): +def test_flush_left_list_after_a_heading_is_body(): plain_source = ( _SPEC_HEAD + "***functional specs***\n\n# add-task\nA :Task: can be added.\n\n- A :Task: has a name.\n" + " - Deeper, four spaces in.\n" ) - with pytest.raises(PlainSyntaxError, match=re.escape("Syntax error at line 14: A flush-left `- ` list")): - plain_file.parse_plain_source(plain_source, {}, [], [], []) + assert _parse(plain_source)["functional specs"][0]["markdown"] == ( + "A :Task: can be added.\n\n- A :Task: has a name.\n - Deeper, four spaces in." + ) @pytest.mark.parametrize("heading", ["acceptance tests", "conformance tests"]) @@ -696,10 +697,25 @@ def test_functionality_without_body_is_rejected(tail): plain_file.parse_plain_source(plain_source, {}, [], [], []) -def test_code_block_in_functionality_body_is_rejected(): - plain_source = _SPEC_HEAD + "***functional specs***\n\n# add-task\nA :Task: can be added.\n\n```\ntodo add\n```\n" +def test_any_block_after_a_heading_is_body(): + plain_source = _SPEC_HEAD + ( + "***functional specs***\n\n# add-task\nA :Task: can be added.\n\n```\ntodo add\n```\n\n" + "| id | name |\n|---|---|\n| 1 | Buy milk |\n\n# list-tasks\nEvery :Task: is listed.\n" + ) + + functionalities = _parse(plain_source)["functional specs"] + + # The table is re-rendered with padded cells; the blank lines between the blocks are kept. + assert functionalities[0]["markdown"] == ( + "A :Task: can be added.\n\n```\ntodo add\n```\n\n| id | name |\n| --- | -------- |\n| 1 | Buy milk |" + ) + assert functionalities[1]["markdown"] == "Every :Task: is listed." + + +def test_block_in_a_list_section_is_still_rejected(): + plain_source = "***implementation reqs***\n\n- A req.\n\n```\ncode\n```\n" - with pytest.raises(PlainSyntaxError, match=re.escape("Syntax error at line 14: Invalid source structure")): + with pytest.raises(PlainSyntaxError, match=re.escape("Syntax error at line 5: Invalid source structure")): plain_file.parse_plain_source(plain_source, {}, [], [], []) From a9dba932ab52683f60af9115434ec37e35a09b91 Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Fri, 23 Oct 2026 15:11:03 +0200 Subject: [PATCH 08/15] feat: report the previous syntax as not supported The messages for content before the first func-id heading, for a functional specs section without a heading, and for a conformance or acceptance tests section in a spec plain file say the previous syntax is not supported and name no skill. Only a missing conformance scenario names add-test-scenarios. "Core spec" is now "spec plain file". --- README.md | 6 +++--- src/plain_parser/plain_file.py | 18 ++++++++++-------- src/plain_parser/project.py | 3 +-- src/plain_parser/test_plain_file.py | 7 +++---- tests/test_plainfileparser.py | 17 +++++++++++------ tests/test_project.py | 2 +- tests/test_test_plain_file.py | 5 ++--- 7 files changed, 31 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 6fa0c08..e727cd0 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,9 @@ my_spec.plain: OK ``` Exit `0` when the module is valid. On an error, `Error: …` is written to stderr and the exit code is `1`. The -message names the file, the line where known, and the skill that fixes the problem: the old syntax (a flush-left -`- ` list in `***functional specs***`, an `***acceptance tests***` section in a core spec) names -`migrate-plain-syntax`, and a functionality without a conformance scenario names `add-test-scenarios`. +message names the file and the line where known. The previous syntax (`- ` bullets instead of `# ` +headings in `***functional specs***`, an `***acceptance tests***` section in a spec plain file) is reported as not +supported. A functionality without a conformance scenario names the `add-test-scenarios` skill. ### `parse` diff --git a/src/plain_parser/plain_file.py b/src/plain_parser/plain_file.py index 5fb4191..0821396 100644 --- a/src/plain_parser/plain_file.py +++ b/src/plain_parser/plain_file.py @@ -30,7 +30,9 @@ IMPORT_DIRECTIVE = "import" REQUIRES_DIRECTIVE = "requires" -MIGRATION_HINT = "Run the migrate-plain-syntax skill to convert the project." +PREVIOUS_SYNTAX_HINT = ( + "A functionality is a `# ` heading followed by its body; the previous syntax is not supported." +) REQUIRED_CONCEPTS_DIRECTIVE = "required_concepts" EXPORTED_CONCEPTS_DIRECTIVE = "exported_concepts" @@ -406,13 +408,13 @@ def read_plain_source_metadata(plain_source_text): def check_no_test_sections(plain_source_full_text: str, module_name: str | None) -> None: - """Raise when a `***conformance tests***` or `***acceptance tests***` marker is anywhere in a core spec.""" + """Raise when a `***conformance tests***` or `***acceptance tests***` marker is anywhere in a spec plain file.""" for line_number, line in enumerate(plain_source_full_text.split("\n"), start=1): for heading in TEST_PLAIN_FILE_SECTION_HEADINGS: if line.strip() == f"***{heading}***": raise PlainSyntaxError( f"Plain syntax error: {syntax_error_at(line_number, module_name)}: The `{heading}` section is not " - f"allowed in a core spec; test scenarios live in test plain files. {MIGRATION_HINT}" + f"allowed in a spec plain file; test scenarios live in test plain files." ) @@ -429,7 +431,7 @@ def section_marker(token: Paragraph) -> str | None: def read_sections(plain_source: dict, document: mistletoe.Document, module_name: str | None) -> None: # noqa: C901 - """Add the sections of the rendered core spec to ``plain_source``. + """Add the sections of the rendered spec plain file to ``plain_source``. A list section holds a mistletoe Document whose children are its list items; ``functional specs`` holds a list of Functionality items, one per `# ` heading, each with every block token up to the next heading or @@ -489,7 +491,7 @@ def close_functionality(): if current_functionality is None: raise PlainSyntaxError( f"Plain syntax error: {where}: Content before the first `# ` heading in the " - f"{plain_spec.FUNCTIONAL_REQUIREMENTS} section (`{token_text}`)." + f"{plain_spec.FUNCTIONAL_REQUIREMENTS} section (`{token_text}`). {PREVIOUS_SYNTAX_HINT}" ) current_functionality.body.append(token) continue @@ -529,7 +531,7 @@ def close_functionality(): if current_functionality is None: raise PlainSyntaxError( f"Plain syntax error: {where}: Content before the first `# ` heading in the " - f"{plain_spec.FUNCTIONAL_REQUIREMENTS} section (`{token_text}`)." + f"{plain_spec.FUNCTIONAL_REQUIREMENTS} section (`{token_text}`). {PREVIOUS_SYNTAX_HINT}" ) current_functionality.body.append(token) else: @@ -806,8 +808,8 @@ def validate_module_structure(plain_file_parse_result: PlainFileParseResult, mod if count_functionalities(plain_file_parse_result.plain_source) == 0: # ***functional specs*** section present but empty: invalid in every role. Syntax error. raise PlainSyntaxError( - f"Plain syntax error: Module '{module_name}' has an empty " - f"'{plain_spec.FUNCTIONAL_REQUIREMENTS}' section. At least one functionality must be specified." + f"Plain syntax error: Module '{module_name}' has a '{plain_spec.FUNCTIONAL_REQUIREMENTS}' section " + f"without a `# ` heading. {PREVIOUS_SYNTAX_HINT}" ) validate_functionalities_have_implementation_reqs( diff --git a/src/plain_parser/project.py b/src/plain_parser/project.py index 6bb4338..33e67b2 100644 --- a/src/plain_parser/project.py +++ b/src/plain_parser/project.py @@ -96,8 +96,7 @@ def bind_test_plain_files( if functionality is None: raise PlainSyntaxError( f"Plain syntax error: Test plain file '{name}' binds to functionality " - f"'{parsed.module}:{parsed.func_id}', which module '{parsed.module}' does not have. " - f"{test_plain_file.TEST_SCENARIOS_HINT}" + f"'{parsed.module}:{parsed.func_id}', which module '{parsed.module}' does not have." ) concepts, _ = concept_utils.collect_concepts(tree) diff --git a/src/plain_parser/test_plain_file.py b/src/plain_parser/test_plain_file.py index df334eb..e0e41a1 100644 --- a/src/plain_parser/test_plain_file.py +++ b/src/plain_parser/test_plain_file.py @@ -18,7 +18,6 @@ FUNC_ID_KEY = "func_id" FRONTMATTER_KEYS = (MODULE_KEY, FUNC_ID_KEY) SECTION_HEADINGS = (plain_spec.CONFORMANCE_TEST_HEADING, plain_spec.ACCEPTANCE_TEST_HEADING) -TEST_SCENARIOS_HINT = "Run the add-test-scenarios skill to update the file." @dataclass @@ -72,7 +71,7 @@ def read_metadata(plain_source_text: str, name: str) -> frontmatter.Post: for key in FRONTMATTER_KEYS: if key not in post.metadata: - raise _error(f"Test plain file '{name}' has no `{key}:` key in its frontmatter. {TEST_SCENARIOS_HINT}") + raise _error(f"Test plain file '{name}' has no `{key}:` key in its frontmatter.") value = post.metadata[key] if not isinstance(value, str) or not value.strip(): raise _error(f"Test plain file '{name}': the frontmatter key `{key}:` must be a non-empty string.") @@ -169,7 +168,7 @@ def close_scenario() -> None: for heading in SECTION_HEADINGS: if heading not in sections: - raise _error(f"Test plain file '{name}' has no `***{heading}***` section. {TEST_SCENARIOS_HINT}") + raise _error(f"Test plain file '{name}' has no `***{heading}***` section.") return sections @@ -183,7 +182,7 @@ def find_scenario_linked_resources(scenario: Scenario, template_dirs: list[str], def parse_test_plain_file_source( plain_source_text: str, name: str, template_dirs: list[str], code_variables: dict ) -> TestPlainFile: - """Parse the text of a test plain file. Liquid runs as on a core spec, with the same template directories; + """Parse the text of a test plain file. Liquid runs as on a spec plain file, with the same template directories; every link in a scenario is resolved against them. Concepts are not checked here, see validate_scenario_concepts.""" plain_source_text = plain_file.normalize_line_endings(plain_source_text) diff --git a/tests/test_plainfileparser.py b/tests/test_plainfileparser.py index bac86a9..9549506 100644 --- a/tests/test_plainfileparser.py +++ b/tests/test_plainfileparser.py @@ -329,7 +329,10 @@ def test_no_functional_specs_section(get_test_data_path): def test_empty_functional_specs_section(get_test_data_path): with pytest.raises( PlainSyntaxError, - match=re.escape("has an empty 'functional specs' section"), + match=re.escape( + "has a 'functional specs' section without a `# ` heading. A functionality is a `# ` " + "heading followed by its body; the previous syntax is not supported." + ), ): plain_file.plain_file_parser( "empty_functional_specs_section.plain", @@ -580,7 +583,8 @@ def test_bullets_without_a_heading_are_the_previous_syntax(get_test_data_path): assert str(exc_info.value) == ( "Plain syntax error: Syntax error at line 11 in module 'old_syntax_functional_specs': Content before the " - 'first `# ` heading in the functional specs section (`Display "hello, world"`).' + 'first `# ` heading in the functional specs section (`Display "hello, world"`). A functionality is ' + "a `# ` heading followed by its body; the previous syntax is not supported." ) @@ -596,15 +600,15 @@ def test_flush_left_list_after_a_heading_is_body(): @pytest.mark.parametrize("heading", ["acceptance tests", "conformance tests"]) -def test_test_section_in_core_spec_is_rejected(heading): +def test_test_section_in_spec_plain_file_is_rejected(heading): plain_source = f"***definitions***\n\n- :Task: is a thing to do.\n\n***{heading}***\n\n# add\nAdding works.\n" with pytest.raises(PlainSyntaxError) as exc_info: plain_file.parse_plain_source(plain_source, {}, [], [], []) assert str(exc_info.value) == ( - f"Plain syntax error: Syntax error at line 5: The `{heading}` section is not allowed in a core spec; " - "test scenarios live in test plain files. Run the migrate-plain-syntax skill to convert the project." + f"Plain syntax error: Syntax error at line 5: The `{heading}` section is not allowed in a spec plain file; " + "test scenarios live in test plain files." ) @@ -678,7 +682,8 @@ def test_content_before_the_first_heading_is_rejected(content): PlainSyntaxError, match=re.escape( "Syntax error at line 11: Content before the first `# ` heading in the functional specs section " - "(`A :Task: can be added.`)." + "(`A :Task: can be added.`). A functionality is a `# ` heading followed by its body; the " + "previous syntax is not supported." ), ): plain_file.parse_plain_source(plain_source, {}, [], [], []) diff --git a/tests/test_project.py b/tests/test_project.py index 4f30dd9..42ddc0d 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -149,7 +149,7 @@ def test_unknown_func_id_is_rejected(tmp_path): assert str(exc_info.value) == ( "Plain syntax error: Test plain file 'tests/a.plain' binds to functionality 'todo:delete-task', which " - "module 'todo' does not have. Run the add-test-scenarios skill to update the file." + "module 'todo' does not have." ) diff --git a/tests/test_test_plain_file.py b/tests/test_test_plain_file.py index ab9c98e..a6654e0 100644 --- a/tests/test_test_plain_file.py +++ b/tests/test_test_plain_file.py @@ -69,7 +69,7 @@ def test_both_sections_may_be_empty(fixtures_dir): ( "missing_acceptance_section.plain", "Plain syntax error: Test plain file 'missing_acceptance_section.plain' has no `***acceptance tests***` " - "section. Run the add-test-scenarios skill to update the file.", + "section.", ), ( "sections_out_of_order.plain", @@ -94,8 +94,7 @@ def test_both_sections_may_be_empty(fixtures_dir): ), ( "missing_func_id.plain", - "Plain syntax error: Test plain file 'missing_func_id.plain' has no `func_id:` key in its frontmatter. " - "Run the add-test-scenarios skill to update the file.", + "Plain syntax error: Test plain file 'missing_func_id.plain' has no `func_id:` key in its frontmatter.", ), ( "module_not_a_string.plain", From 8934edf7ef768e6db819030bb3a4e59fbaaf71aa Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Tue, 27 Oct 2026 11:04:09 +0100 Subject: [PATCH 09/15] fix: require single hyphens between segments of func-ids and scenario-ids An id is segments of lowercase letters and digits with one hyphen between them, so an id never starts or ends with a hyphen, never has two hyphens in a row, and is never only hyphens. --- src/plain_parser/plain_file.py | 4 ++-- src/plain_parser/plain_spec.py | 5 +++-- src/plain_parser/test_plain_file.py | 4 ++-- tests/test_plainfileparser.py | 7 +++++-- tests/test_plainspec.py | 13 +++++++++++++ tests/test_test_plain_file.py | 11 ++++++++++- 6 files changed, 35 insertions(+), 9 deletions(-) diff --git a/src/plain_parser/plain_file.py b/src/plain_parser/plain_file.py index 0821396..9bc1881 100644 --- a/src/plain_parser/plain_file.py +++ b/src/plain_parser/plain_file.py @@ -471,8 +471,8 @@ def close_functionality(): ) if not plain_spec.is_semantic_id(token_text): raise PlainSyntaxError( - f"Plain syntax error: {where}: Invalid func-id (`{token_text}`). A func-id has only lowercase " - f"letters, digits and hyphens." + f"Plain syntax error: {where}: Invalid func-id (`{token_text}`). A func-id is lowercase letters " + f"and digits, with single hyphens between them." ) if token_text in functionality_lines: raise PlainSyntaxError( diff --git a/src/plain_parser/plain_spec.py b/src/plain_parser/plain_spec.py index bccaffc..a1eb4e8 100644 --- a/src/plain_parser/plain_spec.py +++ b/src/plain_parser/plain_spec.py @@ -23,8 +23,9 @@ ALLOWED_IMPORT_SPECIFICATION_HEADINGS = LIST_SECTION_HEADINGS -# A func-id or scenario-id: lowercase letters, digits and hyphens. -SEMANTIC_ID_PATTERN = re.compile(r"[a-z0-9-]+") +# A func-id or scenario-id: segments of lowercase letters and digits, with one hyphen between segments. So a +# hyphen never starts or ends an id, two hyphens never touch, and an id is never only hyphens. +SEMANTIC_ID_PATTERN = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*") def is_semantic_id(text: str) -> bool: diff --git a/src/plain_parser/test_plain_file.py b/src/plain_parser/test_plain_file.py index e0e41a1..bc6a18e 100644 --- a/src/plain_parser/test_plain_file.py +++ b/src/plain_parser/test_plain_file.py @@ -136,8 +136,8 @@ def close_scenario() -> None: ) if not plain_spec.is_semantic_id(scenario_id): raise _error( - f"{where}: Invalid scenario-id (`{scenario_id}`). A scenario-id has only lowercase letters, " - f"digits and hyphens." + f"{where}: Invalid scenario-id (`{scenario_id}`). A scenario-id is lowercase letters and digits, " + f"with single hyphens between them." ) if scenario_id in heading_lines: raise _error( diff --git a/tests/test_plainfileparser.py b/tests/test_plainfileparser.py index 9549506..21bf84e 100644 --- a/tests/test_plainfileparser.py +++ b/tests/test_plainfileparser.py @@ -652,11 +652,14 @@ def test_func_id_with_other_characters_is_rejected(get_test_data_path): assert str(exc_info.value) == ( "Plain syntax error: Syntax error at line 10 in module 'func_id_invalid': Invalid func-id (`Add Task 2`). " - "A func-id has only lowercase letters, digits and hyphens." + "A func-id is lowercase letters and digits, with single hyphens between them." ) -@pytest.mark.parametrize("heading", ["#", "# ", "# add_task", "# add-task!", "# Ädd"]) +@pytest.mark.parametrize( + "heading", + ["#", "# ", "# add_task", "# add-task!", "# Ädd", "# -add-task", "# add-task-", "# add--task", "# -", "# ---"], +) def test_func_id_syntax_is_checked(heading): plain_source = _SPEC_HEAD + f"***functional specs***\n\n{heading}\nA :Task: can be added.\n" diff --git a/tests/test_plainspec.py b/tests/test_plainspec.py index 57bfb82..233e96e 100644 --- a/tests/test_plainspec.py +++ b/tests/test_plainspec.py @@ -217,3 +217,16 @@ def test_get_frids_range_unknown_end_raises(): def test_get_frids_range_start_after_end_raises(): with pytest.raises(plain_spec.InvalidFridArgument, match="must be before"): plain_spec.get_frids_range(_four_functionalities(), "f3", "f2") + + +@pytest.mark.parametrize("text", ["a", "add-task", "add-task-2", "2", "a-b-c", "x1-y2"]) +def test_is_semantic_id_accepts_segments_joined_by_single_hyphens(text): + assert plain_spec.is_semantic_id(text) is True + + +@pytest.mark.parametrize( + "text", + ["", "-", "---", "-add-task", "add-task-", "add--task", "add_task", "Add-task", "add task", "add.task", "ädd"], +) +def test_is_semantic_id_rejects_other_shapes(text): + assert plain_spec.is_semantic_id(text) is False diff --git a/tests/test_test_plain_file.py b/tests/test_test_plain_file.py index a6654e0..8069a0f 100644 --- a/tests/test_test_plain_file.py +++ b/tests/test_test_plain_file.py @@ -109,7 +109,8 @@ def test_both_sections_may_be_empty(fixtures_dir): ( "invalid_scenario_id.plain", "Plain syntax error: Syntax error at line 8 in test plain file 'invalid_scenario_id.plain': Invalid " - "scenario-id (`Works Well`). A scenario-id has only lowercase letters, digits and hyphens.", + "scenario-id (`Works Well`). A scenario-id is lowercase letters and digits, with single hyphens between " + "them.", ), ( "content_before_section.plain", @@ -155,6 +156,14 @@ def test_link_to_a_plain_file_is_rejected(fixtures_dir): _parse(fixtures_dir, "link_to_plain_file.plain") +@pytest.mark.parametrize("scenario_id", ["-works", "works-", "works--well", "-", "--", "works_well", "Works"]) +def test_scenario_id_syntax_is_checked(scenario_id): + text = f"---\nmodule: m\nfunc_id: f\n---\n\n***conformance tests***\n\n# {scenario_id}\nIt works.\n\n***acceptance tests***\n" + + with pytest.raises(PlainSyntaxError, match=re.escape(f"Invalid scenario-id (`{scenario_id}`)")): + test_plain_file.parse_test_plain_file_source(text, "f.plain", [], {}) + + def test_invalid_frontmatter_is_rejected(): with pytest.raises(PlainSyntaxError, match="Test plain file 'broken.plain' has invalid frontmatter"): test_plain_file.parse_test_plain_file_source("---\nmodule: [\n---\n", "broken.plain", [], {}) From 81906a084cbfc106aa52b28b9855583d04faf01c Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Tue, 27 Oct 2026 11:17:09 +0100 Subject: [PATCH 10/15] fix: keep fenced code blocks in scenario text verbatim Inside a fenced code block of a scenario, a line that starts with `# `, `***` or `>` is scenario text, not a heading, a section marker or a comment. A fence that is never closed is an error that names the scenario. --- src/plain_parser/test_plain_file.py | 21 +++++++++++ tests/test_test_plain_file.py | 58 +++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/src/plain_parser/test_plain_file.py b/src/plain_parser/test_plain_file.py index bc6a18e..3f66ed7 100644 --- a/src/plain_parser/test_plain_file.py +++ b/src/plain_parser/test_plain_file.py @@ -100,11 +100,16 @@ def read_scenarios(rendered_text: str, name: str) -> dict[str, list[Scenario]]: current_section: str | None = None current: Scenario | None = None body: list[str] = [] + fence: str | None = None # The marker of the open fenced code block (``` or ~~~), None outside one. heading_lines: dict[str, int] = {} # scenario-id to the line of its heading def close_scenario() -> None: if current is None: return + if fence is not None: + raise _error( + f"{_at(current.line_number, name)}: Scenario `{current.id}` has a code fence that is not closed." + ) lines = _trim_blank_lines(body) if not lines: raise _error(f"{_at(current.line_number, name)}: Scenario `{current.id}` has no text.") @@ -112,6 +117,22 @@ def close_scenario() -> None: for line_number, line in enumerate(rendered_text.split("\n"), start=1): where = _at(line_number, name) + # Inside a fenced code block every line is body text: a `# ` line is not a heading there, a `***` line is + # not a section marker, and a `>` line is not a comment. + stripped = line.lstrip() + if stripped.startswith(("```", "~~~")): + marker = stripped[:3] + if fence is None: + fence = marker + elif fence == marker: + fence = None + if current is not None: + body.append(line) + continue + elif fence is not None and current is not None: + body.append(line) + continue + if line.startswith("***"): heading = line.strip()[3:-3] if line.strip().endswith("***") and len(line.strip()) > 6 else None if heading not in SECTION_HEADINGS: diff --git a/tests/test_test_plain_file.py b/tests/test_test_plain_file.py index 8069a0f..53980aa 100644 --- a/tests/test_test_plain_file.py +++ b/tests/test_test_plain_file.py @@ -164,6 +164,64 @@ def test_scenario_id_syntax_is_checked(scenario_id): test_plain_file.parse_test_plain_file_source(text, "f.plain", [], {}) +_HEAD = "---\nmodule: todo\nfunc_id: add-task\n---\n\n***conformance tests***\n\n" +_TAIL = "\n***acceptance tests***\n" + + +@pytest.mark.parametrize( + "body", + [ + # A shell comment, and one that repeats a scenario-id. + '```bash\n# create an empty list, then add a task\n# add-first-task\ntodo add "Buy milk"\n```', + # A line that looks like a section marker. + "```\n***\ntodo 1.0\n***\n```", + # Lines that look like comments. + "```\n> todo add Buy milk\nAdded task 1: Buy milk\n> todo list\n```", + # A tilde fence, indented, with a backtick fence line inside it. + " ~~~\n # not a heading\n ```\n ~~~", + ], +) +def test_lines_inside_a_code_fence_are_scenario_text(body): + text = _HEAD + f"# add-first-task\nThe commands below add a task.\n\n{body}\nThe task is listed.\n" + _TAIL + + parsed = test_plain_file.parse_test_plain_file_source(text, "add-task.plain", [], {}) + + assert [scenario.id for scenario in parsed.conformance_tests] == ["add-first-task"] + assert parsed.conformance_tests[0].text == f"The commands below add a task.\n\n{body}\nThe task is listed." + + +def test_heading_after_a_closed_code_fence_starts_a_scenario(): + text = _HEAD + "# first\n```\n# not a heading\n```\n\n# second\nIt works.\n" + _TAIL + + parsed = test_plain_file.parse_test_plain_file_source(text, "add-task.plain", [], {}) + + assert [scenario.id for scenario in parsed.conformance_tests] == ["first", "second"] + + +def test_code_fence_that_is_not_closed_is_rejected(): + text = _HEAD + "# first\nIt works:\n```\ntodo add\n" + _TAIL + + with pytest.raises(PlainSyntaxError) as exc_info: + test_plain_file.parse_test_plain_file_source(text, "add-task.plain", [], {}) + + assert str(exc_info.value) == ( + "Plain syntax error: Syntax error at line 8 in test plain file 'add-task.plain': Scenario `first` has a code " + "fence that is not closed." + ) + + +def test_code_fence_before_the_first_scenario_is_content_before_a_heading(): + text = _HEAD + "```\n# not a heading\n```\n\n# first\nIt works.\n" + _TAIL + + with pytest.raises( + PlainSyntaxError, + match=re.escape( + "Syntax error at line 8 in test plain file 'add-task.plain': Content before the first `# ` heading" + ), + ): + test_plain_file.parse_test_plain_file_source(text, "add-task.plain", [], {}) + + def test_invalid_frontmatter_is_rejected(): with pytest.raises(PlainSyntaxError, match="Test plain file 'broken.plain' has invalid frontmatter"): test_plain_file.parse_test_plain_file_source("---\nmodule: [\n---\n", "broken.plain", [], {}) From f1ac0468675c45ab739498af149deaf8d3da0ba4 Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Tue, 27 Oct 2026 12:16:39 +0100 Subject: [PATCH 11/15] feat: require a test plain file per functionality instead of a conformance scenario With conformance-tests-script in the config, every functionality needs a test plain file bound to it; the sections of the file may be empty. The error names the functionality, the conventional path and the add-test-scenarios skill. --- README.md | 8 ++-- src/plain_parser/cli.py | 15 +++---- src/plain_parser/exceptions.py | 4 +- src/plain_parser/project.py | 28 +++++++------ .../comment_only_conformance.plain | 10 +++++ tests/data/testplainfile/minimal.plain | 6 +++ tests/test_cli.py | 29 ++++++++----- tests/test_project.py | 41 ++++++++++++++----- tests/test_test_plain_file.py | 6 ++- 9 files changed, 99 insertions(+), 48 deletions(-) create mode 100644 tests/data/testplainfile/comment_only_conformance.plain create mode 100644 tests/data/testplainfile/minimal.plain diff --git a/README.md b/README.md index e727cd0..26fd2bf 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ functionalities. They stop at the first error. Validates the module. In addition to the steps above it reads every linked file and rejects binary files and files holding a large base64 blob. When the config file names a `conformance-tests-script`, every functionality -of every module in the chain needs at least one conformance scenario. +of every module in the chain needs a test plain file bound to it; the sections of the file may be empty. ``` $ plain-parser check my_spec.plain @@ -37,7 +37,7 @@ my_spec.plain: OK Exit `0` when the module is valid. On an error, `Error: …` is written to stderr and the exit code is `1`. The message names the file and the line where known. The previous syntax (`- ` bullets instead of `# ` headings in `***functional specs***`, an `***acceptance tests***` section in a spec plain file) is reported as not -supported. A functionality without a conformance scenario names the `add-test-scenarios` skill. +supported. A functionality without a test plain file names the `add-test-scenarios` skill. ### `parse` @@ -80,7 +80,7 @@ Test plain files are the `.plain` files under the test directories: those given option for more than one), else the `test-dir` key of the config file (a string or a list), else `tests` next to the `.plain` file. A test directory that does not exist is skipped. Linked resources in a scenario are looked up as in a module. The `conformance-tests-script` key of the config file turns on the check that every -functionality has a conformance scenario. Other config keys are ignored. +functionality has a test plain file. Other config keys are ignored. | Exit code | Meaning | |---|---| @@ -96,7 +96,7 @@ from plain_parser import plain_file_parser, project module_name, plain_source_tree, required_modules = plain_file_parser("my_spec.plain", ["."]) -result = project.parse_project("my_spec.plain", ["."], ["tests"], require_conformance_scenarios=True) +result = project.parse_project("my_spec.plain", ["."], ["tests"], require_test_plain_files=True) chain = result.chain # (module name, plain source tree) pairs, deepest ancestor first, the module itself last ``` diff --git a/src/plain_parser/cli.py b/src/plain_parser/cli.py index eac0b26..b247960 100644 --- a/src/plain_parser/cli.py +++ b/src/plain_parser/cli.py @@ -132,12 +132,13 @@ def parse_spec( template_dir: str | None, load_resources: bool, test_dirs: list[str] | None = None, - require_conformance_scenarios: bool = False, + require_test_plain_files: bool = False, ) -> ParseResult: """Parse the module, its requires chain and its test plain files, resolve the links, and walk the entry's FRIDs; raise on the first error. - ``test_dirs`` defaults to ``tests`` next to the module. With ``load_resources``, each module's linked files are + ``test_dirs`` defaults to ``tests`` next to the module. With ``require_test_plain_files``, every functionality needs a + test plain file bound to it. With ``load_resources``, each module's linked files are also read and checked (text only, no large base64 blob) right after that module's links are collected, so a resource error is reported before a FRID-walk error. """ @@ -151,7 +152,7 @@ def parse_spec( test_dirs = [os.path.abspath(test_dir) for test_dir in test_dirs] parsed = project.parse_project( - os.path.basename(plain_file_path), template_dirs, test_dirs, require_conformance_scenarios + os.path.basename(plain_file_path), template_dirs, test_dirs, require_test_plain_files ) chain = parsed.chain @@ -183,10 +184,10 @@ def check( plain_file_path: str, template_dir: str | None, test_dirs: list[str] | None = None, - require_conformance_scenarios: bool = False, + require_test_plain_files: bool = False, ) -> None: """Raise if the module, its requires chain, its test plain files, or its linked resources are invalid.""" - parse_spec(plain_file_path, template_dir, True, test_dirs, require_conformance_scenarios) + parse_spec(plain_file_path, template_dir, True, test_dirs, require_test_plain_files) def _error_output(type_name: str, message: str) -> dict: @@ -207,12 +208,12 @@ def parse( plain_file_path: str, template_dir: str | None, test_dirs: list[str] | None = None, - require_conformance_scenarios: bool = False, + require_test_plain_files: bool = False, ) -> int: """Print the parse result, or the parser error, as one UTF-8 JSON document on stdout; return the exit code.""" output: dict try: - result = parse_spec(plain_file_path, template_dir, False, test_dirs, require_conformance_scenarios) + result = parse_spec(plain_file_path, template_dir, False, test_dirs, require_test_plain_files) output = { "format_version": PARSE_FORMAT_VERSION, "modules": [{"module": module_name, "tree": tree} for module_name, tree in result.modules], diff --git a/src/plain_parser/exceptions.py b/src/plain_parser/exceptions.py index 9ef40e7..e992884 100644 --- a/src/plain_parser/exceptions.py +++ b/src/plain_parser/exceptions.py @@ -39,7 +39,7 @@ class ImportedModuleWithFunctionalitiesError(Exception): pass -class MissingConformanceScenariosError(Exception): - """Raised when conformance tests are enabled and a functionality has no conformance scenario.""" +class MissingTestPlainFileError(Exception): + """Raised when conformance tests are enabled and a functionality has no test plain file bound to it.""" pass diff --git a/src/plain_parser/project.py b/src/plain_parser/project.py index 33e67b2..6e21cc6 100644 --- a/src/plain_parser/project.py +++ b/src/plain_parser/project.py @@ -6,7 +6,7 @@ import frontmatter from plain_parser import concept_utils, loaders, plain_file, plain_spec, test_plain_file -from plain_parser.exceptions import MissingConformanceScenariosError, PlainSyntaxError, UnsupportedBase64Content +from plain_parser.exceptions import MissingTestPlainFileError, PlainSyntaxError, UnsupportedBase64Content @dataclass @@ -126,28 +126,32 @@ def bind_test_plain_files( return bound -def check_conformance_scenarios(chain: list[tuple[str, dict]]) -> None: - """Raise for the first functionality of the chain, in render order, that has no conformance scenario.""" +def check_test_plain_files(chain: list[tuple[str, dict]], bound: list[BoundTestPlainFile]) -> None: + """Raise for the first functionality of the chain, in render order, that has no test plain file bound to it. + + The sections of the file may be empty; the file itself is what the check asks for. + """ + bound_functionalities = {(item.parsed.module, item.parsed.func_id) for item in bound} for module_name, tree in chain: for functionality in tree.get(plain_spec.FUNCTIONAL_REQUIREMENTS) or []: - if not functionality[plain_spec.CONFORMANCE_TESTS]: - func_id = functionality["id"] - raise MissingConformanceScenariosError( - f"Functionality {module_name}:{func_id} has no conformance scenario. Add one in a test plain file " - f"(by convention tests/{module_name}/{func_id}.plain) with the add-test-scenarios skill." + func_id = functionality["id"] + if (module_name, func_id) not in bound_functionalities: + raise MissingTestPlainFileError( + f"Functionality {module_name}:{func_id} has no test plain file. Add one, by convention " + f"tests/{module_name}/{func_id}.plain, with the add-test-scenarios skill." ) def parse_project( - plain_file_name: str, template_dirs: list[str], test_dirs: list[str], require_conformance_scenarios: bool + plain_file_name: str, template_dirs: list[str], test_dirs: list[str], require_test_plain_files: bool ) -> ProjectParseResult: """Parse a module with its requires chain and bind the test plain files under ``test_dirs`` to its functionalities. ``template_dirs[0]`` is the entry module's directory; test plain files are named relative to it. With - ``require_conformance_scenarios``, every functionality of every module in the chain needs a conformance scenario. + ``require_test_plain_files``, every functionality of every module in the chain needs a test plain file. """ chain = plain_file.parse_module_chain(plain_file_name, template_dirs) bound = bind_test_plain_files(chain, test_dirs, template_dirs, template_dirs[0]) - if require_conformance_scenarios: - check_conformance_scenarios(chain) + if require_test_plain_files: + check_test_plain_files(chain, bound) return ProjectParseResult(chain, bound) diff --git a/tests/data/testplainfile/comment_only_conformance.plain b/tests/data/testplainfile/comment_only_conformance.plain new file mode 100644 index 0000000..a531ffd --- /dev/null +++ b/tests/data/testplainfile/comment_only_conformance.plain @@ -0,0 +1,10 @@ +--- +module: task-manager +func_id: add-task +--- + +***conformance tests*** + +> add conformance test scenarios here + +***acceptance tests*** diff --git a/tests/data/testplainfile/minimal.plain b/tests/data/testplainfile/minimal.plain new file mode 100644 index 0000000..b5715aa --- /dev/null +++ b/tests/data/testplainfile/minimal.plain @@ -0,0 +1,6 @@ +--- +module: task-manager +func_id: add-task +--- +***conformance tests*** +***acceptance tests*** diff --git a/tests/test_cli.py b/tests/test_cli.py index c1e234e..dbd8a55 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -356,7 +356,7 @@ def test_check_project_with_test_plain_files_prints_ok(project_dir, capsys, monk assert _run_check(capsys, "todo.plain") == (0, "todo.plain: OK\n", "") -def test_check_missing_conformance_scenario_names_the_functionality_and_the_skill(project_dir, tmp_path, capsys): +def test_check_missing_test_plain_file_names_the_functionality_and_the_skill(project_dir, tmp_path, capsys): copy = _copy_project(project_dir, tmp_path) (copy / "tests" / "todo" / "list-tasks.plain").unlink() @@ -364,12 +364,21 @@ def test_check_missing_conformance_scenario_names_the_functionality_and_the_skil assert (exit_code, out) == (1, "") assert err == ( - "Error: Functionality todo:list-tasks has no conformance scenario. Add one in a test plain file " - "(by convention tests/todo/list-tasks.plain) with the add-test-scenarios skill.\n" + "Error: Functionality todo:list-tasks has no test plain file. Add one, by convention " + "tests/todo/list-tasks.plain, with the add-test-scenarios skill.\n" ) -def test_check_without_conformance_tests_script_needs_no_scenarios(project_dir, tmp_path, capsys): +def test_check_minimal_test_plain_file_satisfies_the_completeness_check(project_dir, tmp_path, capsys): + copy = _copy_project(project_dir, tmp_path) + (copy / "tests" / "todo" / "list-tasks.plain").write_text( + "---\nmodule: todo\nfunc_id: list-tasks\n---\n***conformance tests***\n***acceptance tests***\n" + ) + + _assert_ok(_run_check(capsys, str(copy / "todo.plain"))) + + +def test_check_without_conformance_tests_script_needs_no_test_plain_files(project_dir, tmp_path, capsys): copy = _copy_project(project_dir, tmp_path) shutil.rmtree(copy / "tests") (copy / "config.yaml").write_text("unittests-script: test_scripts/run_unittests.sh\n") @@ -410,7 +419,7 @@ def test_check_reads_test_dir_from_config(project_dir, tmp_path, capsys, monkeyp _assert_ok(_run_check(capsys, str(copy / "todo.plain"))) (copy / "scenarios" / "todo" / "add-task.plain").unlink() - assert "todo:add-task has no conformance scenario" in _run_check(capsys, str(copy / "todo.plain"))[2] + assert "todo:add-task has no test plain file" in _run_check(capsys, str(copy / "todo.plain"))[2] def test_check_config_test_dir_replaces_the_default(project_dir, tmp_path, capsys): @@ -420,7 +429,7 @@ def test_check_config_test_dir_replaces_the_default(project_dir, tmp_path, capsy exit_code, _, err = _run_check(capsys, str(copy / "todo.plain")) assert exit_code == 1 - assert "todo:add-task has no conformance scenario" in err + assert "todo:add-task has no test plain file" in err def test_check_link_in_scenario_is_read(project_dir, tmp_path, capsys): @@ -443,7 +452,7 @@ def test_check_test_dir_option_overrides_the_config_and_the_default(project_dir, (copy / "config.yaml").write_text("conformance-tests-script: run.sh\ntest-dir: does_not_exist\n") monkeypatch.chdir(tmp_path) # the option is relative to the working directory - assert "todo:add-task has no conformance scenario" in _run_check(capsys, "project/todo.plain")[2] + assert "todo:add-task has no test plain file" in _run_check(capsys, "project/todo.plain")[2] _assert_ok(_run_check(capsys, "project/todo.plain", "--test-dir", "project/scenarios")) @@ -452,7 +461,7 @@ def test_check_test_dir_option_can_be_repeated(project_dir, tmp_path, capsys): (copy / "more").mkdir() (copy / "tests" / "todo" / "list-tasks.plain").rename(copy / "more" / "list-tasks.plain") - assert "todo:list-tasks has no conformance scenario" in _run_check(capsys, str(copy / "todo.plain"))[2] + assert "todo:list-tasks has no test plain file" in _run_check(capsys, str(copy / "todo.plain"))[2] _assert_ok( _run_check( capsys, str(copy / "todo.plain"), "--test-dir", str(copy / "tests"), "--test-dir", str(copy / "more") @@ -632,7 +641,7 @@ def test_parse_resources_include_the_links_of_the_scenarios(project_dir, tmp_pat assert _parse_output(out)["resources"] == {"listing.txt": "listing.txt"} -def test_parse_missing_conformance_scenario_is_a_parser_error(project_dir, tmp_path, capsys): +def test_parse_missing_test_plain_file_is_a_parser_error(project_dir, tmp_path, capsys): copy = _copy_project(project_dir, tmp_path) (copy / "tests" / "todo" / "list-tasks.plain").unlink() @@ -640,7 +649,7 @@ def test_parse_missing_conformance_scenario_is_a_parser_error(project_dir, tmp_p assert exit_code == 1 result = _parse_output(out) - assert result["type"] == "MissingConformanceScenariosError" + assert result["type"] == "MissingTestPlainFileError" assert "add-test-scenarios" in result["message"] diff --git a/tests/test_project.py b/tests/test_project.py index 42ddc0d..87b70c0 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -1,7 +1,7 @@ import pytest from plain_parser import plain_spec, project -from plain_parser.exceptions import MissingConformanceScenariosError, PlainSyntaxError +from plain_parser.exceptions import MissingTestPlainFileError, PlainSyntaxError MODULE = ( "***definitions***\n\n- :Task: is a thing to do.\n\n***implementation reqs***\n\n- A req.\n\n" @@ -21,9 +21,9 @@ def scenario_file(module, func_id, conformance="", acceptance=""): ) -def parse(tmp_path, require_conformance_scenarios=False, test_dirs=None): +def parse(tmp_path, require_test_plain_files=False, test_dirs=None): test_dirs = [str(tmp_path / "tests")] if test_dirs is None else test_dirs - return project.parse_project("todo.plain", [str(tmp_path)], test_dirs, require_conformance_scenarios) + return project.parse_project("todo.plain", [str(tmp_path)], test_dirs, require_test_plain_files) def functionalities(result, module="todo"): @@ -242,21 +242,40 @@ def test_required_module_scenarios_do_not_see_the_top_modules_concepts(tmp_path) parse(tmp_path) -def test_every_functionality_needs_a_conformance_scenario_when_required(tmp_path): +def test_every_functionality_needs_a_test_plain_file_when_required(tmp_path): write(tmp_path / "todo.plain", MODULE) write(tmp_path / "tests" / "a.plain", scenario_file("todo", "add-task", "# works\nIt works.\n")) - write(tmp_path / "tests" / "b.plain", scenario_file("todo", "list-tasks", "", "# flows\nIt flows.\n")) parse(tmp_path) - with pytest.raises(MissingConformanceScenariosError) as exc_info: - parse(tmp_path, require_conformance_scenarios=True) + with pytest.raises(MissingTestPlainFileError) as exc_info: + parse(tmp_path, require_test_plain_files=True) assert str(exc_info.value) == ( - "Functionality todo:list-tasks has no conformance scenario. Add one in a test plain file (by convention " - "tests/todo/list-tasks.plain) with the add-test-scenarios skill." + "Functionality todo:list-tasks has no test plain file. Add one, by convention tests/todo/list-tasks.plain, " + "with the add-test-scenarios skill." ) +@pytest.mark.parametrize( + "content", + [ + # The minimal file: the frontmatter and the two markers on adjacent lines. + "---\nmodule: todo\nfunc_id: list-tasks\n---\n***conformance tests***\n***acceptance tests***\n", + # The conventional placeholder comment in an empty conformance section. + "---\nmodule: todo\nfunc_id: list-tasks\n---\n\n***conformance tests***\n\n" + "> add conformance test scenarios here\n\n***acceptance tests***\n", + ], +) +def test_a_test_plain_file_with_empty_sections_satisfies_the_check(tmp_path, content): + write(tmp_path / "todo.plain", MODULE) + write(tmp_path / "tests" / "a.plain", scenario_file("todo", "add-task", "# works\nIt works.\n")) + write(tmp_path / "tests" / "b.plain", content) + + result = parse(tmp_path, require_test_plain_files=True) + + assert functionalities(result)["list-tasks"][plain_spec.CONFORMANCE_TESTS] == [] + + def test_completeness_covers_the_required_modules_first(tmp_path): write( tmp_path / "core.plain", @@ -264,5 +283,5 @@ def test_completeness_covers_the_required_modules_first(tmp_path): ) write(tmp_path / "todo.plain", "---\nrequires:\n - core\n---\n\n" + MODULE) - with pytest.raises(MissingConformanceScenariosError, match="Functionality core:store-task has no conformance"): - parse(tmp_path, require_conformance_scenarios=True) + with pytest.raises(MissingTestPlainFileError, match="Functionality core:store-task has no test plain file"): + parse(tmp_path, require_test_plain_files=True) diff --git a/tests/test_test_plain_file.py b/tests/test_test_plain_file.py index 53980aa..fdab4c0 100644 --- a/tests/test_test_plain_file.py +++ b/tests/test_test_plain_file.py @@ -57,9 +57,11 @@ def test_valid_file_binds_to_its_functionality_and_keeps_the_scenarios_verbatim( assert [scenario.line_number for scenario in parsed.scenarios] == [10, 14, 26, 33, 38] -def test_both_sections_may_be_empty(fixtures_dir): - parsed = _parse(fixtures_dir, "empty_sections.plain") +@pytest.mark.parametrize("file_name", ["empty_sections.plain", "minimal.plain", "comment_only_conformance.plain"]) +def test_both_sections_may_be_empty(fixtures_dir, file_name): + parsed = _parse(fixtures_dir, file_name) + assert (parsed.module, parsed.func_id) == ("task-manager", "add-task") assert (parsed.conformance_tests, parsed.acceptance_tests) == ([], []) From e0c5f31ea3c4800d2be11f1041ea70b39e046318 Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Tue, 27 Oct 2026 14:49:24 +0100 Subject: [PATCH 12/15] feat: read test plain files only from a given test directory The parser has no default test directory. Test plain files come from --test-dir or from the test-dir config key; without both, none are read. A config that names a conformance-tests-script without a test directory is a usage error. --- README.md | 5 +++-- src/plain_parser/cli.py | 14 ++++++++------ tests/data/cli/project/config.yaml | 1 + tests/test_cli.py | 31 ++++++++++++++++++++++++++++-- 4 files changed, 41 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 26fd2bf..95fff66 100644 --- a/README.md +++ b/README.md @@ -77,8 +77,9 @@ When `--template-dir` is not given, it is read from the `template-dir` key of `c error. The config file is read even when `--template-dir` is given, so a malformed one fails the run either way. Test plain files are the `.plain` files under the test directories: those given with `--test-dir` (repeat the -option for more than one), else the `test-dir` key of the config file (a string or a list), else `tests` next to -the `.plain` file. A test directory that does not exist is skipped. Linked resources in a scenario are looked up +option for more than one), else the `test-dir` key of the config file (a string or a list). Without both, no test +plain files are read, and a config file that names a `conformance-tests-script` makes that a usage error. A test +directory that does not exist is skipped. Linked resources in a scenario are looked up as in a module. The `conformance-tests-script` key of the config file turns on the check that every functionality has a test plain file. Other config keys are ignored. diff --git a/src/plain_parser/cli.py b/src/plain_parser/cli.py index b247960..72fe57c 100644 --- a/src/plain_parser/cli.py +++ b/src/plain_parser/cli.py @@ -15,7 +15,6 @@ TEMPLATE_DIR_CONFIG_KEYS = ("template_dir", "template-dir") TEST_DIR_CONFIG_KEYS = ("test_dir", "test-dir") CONFORMANCE_TESTS_SCRIPT_CONFIG_KEYS = ("conformance_tests_script", "conformance-tests-script") -DEFAULT_TEST_DIR = "tests" PARSE_FORMAT_VERSION = 2 @@ -49,7 +48,7 @@ def build_parser() -> argparse.ArgumentParser: action="append", metavar="DIR", help="Directory scanned for test plain files; repeat the option for more than one. Overrides the " - f"test-dir value of the config file. Defaults to {DEFAULT_TEST_DIR} next to the .plain file.", + "test-dir value of the config file. Without both, no test plain files are read.", ) subparser.add_argument( "--config-name", @@ -137,7 +136,7 @@ def parse_spec( """Parse the module, its requires chain and its test plain files, resolve the links, and walk the entry's FRIDs; raise on the first error. - ``test_dirs`` defaults to ``tests`` next to the module. With ``require_test_plain_files``, every functionality needs a + Without ``test_dirs`` no test plain files are read. With ``require_test_plain_files``, every functionality needs a test plain file bound to it. With ``load_resources``, each module's linked files are also read and checked (text only, no large base64 blob) right after that module's links are collected, so a resource error is reported before a FRID-walk error. @@ -147,9 +146,7 @@ def parse_spec( template_dirs = [entry_dir] if template_dir: template_dirs.append(os.path.abspath(template_dir)) - if test_dirs is None: - test_dirs = [os.path.join(entry_dir, DEFAULT_TEST_DIR)] - test_dirs = [os.path.abspath(test_dir) for test_dir in test_dirs] + test_dirs = [os.path.abspath(test_dir) for test_dir in test_dirs or []] parsed = project.parse_project( os.path.basename(plain_file_path), template_dirs, test_dirs, require_test_plain_files @@ -276,6 +273,11 @@ def main(argv: list[str] | None = None) -> int: config = _read_config(parser, args) template_dir = args.template_dir or config.template_dir test_dirs = args.test_dir or config.test_dirs + if config.conformance_tests_enabled and not test_dirs: + parser.error( + "conformance-tests-script is set in the config file, but no test directory is given. " + "Pass --test-dir or set test-dir in the config file." + ) if args.command == "parse": return parse(args.plain_file, template_dir, test_dirs, config.conformance_tests_enabled) check(args.plain_file, template_dir, test_dirs, config.conformance_tests_enabled) diff --git a/tests/data/cli/project/config.yaml b/tests/data/cli/project/config.yaml index 909167b..438db64 100644 --- a/tests/data/cli/project/config.yaml +++ b/tests/data/cli/project/config.yaml @@ -1,2 +1,3 @@ unittests-script: test_scripts/run_unittests.sh conformance-tests-script: test_scripts/run_conformance_tests.sh +test-dir: tests diff --git a/tests/test_cli.py b/tests/test_cli.py index dbd8a55..abb9bc7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -386,6 +386,33 @@ def test_check_without_conformance_tests_script_needs_no_test_plain_files(projec _assert_ok(_run_check(capsys, str(copy / "todo.plain"))) +def test_without_test_dir_no_test_plain_files_are_read(project_dir, tmp_path, capsys): + # The parser has no default test directory: tests/ next to the module is not scanned on its own. + copy = _copy_project(project_dir, tmp_path) + (copy / "config.yaml").unlink() + (copy / "tests" / "todo" / "add-task.plain").write_text("not a test plain file") + + _assert_ok(_run_check(capsys, str(copy / "todo.plain"))) + exit_code, out, _ = _run_parse(capsys, str(copy / "todo.plain")) + assert exit_code == 0 + assert _parse_output(out)["test_plain_files"] == {} + + +@pytest.mark.parametrize("command", ["check", "parse"]) +def test_conformance_tests_script_without_test_dir_is_usage_error(project_dir, tmp_path, capsys, command): + copy = _copy_project(project_dir, tmp_path) + (copy / "config.yaml").write_text("conformance-tests-script: run.sh\n") + + with pytest.raises(SystemExit) as exc_info: + cli.main([command, str(copy / "todo.plain")]) + + captured = capsys.readouterr() + assert exc_info.value.code == 2 + assert captured.out == "" + assert "no test directory is given. Pass --test-dir or set test-dir in the config file." in captured.err + _assert_ok(_run_check(capsys, str(copy / "todo.plain"), "--test-dir", str(copy / "tests"))) + + def test_check_test_plain_file_error_is_reported_with_its_file(project_dir, tmp_path, capsys): copy = _copy_project(project_dir, tmp_path) (copy / "tests" / "todo" / "list-tasks.plain").write_text( @@ -422,7 +449,7 @@ def test_check_reads_test_dir_from_config(project_dir, tmp_path, capsys, monkeyp assert "todo:add-task has no test plain file" in _run_check(capsys, str(copy / "todo.plain"))[2] -def test_check_config_test_dir_replaces_the_default(project_dir, tmp_path, capsys): +def test_check_config_test_dir_is_the_only_directory_scanned(project_dir, tmp_path, capsys): copy = _copy_project(project_dir, tmp_path) (copy / "config.yaml").write_text("conformance-tests-script: run.sh\ntest-dir: scenarios\n") @@ -446,7 +473,7 @@ def test_check_link_in_scenario_is_read(project_dir, tmp_path, capsys): assert err.startswith("Error: Referenced resource 'listing.bin' in module 'todo' is a binary file.") -def test_check_test_dir_option_overrides_the_config_and_the_default(project_dir, tmp_path, capsys, monkeypatch): +def test_check_test_dir_option_overrides_the_config(project_dir, tmp_path, capsys, monkeypatch): copy = _copy_project(project_dir, tmp_path) (copy / "tests").rename(copy / "scenarios") (copy / "config.yaml").write_text("conformance-tests-script: run.sh\ntest-dir: does_not_exist\n") From 24ab68b9b3033c9a34896604b906af5ac4fd418a Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Sat, 31 Oct 2026 20:27:02 +0100 Subject: [PATCH 13/15] feat: rename --test-dir to --tests-folder The option and the config key take the names the codeplain client uses: --tests-folder, and tests-folder or tests_folder in config.yaml. --- README.md | 10 +- src/plain_parser/cli.py | 48 +- src/plain_parser/project.py | 32 +- tests/data/cli/project/config.yaml | 2 +- tests/test_cli.py | 37 +- tests/test_project.py | 22 +- uv.lock | 768 +++++++++++++++++++++++++++++ 7 files changed, 848 insertions(+), 71 deletions(-) create mode 100644 uv.lock diff --git a/README.md b/README.md index 95fff66..c6c3250 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,11 @@ pip install plain-parser ## Command line ``` -plain-parser check [--template-dir DIR] [--test-dir DIR]... [--config-name NAME] -plain-parser parse [--template-dir DIR] [--test-dir DIR]... [--config-name NAME] +plain-parser check [--template-dir DIR] [--tests-folder DIR]... [--config-name NAME] +plain-parser parse [--template-dir DIR] [--tests-folder DIR]... [--config-name NAME] ``` -Both commands parse the module and its `requires` chain, bind the test plain files under the test directories to the +Both commands parse the module and its `requires` chain, bind the test plain files under the tests folders to the functionalities, check that every linked resource is an existing file (not a directory), and walk the module's functionalities. They stop at the first error. @@ -76,8 +76,8 @@ When `--template-dir` is not given, it is read from the `template-dir` key of `c `--config-name`), found next to the `.plain` file or in the working directory. Finding it in both is a usage error. The config file is read even when `--template-dir` is given, so a malformed one fails the run either way. -Test plain files are the `.plain` files under the test directories: those given with `--test-dir` (repeat the -option for more than one), else the `test-dir` key of the config file (a string or a list). Without both, no test +Test plain files are the `.plain` files under the tests folders: those given with `--tests-folder` (repeat the +option for more than one), else the `tests-folder` key of the config file (a string or a list). Without both, no test plain files are read, and a config file that names a `conformance-tests-script` makes that a usage error. A test directory that does not exist is skipped. Linked resources in a scenario are looked up as in a module. The `conformance-tests-script` key of the config file turns on the check that every diff --git a/src/plain_parser/cli.py b/src/plain_parser/cli.py index 72fe57c..7add73d 100644 --- a/src/plain_parser/cli.py +++ b/src/plain_parser/cli.py @@ -1,4 +1,4 @@ -"""Command-line entry point: ``plain-parser {check,parse} [--template-dir DIR] [--test-dir DIR]... +"""Command-line entry point: ``plain-parser {check,parse} [--template-dir DIR] [--tests-folder DIR]... [--config-name NAME]``.""" import argparse @@ -13,7 +13,7 @@ DEFAULT_CONFIG_NAME = "config.yaml" TEMPLATE_DIR_CONFIG_KEYS = ("template_dir", "template-dir") -TEST_DIR_CONFIG_KEYS = ("test_dir", "test-dir") +TESTS_FOLDER_CONFIG_KEYS = ("tests_folder", "tests-folder") CONFORMANCE_TESTS_SCRIPT_CONFIG_KEYS = ("conformance_tests_script", "conformance-tests-script") PARSE_FORMAT_VERSION = 2 @@ -44,16 +44,16 @@ def build_parser() -> argparse.ArgumentParser: "Overrides the template-dir value of the config file.", ) subparser.add_argument( - "--test-dir", + "--tests-folder", action="append", metavar="DIR", help="Directory scanned for test plain files; repeat the option for more than one. Overrides the " - "test-dir value of the config file. Without both, no test plain files are read.", + "tests-folder value of the config file. Without both, no test plain files are read.", ) subparser.add_argument( "--config-name", default=DEFAULT_CONFIG_NAME, - help="Name of the config file to read template-dir, test-dir and conformance-tests-script from. " + help="Name of the config file to read template-dir, tests-folder and conformance-tests-script from. " "Looked up in the .plain file's directory and the current working directory. Defaults to %(default)s.", ) return parser @@ -84,7 +84,7 @@ def resolve_config_file(config_name: str, plain_file_path: str) -> str | None: @dataclass class Config: template_dir: str | None = None - test_dirs: list[str] | None = None # None when the config does not set test-dir. + tests_folders: list[str] | None = None # None when the config does not set tests-folder. conformance_tests_enabled: bool = False # Whether the config names a conformance-tests-script. @@ -107,9 +107,11 @@ def resolve(value) -> str: template_dir = _config_value(config, TEMPLATE_DIR_CONFIG_KEYS) if template_dir: result.template_dir = resolve(template_dir) - test_dir = _config_value(config, TEST_DIR_CONFIG_KEYS) - if test_dir: - result.test_dirs = [resolve(value) for value in (test_dir if isinstance(test_dir, list) else [test_dir])] + tests_folder = _config_value(config, TESTS_FOLDER_CONFIG_KEYS) + if tests_folder: + result.tests_folders = [ + resolve(value) for value in (tests_folder if isinstance(tests_folder, list) else [tests_folder]) + ] result.conformance_tests_enabled = bool(_config_value(config, CONFORMANCE_TESTS_SCRIPT_CONFIG_KEYS)) return result @@ -130,13 +132,13 @@ def parse_spec( plain_file_path: str, template_dir: str | None, load_resources: bool, - test_dirs: list[str] | None = None, + tests_folders: list[str] | None = None, require_test_plain_files: bool = False, ) -> ParseResult: """Parse the module, its requires chain and its test plain files, resolve the links, and walk the entry's FRIDs; raise on the first error. - Without ``test_dirs`` no test plain files are read. With ``require_test_plain_files``, every functionality needs a + Without ``tests_folders`` no test plain files are read. With ``require_test_plain_files``, every functionality needs a test plain file bound to it. With ``load_resources``, each module's linked files are also read and checked (text only, no large base64 blob) right after that module's links are collected, so a resource error is reported before a FRID-walk error. @@ -146,10 +148,10 @@ def parse_spec( template_dirs = [entry_dir] if template_dir: template_dirs.append(os.path.abspath(template_dir)) - test_dirs = [os.path.abspath(test_dir) for test_dir in test_dirs or []] + tests_folders = [os.path.abspath(tests_folder) for tests_folder in tests_folders or []] parsed = project.parse_project( - os.path.basename(plain_file_path), template_dirs, test_dirs, require_test_plain_files + os.path.basename(plain_file_path), template_dirs, tests_folders, require_test_plain_files ) chain = parsed.chain @@ -180,11 +182,11 @@ def parse_spec( def check( plain_file_path: str, template_dir: str | None, - test_dirs: list[str] | None = None, + tests_folders: list[str] | None = None, require_test_plain_files: bool = False, ) -> None: """Raise if the module, its requires chain, its test plain files, or its linked resources are invalid.""" - parse_spec(plain_file_path, template_dir, True, test_dirs, require_test_plain_files) + parse_spec(plain_file_path, template_dir, True, tests_folders, require_test_plain_files) def _error_output(type_name: str, message: str) -> dict: @@ -204,13 +206,13 @@ def _encode_output(output: dict) -> bytes: def parse( plain_file_path: str, template_dir: str | None, - test_dirs: list[str] | None = None, + tests_folders: list[str] | None = None, require_test_plain_files: bool = False, ) -> int: """Print the parse result, or the parser error, as one UTF-8 JSON document on stdout; return the exit code.""" output: dict try: - result = parse_spec(plain_file_path, template_dir, False, test_dirs, require_test_plain_files) + result = parse_spec(plain_file_path, template_dir, False, tests_folders, require_test_plain_files) output = { "format_version": PARSE_FORMAT_VERSION, "modules": [{"module": module_name, "tree": tree} for module_name, tree in result.modules], @@ -272,15 +274,15 @@ def main(argv: list[str] | None = None) -> int: # The config file is resolved even when --template-dir is given, so an ambiguous config is always an error. config = _read_config(parser, args) template_dir = args.template_dir or config.template_dir - test_dirs = args.test_dir or config.test_dirs - if config.conformance_tests_enabled and not test_dirs: + tests_folders = args.tests_folder or config.tests_folders + if config.conformance_tests_enabled and not tests_folders: parser.error( - "conformance-tests-script is set in the config file, but no test directory is given. " - "Pass --test-dir or set test-dir in the config file." + "conformance-tests-script is set in the config file, but no tests folder is given. " + "Pass --tests-folder or set tests-folder in the config file." ) if args.command == "parse": - return parse(args.plain_file, template_dir, test_dirs, config.conformance_tests_enabled) - check(args.plain_file, template_dir, test_dirs, config.conformance_tests_enabled) + return parse(args.plain_file, template_dir, tests_folders, config.conformance_tests_enabled) + check(args.plain_file, template_dir, tests_folders, config.conformance_tests_enabled) except KeyboardInterrupt: return 130 except Exception as e: diff --git a/src/plain_parser/project.py b/src/plain_parser/project.py index 6e21cc6..ee2d17a 100644 --- a/src/plain_parser/project.py +++ b/src/plain_parser/project.py @@ -23,14 +23,14 @@ class ProjectParseResult: test_plain_files: list[BoundTestPlainFile] -def discover_test_plain_files(test_dirs: list[str], entry_dir: str) -> list[tuple[str, str]]: - """(path, name) of every .plain file under the test dirs: test dir by test dir, and within one by path. A - test dir that does not exist is skipped.""" +def discover_test_plain_files(tests_folders: list[str], entry_dir: str) -> list[tuple[str, str]]: + """(path, name) of every .plain file under the tests folders: tests folder by tests folder, and within one by path. A + tests folder that does not exist is skipped.""" found = [] - for test_dir in test_dirs: - if not os.path.isdir(test_dir): + for tests_folder in tests_folders: + if not os.path.isdir(tests_folder): continue - for root, dirs, files in os.walk(test_dir): + for root, dirs, files in os.walk(tests_folder): dirs.sort() for file_name in sorted(files): if file_name.endswith(plain_file.PLAIN_SOURCE_FILE_EXTENSION): @@ -46,7 +46,7 @@ def read_test_plain_file_text(path: str, name: str) -> str: try: text = raw.decode("utf-8") except UnicodeDecodeError: - raise PlainSyntaxError(f"Plain syntax error: File '{name}' under a test folder is not a text file.") + raise PlainSyntaxError(f"Plain syntax error: File '{name}' under a tests folder is not a text file.") blob = loaders.find_large_base64_blob(text) if blob is not None: @@ -58,11 +58,11 @@ def read_test_plain_file_text(path: str, name: str) -> str: def bind_test_plain_files( - chain: list[tuple[str, dict]], test_dirs: list[str], template_dirs: list[str], entry_dir: str + chain: list[tuple[str, dict]], tests_folders: list[str], template_dirs: list[str], entry_dir: str ) -> list[BoundTestPlainFile]: - """Read every test plain file under the test dirs and add its scenarios to the functionality it binds to. + """Read every test plain file under the tests folders and add its scenarios to the functionality it binds to. - A `.plain` file under a test dir without the `module:` and `func_id:` keys is an error, as is a file that binds to + A `.plain` file under a tests folder without the `module:` and `func_id:` keys is an error, as is a file that binds to a module outside the chain or to a func-id the module does not have. The concepts of the scenarios must be visible to the bound module. Files bound to one functionality are merged in discovery order; a scenario-id is unique across that merged set. @@ -70,17 +70,17 @@ def bind_test_plain_files( trees = dict(chain) bound = [] scenario_places: dict[tuple[str, str], dict[str, str]] = {} # (module, func-id) -> scenario-id -> place - for path, name in discover_test_plain_files(test_dirs, entry_dir): + for path, name in discover_test_plain_files(tests_folders, entry_dir): text = read_test_plain_file_text(path, name) try: metadata = frontmatter.loads(text).metadata except Exception as e: raise PlainSyntaxError( - f"Plain syntax error: File '{name}' under a test folder has invalid frontmatter: {e}" + f"Plain syntax error: File '{name}' under a tests folder has invalid frontmatter: {e}" ) if not test_plain_file.is_test_plain_file(metadata): raise PlainSyntaxError( - f"Plain syntax error: File '{name}' is under a test folder but is not a test plain file: its " + f"Plain syntax error: File '{name}' is under a tests folder but is not a test plain file: its " f"frontmatter needs the keys `module:` and `func_id:`." ) @@ -143,15 +143,15 @@ def check_test_plain_files(chain: list[tuple[str, dict]], bound: list[BoundTestP def parse_project( - plain_file_name: str, template_dirs: list[str], test_dirs: list[str], require_test_plain_files: bool + plain_file_name: str, template_dirs: list[str], tests_folders: list[str], require_test_plain_files: bool ) -> ProjectParseResult: - """Parse a module with its requires chain and bind the test plain files under ``test_dirs`` to its functionalities. + """Parse a module with its requires chain and bind the test plain files under ``tests_folders`` to its functionalities. ``template_dirs[0]`` is the entry module's directory; test plain files are named relative to it. With ``require_test_plain_files``, every functionality of every module in the chain needs a test plain file. """ chain = plain_file.parse_module_chain(plain_file_name, template_dirs) - bound = bind_test_plain_files(chain, test_dirs, template_dirs, template_dirs[0]) + bound = bind_test_plain_files(chain, tests_folders, template_dirs, template_dirs[0]) if require_test_plain_files: check_test_plain_files(chain, bound) return ProjectParseResult(chain, bound) diff --git a/tests/data/cli/project/config.yaml b/tests/data/cli/project/config.yaml index 438db64..25f4c6f 100644 --- a/tests/data/cli/project/config.yaml +++ b/tests/data/cli/project/config.yaml @@ -1,3 +1,3 @@ unittests-script: test_scripts/run_unittests.sh conformance-tests-script: test_scripts/run_conformance_tests.sh -test-dir: tests +tests-folder: tests diff --git a/tests/test_cli.py b/tests/test_cli.py index abb9bc7..710872f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -386,8 +386,8 @@ def test_check_without_conformance_tests_script_needs_no_test_plain_files(projec _assert_ok(_run_check(capsys, str(copy / "todo.plain"))) -def test_without_test_dir_no_test_plain_files_are_read(project_dir, tmp_path, capsys): - # The parser has no default test directory: tests/ next to the module is not scanned on its own. +def test_without_tests_folder_no_test_plain_files_are_read(project_dir, tmp_path, capsys): + # The parser has no default tests folder: tests/ next to the module is not scanned on its own. copy = _copy_project(project_dir, tmp_path) (copy / "config.yaml").unlink() (copy / "tests" / "todo" / "add-task.plain").write_text("not a test plain file") @@ -399,7 +399,7 @@ def test_without_test_dir_no_test_plain_files_are_read(project_dir, tmp_path, ca @pytest.mark.parametrize("command", ["check", "parse"]) -def test_conformance_tests_script_without_test_dir_is_usage_error(project_dir, tmp_path, capsys, command): +def test_conformance_tests_script_without_tests_folder_is_usage_error(project_dir, tmp_path, capsys, command): copy = _copy_project(project_dir, tmp_path) (copy / "config.yaml").write_text("conformance-tests-script: run.sh\n") @@ -409,8 +409,8 @@ def test_conformance_tests_script_without_test_dir_is_usage_error(project_dir, t captured = capsys.readouterr() assert exc_info.value.code == 2 assert captured.out == "" - assert "no test directory is given. Pass --test-dir or set test-dir in the config file." in captured.err - _assert_ok(_run_check(capsys, str(copy / "todo.plain"), "--test-dir", str(copy / "tests"))) + assert "no tests folder is given. Pass --tests-folder or set tests-folder in the config file." in captured.err + _assert_ok(_run_check(capsys, str(copy / "todo.plain"), "--tests-folder", str(copy / "tests"))) def test_check_test_plain_file_error_is_reported_with_its_file(project_dir, tmp_path, capsys): @@ -433,12 +433,12 @@ def test_check_test_plain_file_error_is_reported_with_its_file(project_dir, tmp_ @pytest.mark.parametrize( "config_text", [ - "conformance-tests-script: run.sh\ntest-dir: scenarios\n", - "conformance_tests_script: run.sh\ntest_dir: scenarios\n", - "conformance-tests-script: run.sh\ntest-dir:\n - scenarios\n - more\n", + "conformance-tests-script: run.sh\ntests-folder: scenarios\n", + "conformance_tests_script: run.sh\ntests_folder: scenarios\n", + "conformance-tests-script: run.sh\ntests-folder:\n - scenarios\n - more\n", ], ) -def test_check_reads_test_dir_from_config(project_dir, tmp_path, capsys, monkeypatch, config_text): +def test_check_reads_tests_folder_from_config(project_dir, tmp_path, capsys, monkeypatch, config_text): copy = _copy_project(project_dir, tmp_path) (copy / "tests").rename(copy / "scenarios") (copy / "config.yaml").write_text(config_text) @@ -449,9 +449,9 @@ def test_check_reads_test_dir_from_config(project_dir, tmp_path, capsys, monkeyp assert "todo:add-task has no test plain file" in _run_check(capsys, str(copy / "todo.plain"))[2] -def test_check_config_test_dir_is_the_only_directory_scanned(project_dir, tmp_path, capsys): +def test_check_config_tests_folder_is_the_only_directory_scanned(project_dir, tmp_path, capsys): copy = _copy_project(project_dir, tmp_path) - (copy / "config.yaml").write_text("conformance-tests-script: run.sh\ntest-dir: scenarios\n") + (copy / "config.yaml").write_text("conformance-tests-script: run.sh\ntests-folder: scenarios\n") exit_code, _, err = _run_check(capsys, str(copy / "todo.plain")) @@ -473,17 +473,17 @@ def test_check_link_in_scenario_is_read(project_dir, tmp_path, capsys): assert err.startswith("Error: Referenced resource 'listing.bin' in module 'todo' is a binary file.") -def test_check_test_dir_option_overrides_the_config(project_dir, tmp_path, capsys, monkeypatch): +def test_check_tests_folder_option_overrides_the_config(project_dir, tmp_path, capsys, monkeypatch): copy = _copy_project(project_dir, tmp_path) (copy / "tests").rename(copy / "scenarios") - (copy / "config.yaml").write_text("conformance-tests-script: run.sh\ntest-dir: does_not_exist\n") + (copy / "config.yaml").write_text("conformance-tests-script: run.sh\ntests-folder: does_not_exist\n") monkeypatch.chdir(tmp_path) # the option is relative to the working directory assert "todo:add-task has no test plain file" in _run_check(capsys, "project/todo.plain")[2] - _assert_ok(_run_check(capsys, "project/todo.plain", "--test-dir", "project/scenarios")) + _assert_ok(_run_check(capsys, "project/todo.plain", "--tests-folder", "project/scenarios")) -def test_check_test_dir_option_can_be_repeated(project_dir, tmp_path, capsys): +def test_check_tests_folder_option_can_be_repeated(project_dir, tmp_path, capsys): copy = _copy_project(project_dir, tmp_path) (copy / "more").mkdir() (copy / "tests" / "todo" / "list-tasks.plain").rename(copy / "more" / "list-tasks.plain") @@ -491,7 +491,12 @@ def test_check_test_dir_option_can_be_repeated(project_dir, tmp_path, capsys): assert "todo:list-tasks has no test plain file" in _run_check(capsys, str(copy / "todo.plain"))[2] _assert_ok( _run_check( - capsys, str(copy / "todo.plain"), "--test-dir", str(copy / "tests"), "--test-dir", str(copy / "more") + capsys, + str(copy / "todo.plain"), + "--tests-folder", + str(copy / "tests"), + "--tests-folder", + str(copy / "more"), ) ) diff --git a/tests/test_project.py b/tests/test_project.py index 87b70c0..50c78a2 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -21,9 +21,9 @@ def scenario_file(module, func_id, conformance="", acceptance=""): ) -def parse(tmp_path, require_test_plain_files=False, test_dirs=None): - test_dirs = [str(tmp_path / "tests")] if test_dirs is None else test_dirs - return project.parse_project("todo.plain", [str(tmp_path)], test_dirs, require_test_plain_files) +def parse(tmp_path, require_test_plain_files=False, tests_folders=None): + tests_folders = [str(tmp_path / "tests")] if tests_folders is None else tests_folders + return project.parse_project("todo.plain", [str(tmp_path)], tests_folders, require_test_plain_files) def functionalities(result, module="todo"): @@ -86,18 +86,18 @@ def test_files_bound_to_one_functionality_merge_in_path_order(tmp_path): ] -def test_test_dirs_are_read_in_the_given_order(tmp_path): +def test_tests_folders_are_read_in_the_given_order(tmp_path): write(tmp_path / "todo.plain", MODULE) write(tmp_path / "more" / "b.plain", scenario_file("todo", "add-task", "# from-b\nFrom b.\n")) write(tmp_path / "tests" / "a.plain", scenario_file("todo", "add-task", "# from-a\nFrom a.\n")) - result = parse(tmp_path, test_dirs=[str(tmp_path / "more"), str(tmp_path / "tests")]) + result = parse(tmp_path, tests_folders=[str(tmp_path / "more"), str(tmp_path / "tests")]) assert scenario_ids(functionalities(result)["add-task"], plain_spec.CONFORMANCE_TESTS) == ["from-b", "from-a"] assert [bound.name for bound in result.test_plain_files] == ["more/b.plain", "tests/a.plain"] -def test_missing_test_dir_is_not_an_error(tmp_path): +def test_missing_tests_folder_is_not_an_error(tmp_path): write(tmp_path / "todo.plain", MODULE) result = parse(tmp_path) @@ -153,7 +153,7 @@ def test_unknown_func_id_is_rejected(tmp_path): ) -def test_plain_file_under_test_dir_without_binding_keys_is_rejected(tmp_path): +def test_plain_file_under_tests_folder_without_binding_keys_is_rejected(tmp_path): write(tmp_path / "todo.plain", MODULE) write(tmp_path / "tests" / "helper.plain", "***definitions***\n\n- :Helper: helps.\n") @@ -161,16 +161,18 @@ def test_plain_file_under_test_dir_without_binding_keys_is_rejected(tmp_path): parse(tmp_path) assert str(exc_info.value) == ( - "Plain syntax error: File 'tests/helper.plain' is under a test folder but is not a test plain file: its " + "Plain syntax error: File 'tests/helper.plain' is under a tests folder but is not a test plain file: its " "frontmatter needs the keys `module:` and `func_id:`." ) -def test_file_under_test_dir_with_invalid_frontmatter_is_rejected(tmp_path): +def test_file_under_tests_folder_with_invalid_frontmatter_is_rejected(tmp_path): write(tmp_path / "todo.plain", MODULE) write(tmp_path / "tests" / "broken.plain", "---\nmodule: [\n---\n") - with pytest.raises(PlainSyntaxError, match="File 'tests/broken.plain' under a test folder has invalid frontmatter"): + with pytest.raises( + PlainSyntaxError, match="File 'tests/broken.plain' under a tests folder has invalid frontmatter" + ): parse(tmp_path) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..44d98d0 --- /dev/null +++ b/uv.lock @@ -0,0 +1,768 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "ast-serialize" +version = "0.13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/d5/5dc1ee8a80c3b20a0b63c61b43ba6bc53517faecd1def47bcd752d22268e/ast_serialize-0.13.1.tar.gz", hash = "sha256:401cc84e8888d4d76f2c8d296fa006ed1a5ff993b6aff90dd777c03973efd025", size = 955137, upload-time = "2026-10-17T00:04:17.183Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/a8/fbd136b9f70c59e75f59a8e974c84102976f1201828b4f886480ba956936/ast_serialize-0.13.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3e129d44b0cffbea24a7f40117ce49e30993c3d119e584d4817ebd6f87246456", size = 897306, upload-time = "2026-10-17T00:02:52.479Z" }, + { url = "https://files.pythonhosted.org/packages/50/3f/2ad51e9823a790e7a70fd781e9595ab3abeace27f3aefcaa1939457efd72/ast_serialize-0.13.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:523d45bab16ddaf1399c04c4fe81bbf180f81e33c2f7bcbe76be56b0f7311ab4", size = 1236082, upload-time = "2026-10-17T00:02:54.82Z" }, + { url = "https://files.pythonhosted.org/packages/c0/5c/8e959db5cc2237f9d4c560a8e2253c98f9746883fb4774651e4cda458901/ast_serialize-0.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:638333fb8ae72c62753f9e794fda20290883ddc65b0d85e26e08f8bb227567ad", size = 1221242, upload-time = "2026-10-17T00:02:56.395Z" }, + { url = "https://files.pythonhosted.org/packages/d6/05/ab1c56c3b7adcac88764bba609502d6b4989e449b2dd60eb478e1d20e369/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc34c670747d85d4232834665e247009413eaebe47c69eaf60a5c439aaecaed9", size = 1282105, upload-time = "2026-10-17T00:02:58.275Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f4/41981fcd2ddffe0760e5326be9dcbd2eda9478a8430a637e5123bd8fcb26/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8c8ebd419811c435812d242fb2a173e57dfede198c9ac9a0a584666ebc9b9331", size = 1287905, upload-time = "2026-10-17T00:03:00.068Z" }, + { url = "https://files.pythonhosted.org/packages/6a/43/b783addc5681153707121991b7b47892f7e9c8ba58a0d03b71a3b023ba47/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8899c038b1c15ae8b98e379057fb3ef398ceb0b21d1737a8156dd5a0923b7e51", size = 1541806, upload-time = "2026-10-17T00:03:01.503Z" }, + { url = "https://files.pythonhosted.org/packages/47/10/969db095038ee15270f65ad20d1129c5e4da605bf7e93b70ae640f1c8a9f/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e14c4de98f965647a74e6eaf37e20207124e45451ccd36e84a6f6ebab45e0372", size = 1309946, upload-time = "2026-10-17T00:03:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/06/dd/7c623d2fb0b868948952d1787e629a43b866f7d4caefd5445e20aefacec4/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f3e8a3eff69af640db985360fef35073fb46956dfc3e5c614cf00920d2746dd", size = 1302773, upload-time = "2026-10-17T00:03:04.378Z" }, + { url = "https://files.pythonhosted.org/packages/d4/c2/4d49469110a19a44fc6269f4fd59679bcf84df068f185f89b73bee4da19b/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:75d7750fe98f6f35a96c43aba8d98a2e623e22c778a0c17ab9ede92eb10f5d82", size = 1299579, upload-time = "2026-10-17T00:03:05.88Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9a/2d381f1f262dda15affc8ad643fba1619eb07e7d9f8bd904deffef183006/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:43e8ddd63e80fc280c2f790157386f27f9b9f6ccb9c4f8e803b863cbfb085769", size = 1355969, upload-time = "2026-10-17T00:03:07.321Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9c/2266794e6c25fa9ce51ce04a9bf4b7e330e3ac9351ba3fa14e487678acdb/ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9221e09496fb942901993c1812ee87950cc0ecbb514c7c0f1f5fa6d78f701ed8", size = 1460227, upload-time = "2026-10-17T00:03:08.733Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d6/14deb28e5d8f328f626a0778ea2cf98fe908f2bbcccd804cb6f449ee1782/ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:7a5c38bdb93669c596f94fb7204dd6ebe4fadb19058ac9a2faf2948c2a175f96", size = 1562691, upload-time = "2026-10-17T00:03:10.25Z" }, + { url = "https://files.pythonhosted.org/packages/f5/19/7a28c0e5ba54f0d3e054f847a2a549b748cb179ab433321a8dc1b39e398d/ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:acf4e9f055d75058ab01536da330648746f2f82b7697a85230547f2d5ebe895c", size = 1558669, upload-time = "2026-10-17T00:03:11.799Z" }, + { url = "https://files.pythonhosted.org/packages/18/9a/f9e2690eab58217f1d395a964a78efa7eaf813a1e38be8dfd9a8d45d0840/ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87427913c325adc811e81e7c9a757b061f47ef1e29114ac479aa0953a2687a98", size = 1665071, upload-time = "2026-10-17T00:03:13.284Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f3/ef5b159a0d7161b404f05e7ec21cf7735ee7edc10c000cd2cbbc7bb6b69a/ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:ad88921f95b92bc144d9ad059a0e16f8c1ae69f5239f2d3c6536fd2774f73dfe", size = 1473364, upload-time = "2026-10-17T00:03:14.563Z" }, + { url = "https://files.pythonhosted.org/packages/1d/87/6e8ac4a865124a12cc4f915bf1bcfac3bba426cd5f58d3712a873ee61012/ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f8b17e2921e56621bcb46792bd3e0b82ca3b85de78d5936e7b8174ba1758b0aa", size = 1500566, upload-time = "2026-10-17T00:03:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1e/29155f1621433008013ad5c940a03d41a676be0680c41527040472d37583/ast_serialize-0.13.1-cp314-cp314t-win32.whl", hash = "sha256:2f9a9ce8b0994f757b941f7f7fe384a6666b4162b941ebc5c248081baea18e47", size = 1128674, upload-time = "2026-10-17T00:03:17.481Z" }, + { url = "https://files.pythonhosted.org/packages/bb/64/2506da65143ea138de5822e9173e0b72320a2f70e23bd723f044033175f1/ast_serialize-0.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5d99910e6b9a9b9360bcca2ae99aee36cc912ce058df2a57d1c33099b6cb2593", size = 1163306, upload-time = "2026-10-17T00:03:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/6d/8f/c7cb22638899b0cf15908809c270560e46395e65604bc59e4b623ab9f405/ast_serialize-0.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:a789cbe14d0a0e9ec9b32a456e5abc81ad8799479c3cfd88a29e3442262aebe6", size = 1133775, upload-time = "2026-10-17T00:03:20.377Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e2/b0c140fe50fa39dd249e382f7733af6f816c90021fe7959a0b026f86237f/ast_serialize-0.13.1-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:23d768369b89d551077dc173da6892fad00443338417523b6732f4e8ba4a7ed5", size = 1236160, upload-time = "2026-10-17T00:03:21.945Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/a8a2ca21e3943555b48221f8fba546877a7ad39efaf6505506524967d7c4/ast_serialize-0.13.1-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:68f8829d114dab6c9db4a8bbb8d0e342958faee1b5e15a29022678df639960f8", size = 1222214, upload-time = "2026-10-17T00:03:23.462Z" }, + { url = "https://files.pythonhosted.org/packages/15/19/2f58f2ba64e4799b39de85c140aef09a03aa6ea53be75dd292a9d9b9d8ae/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e385b17d5bde892a6184003a9aff3518387908b73f8f5ea539313c07c8c0ede", size = 1282979, upload-time = "2026-10-17T00:03:24.695Z" }, + { url = "https://files.pythonhosted.org/packages/b1/af/ca6087fe84210c1ccd43e3182917bb70649fa1e69745a90c1d59ba08fea8/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:02686b281ec9b8e9614f6f0db27e12e4cac515f336c5d05b678bc9eb73ff2af5", size = 1288596, upload-time = "2026-10-17T00:03:26.04Z" }, + { url = "https://files.pythonhosted.org/packages/b0/2c/4ef964c763329a0347125e745ec0451ba4fbaceec9efe2ac729b7cd55f17/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a8ab2c2585b2e22e48cd4aff2136fce4eab4ced569b85f150ef9a5a5d6f34d49", size = 1545540, upload-time = "2026-10-17T00:03:27.52Z" }, + { url = "https://files.pythonhosted.org/packages/bb/85/aa961dbb75813cfa94de0ce9a16a9510c226b95f604c405421e727f44bb0/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c652d9f5d52d8b81eff3ff2c95c9dd0f281de45f6ad5d3fa95527550c396454a", size = 1309374, upload-time = "2026-10-17T00:03:29.091Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e6/f2b67c327d9f9533e41791804403ccc2dc457821195a2e51f0b587a90b90/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:121e1d08d58b75c15491cc3003609cbad71ac2b0016043c36d30a6b2032b6605", size = 1303466, upload-time = "2026-10-17T00:03:30.451Z" }, + { url = "https://files.pythonhosted.org/packages/73/1d/33f18b81d1bb1f5d682943ea92fda8130a6593627f12f6201668d40824fd/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:30cf89b6a6844542d116c758725d13de5b97b9410a51ee577715f761270d0c1c", size = 1300576, upload-time = "2026-10-17T00:03:32.102Z" }, + { url = "https://files.pythonhosted.org/packages/25/cb/64914144925f1a4abdd9217e717b122255c38fc5ebe4342f92052f3bacbc/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:732aaae9e498c973fc63fd7f308009b57c4afa6ff59d0b674d894e57d0bca01a", size = 1355960, upload-time = "2026-10-17T00:03:33.676Z" }, + { url = "https://files.pythonhosted.org/packages/ee/80/bd29379fab2bce19adc0bf9177002b11b23dfe8d8fecf409998b458b4624/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:d9c1a920b2aa0c1e016bed7721689717034ca0dba64ac3a3ff243b003d4e8dd8", size = 1459779, upload-time = "2026-10-17T00:03:35.09Z" }, + { url = "https://files.pythonhosted.org/packages/83/89/012dbe733de05ffdf8e40ca0f9fa5d417cb16ec93a74463c57c001914198/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:847cad4c22953823f5e76d0b126c6f9da670cfa45b1df41b9324e22189d77611", size = 1563399, upload-time = "2026-10-17T00:03:37.285Z" }, + { url = "https://files.pythonhosted.org/packages/69/37/ee4bcb0358bee38744eb0f18560446572d095fe3bf6b63d6f739d08a839c/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:aca07de3ca8e3d9f2b716c3c31f4f384fe4bd2d03e09912417cff7a3e08036f7", size = 1558808, upload-time = "2026-10-17T00:03:38.711Z" }, + { url = "https://files.pythonhosted.org/packages/54/1c/ff44c5355251d61ef22934c4430bb31a61de4781d25a992154aac9b8c82a/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:5db12d843d931b9d1f1519ce66b9cbfe72d8563b75c1204d255322254d1964dd", size = 1666324, upload-time = "2026-10-17T00:03:40.376Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bb/6da4bfc8aa3403e6447607131bf3463acaf954d990c6c8b6787c1902ddfe/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:fb8021f4dc3296de18737e3b8b34994411da725374d74a0b9eb32a738f2a23d0", size = 1474089, upload-time = "2026-10-17T00:03:41.715Z" }, + { url = "https://files.pythonhosted.org/packages/21/97/897e33bef2eefd880e6350e375cc66bc5bcd00a139be480fd655f5b3f095/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:0d764913374ba21df8cd618045b371ed104ce55c6276de7c59e554a4f5ba26fd", size = 1501542, upload-time = "2026-10-17T00:03:43.08Z" }, + { url = "https://files.pythonhosted.org/packages/8d/4c/821211ac83870c27e4412381b7c7838336456b40575bf840e4680b2018d9/ast_serialize-0.13.1-cp315-abi3.abi3t-win32.whl", hash = "sha256:2bad302ae2427454ef9dab71bd5c3ad67188860c39d36daee38f72031edb39c4", size = 1126118, upload-time = "2026-10-17T00:03:44.616Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/2b2d5e6c4fa352874a80618e742ffd32d249dee6c119f4233b51afc6b8f4/ast_serialize-0.13.1-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:032b09d86c785c3984e975378b7861f2a68ac44e22ba7b07b93a536b2524eeb8", size = 1163766, upload-time = "2026-10-17T00:03:45.986Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/d610cfcdabaa44fd62a54ca1fc57a7dba120643798b089a14914973562de/ast_serialize-0.13.1-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:929eda0c419e622ab49df3a4c45f81c57dcc10874bbad55d0470a4a03324dac3", size = 1134043, upload-time = "2026-10-17T00:03:48.453Z" }, + { url = "https://files.pythonhosted.org/packages/f0/89/0e30f9eddf7d44ccf9d25979030754fd0457af61fa4279acb8c293a04f97/ast_serialize-0.13.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:bf53a3ba1cbe45399fc1e4ccd684b8f2a11cca450f89706b79122f478534c6f5", size = 897546, upload-time = "2026-10-17T00:03:50.093Z" }, + { url = "https://files.pythonhosted.org/packages/81/92/d705be88ff452b9ba0088b554a0cbd308fd01879da092b33fd2ad7d1aa60/ast_serialize-0.13.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:4bcf1d5f4eefecaa325a34ecf079b2741189e7d0821523f92cb343a389a67489", size = 1242416, upload-time = "2026-10-17T00:03:51.521Z" }, + { url = "https://files.pythonhosted.org/packages/60/c2/98370845a7f35cb8a25782e78284b48e01bf3128339881d48d613f304f8f/ast_serialize-0.13.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1b158f1c9e3a0d386913924c57995d6df78224868a4d0f215bdd41587c97b34a", size = 1229729, upload-time = "2026-10-17T00:03:52.825Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8b/2d7ffed76addd9d3fcf37fb171400942735c8912264c763561a85407f6a8/ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a6201b69ce42e919f2fd9a559138d5410b5d8afff7c3a6b1f500b0481ff7d8", size = 1292255, upload-time = "2026-10-17T00:03:54.148Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2a/bb9e0e719d89cc950fadfcfcc9cb4bc7640419c4228c852f031ab8abf440/ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6eb0be64c9b8f1ed26637eef345abe75ea90351c92c85a8aec7838b3af971011", size = 1298501, upload-time = "2026-10-17T00:03:55.559Z" }, + { url = "https://files.pythonhosted.org/packages/7a/32/13ce07dce48226f5bd9cfd8f708ec101a8be05aa430cda4918ce31ce8c82/ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f2427bddfca208e84de700044fadf9aef2769a70bae740a1bdabb89ee9e1117", size = 1553758, upload-time = "2026-10-17T00:03:56.952Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fb/9b51d186db0c20ca28cbaaefd50f03b573c89327b9a2627820cbce22196b/ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88df960e67184a71874e5b2ee014f87d1254a0dbc1f903c7bd7b1ab7733ecaef", size = 1318258, upload-time = "2026-10-17T00:03:58.418Z" }, + { url = "https://files.pythonhosted.org/packages/c5/fa/da311559dd3dae9af5825cf21660775a929b970f72e3786af3f2cd667944/ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f99786e0d62082a5b121b3437f3167562133711df22128de1e9886f57f0b226", size = 1313260, upload-time = "2026-10-17T00:04:00.194Z" }, + { url = "https://files.pythonhosted.org/packages/2b/11/97a4b9e5b4776d72f47fcd9a09a497bbf68ec8c1ae074307a2c51e410d85/ast_serialize-0.13.1-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:a01b515b25a03184eff4acef25b981ae007916379682e908fc8785b342a0bf3e", size = 1309992, upload-time = "2026-10-17T00:04:01.575Z" }, + { url = "https://files.pythonhosted.org/packages/2e/c4/62f85121a80cd451ef04f550cf0f7ea0ad9ff46cc4e9a5b9ae439b6527b1/ast_serialize-0.13.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:29422d7bcd9e7f39411571c36cec45ce8a7a9b3ad4b2e7aa64497c15a9e397f6", size = 1362715, upload-time = "2026-10-17T00:04:02.881Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fa/c5f4a545f72a7681e3295bab6e3600ee0862ec4391bed961fe9f735b67e0/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6ffb0f548a7dc126067fdfb7b2325ab5dfcdeecc29faf5e0f0461c96d6ebfa3d", size = 1469931, upload-time = "2026-10-17T00:04:04.181Z" }, + { url = "https://files.pythonhosted.org/packages/38/a9/65aed4f8fa7a001a7cfdc03e68e844517ab041ba6fb614b7d78a1d6ac056/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:2876444a5f87a7c76f371af3c07c2fda563a08bc8af52568df812e8aba4bf568", size = 1570945, upload-time = "2026-10-17T00:04:05.778Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8c/44b2da991cede79bfbf52089f33a5f0168945466b3d9cd38b5dcf08e7130/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:885a62ec6481ce58ab24c6357360d7a82023b62dd7e5e7ceccf59e9662a3272b", size = 1569978, upload-time = "2026-10-17T00:04:07.347Z" }, + { url = "https://files.pythonhosted.org/packages/46/e4/109b2a5beca700237767d5add920c2f8e1ba9bd5799b0faf143d0e9cf4ef/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:2015fd0165e463aac3b1aadc6a1fa1e5820c14ca950821f4fe02f8a3f6a388d6", size = 1676082, upload-time = "2026-10-17T00:04:08.7Z" }, + { url = "https://files.pythonhosted.org/packages/73/e0/7df70f6dbb11b5762da5416318f08316a1c51348e0d69ec79ca5f98f836a/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:eb41e1906502ac80d24ff343f45008552a3f5b5158607959fe6a07dbfc6eb037", size = 1483207, upload-time = "2026-10-17T00:04:10.162Z" }, + { url = "https://files.pythonhosted.org/packages/1f/bc/523aa1c08bd69b5654621beb3dc1a9035715a4a63762fb8d219d6fb3eb81/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ca9acc67f796aa2554fa3037dccc022a4b08b38b2ad153edfac9c9bb7cb1f3c6", size = 1512140, upload-time = "2026-10-17T00:04:11.589Z" }, + { url = "https://files.pythonhosted.org/packages/66/83/6607060659998f00afdea0d8375c1e6e2b7d515ab905090d9a5a02f48851/ast_serialize-0.13.1-cp39-abi3-win32.whl", hash = "sha256:4f98da562e5901d69c7dd30885594c5438f859edf354e9ac7517d188fb026c3a", size = 1131889, upload-time = "2026-10-17T00:04:12.953Z" }, + { url = "https://files.pythonhosted.org/packages/9f/6f/bafd6490fe7b355627fabe95f4417e3d02da93102c8552550eb9a0774e1e/ast_serialize-0.13.1-cp39-abi3-win_amd64.whl", hash = "sha256:ddf7cbc9c8abf70102c4a088efe154bb86bfb825b0a10e556d2b15972cf99bd7", size = 1171153, upload-time = "2026-10-17T00:04:14.362Z" }, + { url = "https://files.pythonhosted.org/packages/11/25/4235f4b375a67a37ac595bf478a5407a744eaf7b64113c1c3a5432b8d954/ast_serialize-0.13.1-cp39-abi3-win_arm64.whl", hash = "sha256:48f11d96c9ec71119612f8e79a8c55c12f2b136716d68347433c561d27a25f43", size = 1139035, upload-time = "2026-10-17T00:04:15.767Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "black" +version = "26.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pytokens" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/37/5628dd55bf2b34257fc7603f0fe97c40e3aaf24265f416a9c85c95ca1436/black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73", size = 679439, upload-time = "2026-05-18T16:53:36.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/96/3c3e09f09f44a37aac36b178a279cd19aa7001bd796187a7b162a294c81f/black-26.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96ae2c733b2aabdd9986e2c5df628ff3473676cd1c5faded1ff496cf6d74083c", size = 1970639, upload-time = "2026-05-18T17:05:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/83/ea/5ad117b9ee3ecd933c712bcbae610006e5b7cc9f41c526cd7ed3b6c4124c/black-26.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0e48b87e03bf109288e55cfceadcfa15ff5470aca2851a851950ed2926f450d7", size = 1792130, upload-time = "2026-05-18T17:05:12.983Z" }, + { url = "https://files.pythonhosted.org/packages/06/3a/7c448bc623fcdfa96672531beb5a616ea5e64f6975955254d7731ffb0ad9/black-26.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5119fa92ae61f786e8c3662fd60aece1d0a2dd5cca5d0c79417a95e7a4272a59", size = 1846134, upload-time = "2026-05-18T17:05:14.506Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5b/0b39b3a5917f0657ac014ad2edb58c139553a478adfe7f817abf1622ff6e/black-26.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:30d3c14661f2792e9142cce3eeeb1cbc175b3eb5f733be0c8eeb99651e52b0c3", size = 1478883, upload-time = "2026-05-18T17:05:16.542Z" }, + { url = "https://files.pythonhosted.org/packages/4c/48/dc222692e0f95030db1bbfb6c857e76858bad09058221ea7aae815255327/black-26.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:1ef92b76f7733f282fd096ea406200b5a286c42947412b0eaff3a74e3616cefe", size = 1277776, upload-time = "2026-05-18T17:05:18.029Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/7744b906703228264ef73bdd534df88ec1ef3de45c4e78f6d31b9e32d0c9/black-26.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4ad6fa01f941920f54f2bbb35f3df7673428a0ef98a0b0840c2eaef3b110efa8", size = 2012518, upload-time = "2026-05-18T17:05:20.108Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c0/c5a3b1636dfd09c42534f2b3cf33506814f6d3e066fb0879ffa16c1ae860/black-26.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3915f256e75a2d7cf88d8953d37f780455dc586cc72dee059c528fe77f581217", size = 1816016, upload-time = "2026-05-18T17:05:21.84Z" }, + { url = "https://files.pythonhosted.org/packages/1f/0e/36044316b65ca471d3bb6d3703fd06fb50c6b727c3562f6a5a3153634f88/black-26.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d98d4137277c75dfb898ec8d846c4fd68ba1e9cf77f95e2865c203dc18f4c3d", size = 1884150, upload-time = "2026-05-18T17:05:23.546Z" }, + { url = "https://files.pythonhosted.org/packages/b3/33/dafc5808c2af43672912111d7c3354af1615f7e2be3bed7a878461abbe4d/black-26.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:a1dca32d9f1784af512a13410ec204c6f7f0aa9797a111c42e1c03449821c264", size = 1486825, upload-time = "2026-05-18T17:05:25.004Z" }, + { url = "https://files.pythonhosted.org/packages/82/14/b965ee6ad2a311f28bdbf692def3ee9848d2ae289dab28b27657fcee3e78/black-26.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1037d5ac7b7b310b2632ad867ec8d0e4c4819dcdb0b820f63135da746a24e418", size = 1288646, upload-time = "2026-05-18T17:05:26.477Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5c/c384363980e11e25ca6b93205949bb331fbf35f4e0dbec376dfa6326cec8/black-26.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b36cf2ddf5566e205f6535f782a62194a184d33e175b64ae8c40b1737522be3", size = 2009020, upload-time = "2026-05-18T17:05:28.132Z" }, + { url = "https://files.pythonhosted.org/packages/0b/df/9f31c5e0babbfed77d505fc5d120beb98b21b33feaeded3924ea941fe360/black-26.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f7ea64ebfa01b50f693508fc39f875e264446d3b097088f84f203b9d09618a0", size = 1813335, upload-time = "2026-05-18T17:05:31.266Z" }, + { url = "https://files.pythonhosted.org/packages/fb/24/8e7b9a2fa61b0afd82209efe937557d180a1fa055bd7f6161eb9defc3719/black-26.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294", size = 1881614, upload-time = "2026-05-18T17:05:32.718Z" }, + { url = "https://files.pythonhosted.org/packages/49/ad/b4e0d9365ba8ac34f6bbab62a4b1b2dd5d618fac3fa1b8db968c844201b5/black-26.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:e1a26503279b6b310669fb0b219c39e4820b77e8189fe80f522bb511f247db0a", size = 1488925, upload-time = "2026-05-18T17:05:34.259Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4b/652b859bf5df88a751c30451b09338f7fd26a77d1271c666992f836b7711/black-26.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c34b25da232ead53a6f335b76dbea124f4d152ad568b9080d6f944bc2b34b52", size = 1289883, upload-time = "2026-05-18T17:05:36.019Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a8da8eb208c51c7f4ce74609a45d0dcc6d8a2141e45e81ee5289d1bb0d59/black-26.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168", size = 2004800, upload-time = "2026-05-18T17:05:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/11/8a/a479296a19e383b70a725882a6cf3d786540601ff03cabbaaf1cce864c5a/black-26.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3", size = 1815576, upload-time = "2026-05-18T17:05:40.309Z" }, + { url = "https://files.pythonhosted.org/packages/81/6b/cfaf3d39f25132c156a068f6b805576c9103a84086019507c70e1911ee7d/black-26.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18", size = 1877927, upload-time = "2026-05-18T17:05:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/66/76/302e313964bcff7e28df329d39f84f5270095730d85ff0acc260610a0d82/black-26.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:577f21094ea469ef92ec1adaf2c9441a226d2144d01a5be2fa823cecf6543e50", size = 1511860, upload-time = "2026-05-18T17:05:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/27/4e/a3827e35e0e567f9f9ee59e2a0ab979267dca98718f25547ca8c6733afd4/black-26.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae", size = 1316632, upload-time = "2026-05-18T17:05:45.521Z" }, + { url = "https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693, upload-time = "2026-05-18T16:53:33.964Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +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 = "flake8" +version = "7.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mccabe" }, + { name = "pycodestyle" }, + { name = "pyflakes" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/af/fbfe3c4b5a657d79e5c47a2827a362f9e1b763336a52f926126aa6dc7123/flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872", size = 48326, upload-time = "2025-06-20T19:31:35.838Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, +] + +[[package]] +name = "isort" +version = "8.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" }, +] + +[[package]] +name = "librt" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/f5/9dc696772d241814bacac7880bac32f2930b5a6ebc1f85317b83161a011c/librt-0.16.0.tar.gz", hash = "sha256:ac38d6d8d66bf3d744148dbbc0b8e193e195a51e364ed55e224631f5721891fc", size = 219839, upload-time = "2026-09-29T00:55:32.891Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/0e/b04fd2d76b6a78a02286938251b2c119fb36ddece92c76cb4886ef7d44b8/librt-0.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fe4372c52d4849096c6cc1cda2817d293ec51440c890474ed59ef38d46556f18", size = 148058, upload-time = "2026-09-29T00:44:40.903Z" }, + { url = "https://files.pythonhosted.org/packages/a3/fc/e1ed24aadb32d38603c0c07f579d739eb4d90e64e90d525b024e4a999b4d/librt-0.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c72c5295a84bd249526da9bdca38f2e176d15c31c13bb0063c5053f4ca023421", size = 153073, upload-time = "2026-09-29T00:44:42.165Z" }, + { url = "https://files.pythonhosted.org/packages/be/38/133ab655409420e0c34a9a9836a7f4e5ae75fba758d7fe177099130d58ba/librt-0.16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be56ba9c884143495b517f23fe794ae367d58cd89ea0fdd6d437e3c024a87f9f", size = 493052, upload-time = "2026-09-29T00:44:44.043Z" }, + { url = "https://files.pythonhosted.org/packages/94/42/aaa663bc9421f5bf7bbbe9d3316d7219749698ec7688ac3b519770a89c04/librt-0.16.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:ef46c1a29ffb8c72e882e22618ec618778eacd0578fb22c6e7cf9c11d15f357b", size = 485506, upload-time = "2026-09-29T00:44:45.479Z" }, + { url = "https://files.pythonhosted.org/packages/7b/59/83a6eb5087ba8b45b083859e2085256963e06d4c2531944cdd962670bfb5/librt-0.16.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d3c94211ee0c4f8d649ec06b7c115c0ec4eadb873a0e3154ca15cef3f814b071", size = 498708, upload-time = "2026-09-29T00:44:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e4/15a8d62b2bc12935080ec7277e2e0b9b233047781eb784a51d3e25af669a/librt-0.16.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94aed6a8308818b91677957d1bd03188869cd7aeb23c5dba7912a6c0402f7602", size = 515981, upload-time = "2026-09-29T00:44:48.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/4f/287cc282ac5dd815204c0779e79401bb0a568c3a1335fb319bf0838fb566/librt-0.16.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:349c0bcb87ebd07481b6ff781e25cdc699723dbe2212e57dabb27f7a13b7b87d", size = 508670, upload-time = "2026-09-29T00:44:49.726Z" }, + { url = "https://files.pythonhosted.org/packages/bf/5b/ab7292add898ade0d2b5b75d32a923aae315861671aa5c39f5491309f7ae/librt-0.16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:001bfd59a7d45b17e3e75f2a8c6405280b35e7b84471792778e718c4f368950e", size = 530393, upload-time = "2026-09-29T00:44:51.356Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9e/8e6bcf027d4bcbc16a4e864b3d158a1d3e5f55ffa53af0238020cad89271/librt-0.16.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:28e038895b998d7a0c7798922ce8a1dc157675df5cf1c9ef0aca809ed804b7a1", size = 534302, upload-time = "2026-09-29T00:44:52.893Z" }, + { url = "https://files.pythonhosted.org/packages/96/a4/8c5ab4f3b3a8e905ff8305d76976a2e666f52fe049edd398aed2402beb36/librt-0.16.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0dbe4096a7ecc00fa835d24510ad8545a4efef738dac96e0e63516783ccde905", size = 536150, upload-time = "2026-09-29T00:44:54.421Z" }, + { url = "https://files.pythonhosted.org/packages/ea/5e/7c4bc267e3cd4b8bd288de9f62d61fff203829c9cc649ea2aa12e9218cf0/librt-0.16.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5cd5b092441053364af968ea12084692cb9d4a22f3ce9524e377880bf028761e", size = 514316, upload-time = "2026-09-29T00:44:55.998Z" }, + { url = "https://files.pythonhosted.org/packages/e0/75/c8bc6b50f43c73819334233f5a3259c81216742cef56d2140c3dd377c3f0/librt-0.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ddeb3c9dedb461bb457c6c7d9aa7fbf35329da313d1a7543d00c8d0f3473c96", size = 557731, upload-time = "2026-09-29T00:44:57.581Z" }, + { url = "https://files.pythonhosted.org/packages/93/fb/e241f91bc8de6c7ac1c6a20ea6dd381bc52312b44d711ffcd97140db1e57/librt-0.16.0-cp311-cp311-win32.whl", hash = "sha256:e05108e0849966f53a8d2d3112a7af881d0efaa479bc735bba91108f9f2350a7", size = 104922, upload-time = "2026-09-29T00:44:59.078Z" }, + { url = "https://files.pythonhosted.org/packages/d1/38/c892383d2bb1a6a80b2ec8ede84970a265a792c56614a06282bf3008517c/librt-0.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:5f49cff01bd608ef7d97104cb035c75455e79c2d70bf4a506cf773338ac1860d", size = 125093, upload-time = "2026-09-29T00:49:01.621Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/8bbde1ed0b21480163617648f638fdaec9374cf5e9c846ebcee34ec02a42/librt-0.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:1d28ae980ae2218f9c5b95d191e947296f918c9bf0b400d467a9430275bbe678", size = 115910, upload-time = "2026-09-29T00:52:40.131Z" }, + { url = "https://files.pythonhosted.org/packages/ad/76/bbdaeb87b7c47b5c7343e90222b9bfa4e4a8a83f647e02933ad0129225b1/librt-0.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe52bf4641069e7978a14253b036cb9002def1926317e710f2e249f8a8c47742", size = 151028, upload-time = "2026-09-29T00:52:41.508Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0c/ab8ed3dab0085931aec4a792c7eaac8dc6c5ff4691fda3a5360d9d8a9cd2/librt-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5bcc2c4726ced915b00de0c9856a4eeabfb3fddb93e10e0b8f735b7709358b6d", size = 155283, upload-time = "2026-09-29T00:52:42.873Z" }, + { url = "https://files.pythonhosted.org/packages/eb/36/494e79d460c80c1f030661e8287c9eca5e1ad652dc2b2180b6cd42abce0a/librt-0.16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff7baa55f8e7c69851419e50a666015d02a74198716fd45c0125a2112e0a389f", size = 503185, upload-time = "2026-09-29T00:52:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/9b/34/a8464038dd9db6e4381fa2b6eb73dc9a50888d77102c4c139304ac35cddc/librt-0.16.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b95d5d92ab83d39e760a52091bb1baba664f3a2351e39b1e16801e5747c2f0e9", size = 496738, upload-time = "2026-09-29T00:52:46.441Z" }, + { url = "https://files.pythonhosted.org/packages/6a/53/e0e5e334ef0c6ed27039d323819368b9ef6712be87d55ee2bf9799398afd/librt-0.16.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b6d085d70bce51d43c5c7c36d63490770180d8779e71c49305c87b4213918de7", size = 513818, upload-time = "2026-09-29T00:52:48.068Z" }, + { url = "https://files.pythonhosted.org/packages/ad/f7/7ce72cbf19d0addd05090b339152fd0548a02562c2866a603e6e3b3da2df/librt-0.16.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36e53948e99bbe3ffea257124cfcae1cfb01831555c9a9c903c9f9a72db7fd07", size = 531952, upload-time = "2026-09-29T00:52:49.816Z" }, + { url = "https://files.pythonhosted.org/packages/83/22/0b1bcb6a8e723c8b4fd60dfc8ae8ec6461c54073fbc8685efeb8d900d407/librt-0.16.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:54d11f726aae9df5a6ffbbf0a03a52449bbac84a53ef03669cb41cdfd4ae41bf", size = 524465, upload-time = "2026-09-29T00:52:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/64/2e/e9c23b8b9df1813da1be205deca9606beb7ddd033972246cd426d05374a0/librt-0.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4323193ac0cd025f85af531df8ba91bf24d1973b401697347a6282e8fd3fcf5e", size = 543191, upload-time = "2026-09-29T00:52:53.284Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e5/6a8b21b342c03ed7e230fa3afbfd2edc58e6e87ef1f0d11fa2b9a748c252/librt-0.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e42f8e098b9c5396fefa05fb1cc7e33b0e08fc51da106b5de4a45fd22aac6743", size = 546979, upload-time = "2026-09-29T00:52:54.932Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/b5129023eced1be01e01c22757f53be551d463b1bb7264f787927404c1d6/librt-0.16.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:39ec1d5a14e37baf1450a6cabf03fe552340808bf1ad9d71824ab90117716459", size = 555317, upload-time = "2026-09-29T00:52:56.869Z" }, + { url = "https://files.pythonhosted.org/packages/71/89/28bba5938c725fe91f06bf93f7fa6c6b150229df53a87b454b0d5c2a796e/librt-0.16.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d1aabe3925cbb4a08d15b7b20ba4011b53019da0c4173a25155139b7b1baed65", size = 535846, upload-time = "2026-09-29T00:52:58.475Z" }, + { url = "https://files.pythonhosted.org/packages/22/92/63773026614f888c5d4e370e395ce42ca604b89f70b3acdfedbf94851b80/librt-0.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:300c3ffdc459f4a779a8411ecb188e3ac0b1ff3a3a7b099642555dedae06c69b", size = 573700, upload-time = "2026-09-29T00:53:00.127Z" }, + { url = "https://files.pythonhosted.org/packages/66/8f/347d4677eefb57cd9f8e01d95a1dcee9a91b4e44e664173c32ff6f3752e5/librt-0.16.0-cp312-cp312-win32.whl", hash = "sha256:c17194318e4c0c0348b36f36c2ec7534436fe0a4c15582403162a4f08c80797a", size = 106158, upload-time = "2026-09-29T00:53:02.031Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2c/5193dc81127cd5ddfad031391b046bf32dda219b38463ab872407ca30646/librt-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:25a58a19ea8d83b68209f04912df765e9260635ef77646542ed4b4abe6bc7940", size = 126176, upload-time = "2026-09-29T00:53:03.445Z" }, + { url = "https://files.pythonhosted.org/packages/ff/3d/9668a400c8dd81d162eba38b33fa49fa6205f1a64493570a13fbb815c3ee/librt-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7be7cf555bc30ec12622e9447299cc4a9b8ff307548b634794353db0c2065dc", size = 116572, upload-time = "2026-09-29T00:53:04.815Z" }, + { url = "https://files.pythonhosted.org/packages/46/cd/ae5e0e9dba45d1399aa04a5395bcc0bead40d9fa06dc903634a7b4d7473d/librt-0.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c5e6144e68b577f157519f2ba88ca20e3ed61c29b00e5cdfa76cd2d45acf059a", size = 151036, upload-time = "2026-09-29T00:53:06.284Z" }, + { url = "https://files.pythonhosted.org/packages/41/5a/48a16e323c5f9447a94cce7b59babf60fa04e62c3365ecf060c77ed8b320/librt-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:33f41443a1f4e1f099331b3d8120e409fbff84b9760bc1cc9ea496f37ddaa5cc", size = 155211, upload-time = "2026-09-29T00:53:07.72Z" }, + { url = "https://files.pythonhosted.org/packages/3f/29/0f59299eb4251a409b2e690ad4b7d9f8a676db829d7817ec961f32b44f7e/librt-0.16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e510b7770bee609617a3374a96548eb114cae048023e3f049ee449e7ff2db32", size = 502587, upload-time = "2026-09-29T00:53:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/de/ba/d6fb4ef8d1537c396079d72289f16be7cd35a366e5065c51253fea2760b6/librt-0.16.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:efc49c462d4516b8a58b00b490078fa64689fd1fe66970cc190131d7afb8027e", size = 496146, upload-time = "2026-09-29T00:53:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/52/fc/8c50dd4d7cc97c0ee8f252c8a3104980f234391cf1519b554e8b9de08b60/librt-0.16.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:92caf82ebef5e12d21c72242b70d1e92536f1711cf2a727a4c276de4b4469087", size = 513336, upload-time = "2026-09-29T00:53:12.655Z" }, + { url = "https://files.pythonhosted.org/packages/a9/59/16c409c56f708eda2db9a0553662845d45e3871c77d70a240dae3f3bdc56/librt-0.16.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17bac7f7a16b328fff77e440287693eb017abde913595b5827ebccbc21ecd8a6", size = 531689, upload-time = "2026-09-29T00:53:14.39Z" }, + { url = "https://files.pythonhosted.org/packages/88/82/d34772a6c29d1446dcca6e64d74062efd508523ab351aa16625a4689d5cc/librt-0.16.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5b976054553670829985ed767feb78fb6bcede0175327c4844dd5c281c1be659", size = 524537, upload-time = "2026-09-29T00:53:16.064Z" }, + { url = "https://files.pythonhosted.org/packages/77/8f/24c5631313746131ccee53bc91fdc8374f9cf25e0082a1fee9c93bb98acc/librt-0.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0058f9d68721094105917254c72ac0569117bb7b13b9769cf45d26d89f9d21cd", size = 543231, upload-time = "2026-09-29T00:53:17.939Z" }, + { url = "https://files.pythonhosted.org/packages/f2/cb/5f8e0d41dbd8b499c2265e939c31acc9ba59845565bf99539ad1c06aebcf/librt-0.16.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:30b7beaf3f4487b7d8adef1f158b49067cb4d5a19fa7a3bf31a4e7a820e435c5", size = 546499, upload-time = "2026-09-29T00:53:19.666Z" }, + { url = "https://files.pythonhosted.org/packages/be/61/063052de441d1385f59cea4223f184bf9e5d125de1ae3239b490aa1e486e/librt-0.16.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:468df902df016a06eb0e40b0747dc8d14e47d7a38b18b63b1fb167d85cb94d63", size = 555261, upload-time = "2026-09-29T00:53:21.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/11/a2ada0529372268d6401afa9d457a095b68cd7753532b6f7f33049a19b43/librt-0.16.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:aea7b1f2b125dad5de85f049136651bff256c883c65e6b9209b2da0a1ac3cdef", size = 536029, upload-time = "2026-09-29T00:53:23.07Z" }, + { url = "https://files.pythonhosted.org/packages/23/9d/5bb6d38853382986dca702fc7e06c8256d30f5fa0676d882773b744610dc/librt-0.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a8afb6557920860b7a3a596eb804cf37e09e7cf8a803db2478c202acc72d8c2e", size = 573748, upload-time = "2026-09-29T00:53:24.696Z" }, + { url = "https://files.pythonhosted.org/packages/99/f6/0025cde35ff7f607684dc775a2b2d732cce2561cec050a6ee1fb2e1fc6fe/librt-0.16.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:77c7a2b4fe2c1369e0d5aa1cade26740a7b14be32fbc9a5535d617d20065c39d", size = 79861, upload-time = "2026-09-29T00:53:26.089Z" }, + { url = "https://files.pythonhosted.org/packages/93/93/303b8592909bd583f83f02818ffbea3f7647ca1e22b1cd5465d04b8145fe/librt-0.16.0-cp313-cp313-win32.whl", hash = "sha256:02d89c813d5ff74b17df72d3a34819d132cd168e56b81bf755b809bd9e46b8c4", size = 106236, upload-time = "2026-09-29T00:53:27.43Z" }, + { url = "https://files.pythonhosted.org/packages/cf/24/80bbb463c60ed18e29cb26aba386ddb76609580e4e7160710e550587018c/librt-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:14ed6ebe3e4f85f326d7920011ad30ff49ed9334e62cf88caef9ba973d9e3a92", size = 126180, upload-time = "2026-09-29T00:53:28.7Z" }, + { url = "https://files.pythonhosted.org/packages/43/80/b1a6fbdd7da825cdd55c71aa81eb6cfa82c513c360774152eacb50b4a771/librt-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:83d4041a3d9b2fd053a8a4e1f22878b3e5833e2712956382d5c048d791454e91", size = 116557, upload-time = "2026-09-29T00:53:30.012Z" }, + { url = "https://files.pythonhosted.org/packages/1e/93/9e0cf7da129a93c3dc7f45bc3cd4a660f2aaa995aa8a6f95c2583ef41239/librt-0.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:931a0bb0fcac88f263e269e46eb30ba8e21402cd3c62ca40cb97034c0693fab1", size = 149844, upload-time = "2026-09-29T00:53:31.391Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/8a90d2a8f2b2e471bb486b7aec117b8ae622715ed6c39853aec48ea20073/librt-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1bc17e54e5305f8d40b7ca203671ff5a9e59c1d0f8ea0f625dcca53a3984de11", size = 154111, upload-time = "2026-09-29T00:53:32.718Z" }, + { url = "https://files.pythonhosted.org/packages/35/ce/67abb46258da4d3e42ff5b141db6f38c59357bef84c7979f183b22f924f1/librt-0.16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:877698bf6bca5721d8be345f2fe09778e40ecadea8b58c73075f2b1a53666bf2", size = 494234, upload-time = "2026-09-29T00:53:34.466Z" }, + { url = "https://files.pythonhosted.org/packages/12/f9/ea7162414a16f8f1bbd3b493ad6d22b926c5471916e078350bdbca8c4e5f/librt-0.16.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5981c011b306781ce561e18e14230a14524a3d8109b97553666c942c18f31a96", size = 491173, upload-time = "2026-09-29T00:53:36.124Z" }, + { url = "https://files.pythonhosted.org/packages/b1/09/9b3e869060dd33f9989b80ba4fea306f6db8cecefcdfe6d7346ef0603f65/librt-0.16.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:afced3dfc17cd805ecf7a3d77996a71cf5f2c75aa66eb0c21a9930f4fc992f86", size = 505547, upload-time = "2026-09-29T00:53:37.686Z" }, + { url = "https://files.pythonhosted.org/packages/50/07/79007d2165f649ea93e08c0962d1d62c70af9cde77965255095bf9d96f9a/librt-0.16.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca8052401c55d7511dda6760719fda7618067e83535d7d0010096d216c34b667", size = 523057, upload-time = "2026-09-29T00:53:39.301Z" }, + { url = "https://files.pythonhosted.org/packages/61/0c/8fbaff66d0ba376d8864653f5acce1569bae27e89648671f26f7eca67ab8/librt-0.16.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1e511762a074005bb0aa569166779834e75e438370226930d0ce1866d4b6a33b", size = 515158, upload-time = "2026-09-29T00:53:41.012Z" }, + { url = "https://files.pythonhosted.org/packages/df/2e/23ff0dece76f07a4124413a57682efa0bbeb5765ac0122bc0955513f82ca/librt-0.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1e8591bd8a5a628cd7f07954c6a1592359a878bf032957a8e9057a41d644311", size = 534183, upload-time = "2026-09-29T00:53:42.451Z" }, + { url = "https://files.pythonhosted.org/packages/26/c4/e11dea21d9a29486eba78887380374189d472734fa32cc50cb37ca44d3d0/librt-0.16.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4aaefb4ba6c07e1aeebb2795c8958148f1d6f9af3b555b53d23d766edb6d67a", size = 540663, upload-time = "2026-09-29T00:53:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/75/e873ae158a8b7f5359be33e7fd6c1fbe02d9a78a3e89a77de6b0e837f476/librt-0.16.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d92db7a0f6aee44f1baee94750457e8d2d1c6ccea41842de6268d34e8dc7eddd", size = 545908, upload-time = "2026-09-29T00:53:45.812Z" }, + { url = "https://files.pythonhosted.org/packages/ba/36/8939d3f6a93e11bd9592e6fe28d2b44f1c2dc4bed6e22e72359a91e18ffb/librt-0.16.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:378dfaffb38e59c24a87cde5713cd865d51ff7383fa12947f3907f306ea1ca55", size = 523302, upload-time = "2026-09-29T00:53:47.596Z" }, + { url = "https://files.pythonhosted.org/packages/71/14/35309f44a077f0f42ade0e2e7cd88c0cea760c661af205c01ea90d3c0e1f/librt-0.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3e0c39bdc85370422e8b637be76eb1fd07d30967551b03e62267dd156f553152", size = 565911, upload-time = "2026-09-29T00:53:49.278Z" }, + { url = "https://files.pythonhosted.org/packages/78/0c/df6255b94967f3159ebc46f08d8e783c12da6ee269ddb74b2efed63c640c/librt-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:1b384b90ab79a7bc30b566895809a636e0666f21f3cf12b54823d025b7e83839", size = 79119, upload-time = "2026-09-29T00:53:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/6b/44/d30d5a5461378c9d33f36736c6791c3b4c4ba4b1ffe0da7350aedcb2c9a0/librt-0.16.0-cp314-cp314-win32.whl", hash = "sha256:52327da75a94012e7f932f913d20d3876bed3c102be00e6c3e8600ff7bdd58a7", size = 100140, upload-time = "2026-09-29T00:53:52.205Z" }, + { url = "https://files.pythonhosted.org/packages/c2/98/769712f356a1e897df3581bb0c3100375d054a000de26099360ea65b5111/librt-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:3f0b8114c44b2ac06ff5dacd08e07e8e807ff4f46083f2a1602685122559be41", size = 120464, upload-time = "2026-09-29T00:53:53.495Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d3/ae2abccc8bdc8b063c1613a77686e17b74e9d3d60cfe6f12c63fa2821b9f/librt-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:8caf96a4ef8fb27d0ac0d1ad8337d26a240acd4a02fe4345d0a8f264753e8f99", size = 110889, upload-time = "2026-09-29T00:53:54.817Z" }, + { url = "https://files.pythonhosted.org/packages/e4/26/0737d4be058dd6376eade7dd8b380d4b869b6394cee929393a5a431c45bb/librt-0.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:953107e2f68d0f3512c48f898b0dbf0ce5cc52bba0f318d847c985dc555ee4cc", size = 159838, upload-time = "2026-09-29T00:53:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/01/96/9bc96531d7c620e9949af904470f02e3fa8f35129ab8e8f281c51eaa3788/librt-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad37d5b9abd49c9a655dcda7ea52a8a752884062ef1ee71ae17c2f2a0f81fe6a", size = 162018, upload-time = "2026-09-29T00:53:57.532Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fa/b0289dcb186eb3f97221ba00da5f4bd3ba7fa5e99752d48aa8d336615334/librt-0.16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c4aa329c17bd1aaea4f6e89335d8ccd494b3a5830b6654462273e50e11023f0", size = 703983, upload-time = "2026-09-29T00:53:59.024Z" }, + { url = "https://files.pythonhosted.org/packages/d9/ab/05ebbbde7530fc5eeb58fbd1581522a9f64f132fa703c4c595eed6e14760/librt-0.16.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0ead24d2562a49473dddd9efef8581f020007eb0054389c3ee3ffad38b1ca4c9", size = 683847, upload-time = "2026-09-29T00:54:00.671Z" }, + { url = "https://files.pythonhosted.org/packages/a3/75/f52aeecd4dbadbddf80725ba7de126d8bd5d0eb66247eae17a81ed90dd4d/librt-0.16.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02118f56a9c36ddd07dfd9b919d9ecc117ba20a90987d56aa4c429fa34509188", size = 698045, upload-time = "2026-09-29T00:54:02.247Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/fa277a835cd6eb42380591ceb85f48c5b4d2b2d7e2cb9869e1e17d24d237/librt-0.16.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e29522c62e28595ff7e324c6834ade51127707f0e255b18d1c1cf03d39c1048", size = 724640, upload-time = "2026-09-29T00:54:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/25/e4/2cf64354f3fde8ebd591b3b48f96510bee24ac5f7d1e567adbbe210abdbd/librt-0.16.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ff4b2367926b69c6215635902cccb04048e73094e9862900d27cb2c6bbff143", size = 731748, upload-time = "2026-09-29T00:54:05.741Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c9/c180af3e94e01aa529fa93d7733fec2abc47f222345400cf21d5481d5f8a/librt-0.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c6f1b27bf1632a7e016af9f145f82be95e1edd7721a646505c21059257cb5a04", size = 754960, upload-time = "2026-09-29T00:54:07.38Z" }, + { url = "https://files.pythonhosted.org/packages/95/d6/01073aa78c58f356b10d9c57b3fe9abb143df338142bcd316df417b89db0/librt-0.16.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5696d7f52e7b37217cb3a8f92c744fe835942602fdd4c1a8bc4741d3bfdce15e", size = 747000, upload-time = "2026-09-29T00:54:09.06Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d8/1de3783908658d697a8cfc00582f61299ffba7796d7260c112e4700b1109/librt-0.16.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:6072e92dd876ff6ceeb6cf371e35e51f479349837391341f479b08df4564242b", size = 749569, upload-time = "2026-09-29T00:54:10.702Z" }, + { url = "https://files.pythonhosted.org/packages/b7/32/e817f66c96d6caa8bb8435ff93c4c220624d59efc506be59ab98fcd01d0c/librt-0.16.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:39ca4f2f2fe05de8e63493da592d84311adabe5bef52b193851981da9816b302", size = 729483, upload-time = "2026-09-29T00:54:12.25Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c9/23992ccd2b9d22798fdd0f61353183a47414e651da782ad83eb680f50833/librt-0.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9807485a908f00355820f18e91e045ffdcdc5adb68aaec40a1e2b88c5f7bba1", size = 776584, upload-time = "2026-09-29T00:54:13.837Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/e8af957f08e6e2e8e566b099e43d2748418aa565df6ee1d9d3331209fdfe/librt-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:94be5cb7bca4df6201f4183e9e4fa2086c655283d20b38cd84500a69057575a7", size = 104402, upload-time = "2026-09-29T00:54:15.568Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/946e6443d7cd32347a086043395e421e52fd603c9163d2ec970ceab8eed6/librt-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d46ca272b251d033dd4527b0dec5f261a28a52bd5fa0f99c117b0a1f8588cc2d", size = 126227, upload-time = "2026-09-29T00:54:17.165Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a3/bc4f9959d3c62bcbf9e5fd3470a8bbd8bf33224ed4c25d7fba173201b8ac/librt-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b9d6d4b14e92d876f8026b54c20c445f36425214c1081dc76f74e40db386b82b", size = 116493, upload-time = "2026-09-29T00:54:18.567Z" }, + { url = "https://files.pythonhosted.org/packages/b6/4b/10fdb42dfab4c1533e1570e686b18e86ff4328b406361b39fb3016667638/librt-0.16.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:6fe436af2eaf630474f491af5d032cbe45f93fcff5c3b9fe4ab194a7255b20ff", size = 149829, upload-time = "2026-09-29T00:54:19.933Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/a90ca13f1d3d91af1680000a4038536907018fbd51764767287a05d28b8b/librt-0.16.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:8ff5d26c529336be9bd7ae04483235d77778ee7d6444a95353102b542601ce81", size = 154522, upload-time = "2026-09-29T00:54:21.306Z" }, + { url = "https://files.pythonhosted.org/packages/5c/dd/bcf364eacfa070bb1fc88d503111ae7177914197ba1fa717c748926e7930/librt-0.16.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:909d8e3c1faee44cb762b1c519ff8613dcc5ceae5c99987a00917b5a31fd1d6a", size = 497593, upload-time = "2026-09-29T00:54:22.798Z" }, + { url = "https://files.pythonhosted.org/packages/18/c1/2c4e81e347bdabfe8346bfc6dc37ae5e154d61c88e30848ec0606025a5e0/librt-0.16.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:6d4a64283ee61824b5790de882bc68e2d9d7a5143537cb7a966f7354f71646d4", size = 480459, upload-time = "2026-09-29T00:54:24.364Z" }, + { url = "https://files.pythonhosted.org/packages/ab/d7/fef2a3cb8400701be496f6e459876f650b3451f407e30cb243de571ae615/librt-0.16.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5810ba811297fdf37a1531a57667cb8ace0842013ca8606bf9eb7c24cf4be154", size = 507814, upload-time = "2026-09-29T00:54:25.997Z" }, + { url = "https://files.pythonhosted.org/packages/e1/6f/53762927a32e9dc9eb1d1c1f3528da281290c86d96929a8af652f671266e/librt-0.16.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e56aaf8c167548dc8e5d6f3bd0f48dcdd299a23c73be3f744aab79d99e9c7f5d", size = 525114, upload-time = "2026-09-29T00:54:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/6c/67/0b9d031f303c4e8c691a9a8ef9d272f13b530c11cc563b819df621b6a348/librt-0.16.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f36c58e33b304b525c6c9c5076399c6ebf1109e17b9051a05a407b091b9215b", size = 520465, upload-time = "2026-09-29T00:54:29.331Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/2056a3a85864e882eb17a203a10ddb26fa748bc9718ec67e79059ab46cae/librt-0.16.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:242e00b3d4fa37c3d3c1ca5f5c9adb7d909ddb1eac9c41f2787320d00caa0af2", size = 537257, upload-time = "2026-09-29T00:54:30.915Z" }, + { url = "https://files.pythonhosted.org/packages/54/57/e0d79790c163cbc0909e209a6f62e30bb713b647f905a176cdc64848fd4d/librt-0.16.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:0253721561787b8df8443eb347b7a6461015354e5bdd37ee38a41fef220d2bb0", size = 527442, upload-time = "2026-09-29T00:54:32.478Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/1c0c95aba7af51f4752ea34fbf2eb79b36e7cae3a72536248e8c735de735/librt-0.16.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:3e483a8d69ede8067db70c0e83007423b6925de6fd53afed01d66160f2e9398c", size = 548139, upload-time = "2026-09-29T00:54:34.183Z" }, + { url = "https://files.pythonhosted.org/packages/98/91/a8a43dd5138d4f55f88846b8f0c85454a4fad69952cebfcff9948f831290/librt-0.16.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:69ba927445cfaaffb4081003ef5224c55a5c2ab67ef956f416ef744916e44121", size = 529708, upload-time = "2026-09-29T00:54:35.761Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/d5226881ab2b7c20d9d587b37bdd4a0ec8775a96d00ca87ac9f385587db4/librt-0.16.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:d6a365f2ab45a984d0e00eee0dd17f599ceab8cadab6ea07b6111c8132fc0e42", size = 567781, upload-time = "2026-09-29T00:54:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/bb/bf/2345ba57a626e8c78c4ddcc724636a8df5a593fe46c1ee76bbf477e32b0d/librt-0.16.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:f01f3805f2dae4781c0c34b440e31740d082950bdaf89a6f601ad589a28af57a", size = 79177, upload-time = "2026-09-29T00:54:38.792Z" }, + { url = "https://files.pythonhosted.org/packages/1e/40/99e77936cc9207f629b077bf7cbc5e2f0827cddd7c29792f04ef881bd2c3/librt-0.16.0-cp315-cp315-win32.whl", hash = "sha256:b0e3e721c75d2e79a76d4422c79d7ba705fe1bbafec907037fe7a657a480a0e3", size = 100127, upload-time = "2026-09-29T00:54:40.251Z" }, + { url = "https://files.pythonhosted.org/packages/56/1e/801fe26bc622061b9dfd010e166d94142cb774b733217e6d98d1c0cf2638/librt-0.16.0-cp315-cp315-win_amd64.whl", hash = "sha256:bc02954b1295de798bbdb0b4e2d8a28c2117de8b5c73dcbeb27dc32572dfb971", size = 120480, upload-time = "2026-09-29T00:54:41.722Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a9/d533983055bd36e112627384c2c038845d1df882540b8bcefb566475640b/librt-0.16.0-cp315-cp315-win_arm64.whl", hash = "sha256:c5db585d43449a5f54303d4b2774e45e1babd975cfe1630a3d708c0b80c3e560", size = 110915, upload-time = "2026-09-29T00:54:43.052Z" }, + { url = "https://files.pythonhosted.org/packages/55/fe/d62238fa9c653b0e0613290349467cb65711b5800e8e474f45e942a0ad95/librt-0.16.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f06c689cb14afd9b612727553a5ec5a40febf113ca41c4413a2b0b334285884b", size = 159714, upload-time = "2026-09-29T00:54:44.497Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ac/f31efe7818700be72ba4f9af8a80fa67c39808c8d26dacc55dc1f6f35172/librt-0.16.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:13b4e8aba90b0b1c82474e9844aa9ffe7ad3faa484350e1da64cb8188d903134", size = 162086, upload-time = "2026-09-29T00:54:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/d9/16/4d7487bf86a9d7e8e18f37ba5538789233ea693637379b75701bd35ec9de/librt-0.16.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a269c46ae327d8e6f8c1f85f7516cb52c0fa48127565a1105a4f4a05ff2a0b4", size = 712727, upload-time = "2026-09-29T00:54:47.486Z" }, + { url = "https://files.pythonhosted.org/packages/77/74/50cd550ccc1a517b9ed62347625c01ddf8c4afad66490312677c011458f6/librt-0.16.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a33e0dae1f8592146a4764d54ce842b278732d21a84e17c3bbe6b1bc158a2248", size = 681106, upload-time = "2026-09-29T00:54:49.126Z" }, + { url = "https://files.pythonhosted.org/packages/df/5d/7293f712975ee6fdd2251411fd9ef1c62bc99c7b83ecbe62dbc999b1fab7/librt-0.16.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:47ada6ea32636492c61aa8ad27ae3b9404bfe7a97e3ba946d1984236cc741da0", size = 705921, upload-time = "2026-09-29T00:54:50.905Z" }, + { url = "https://files.pythonhosted.org/packages/67/f7/8aab946f11d59d1bece9ffc65d994b200c789a8ca5a95c17e17e609f912a/librt-0.16.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43bd6e642d8a248c114327f98dd25ac5a7cb5aa168ef02f0559b91874df16b8", size = 731786, upload-time = "2026-09-29T00:54:52.584Z" }, + { url = "https://files.pythonhosted.org/packages/43/76/1c42ab31e7cb8384ebf6d3af607213c495222474f4940443ae7639ab7685/librt-0.16.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c3d1bb7841a816ace6449bb26d3f9560dbfa20e71c568d23f0f62bf1e68f50b1", size = 744964, upload-time = "2026-09-29T00:54:54.217Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2e/4b19982d933d2dfced671e840b219e4c1cd3f507df6e6dabb51bbd4e3850/librt-0.16.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3931f7a3db322e7f44e02a280e3949326ce9579ad388ee8d691dc7c76da9fb70", size = 765305, upload-time = "2026-09-29T00:54:55.815Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1b/e872583de2dcb3ac7746e7a2321aeeb168274f3952a87dc66e05ddb29faf/librt-0.16.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f4462528b6000afe8f16907b5c7c2553abf1df005ba5140e6eb394541c3624c3", size = 744959, upload-time = "2026-09-29T00:54:57.532Z" }, + { url = "https://files.pythonhosted.org/packages/10/de/a18c6bcfb297af2674233e90b3c3661c3a0af0f1434a0c966d2ef708a835/librt-0.16.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:80039ba9b6a7d5f1a0175a4cca6bbefead87bd854c80abad1cb30afe47a830db", size = 758138, upload-time = "2026-09-29T00:54:59.484Z" }, + { url = "https://files.pythonhosted.org/packages/22/1c/0df1d732539c297bb1a093e1fe3204d2faf76a9022cf4e1d1c2fce059790/librt-0.16.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:7a1d272724b581bb6bc769dfdafed6da2ecc9886ba2450311de55a4ac2e1e9cd", size = 744268, upload-time = "2026-09-29T00:55:01.179Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f7/ccaf31331f20c91a5bd9bd48ffc3f743f9c81bb5720cc7b2a720ca204f0a/librt-0.16.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:fbe4fb8c5445f7496d7f7f6bb0807875d09d47e6771ffa175fb2df2895fb86ba", size = 785783, upload-time = "2026-09-29T00:55:02.938Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ea/7421d9e6db894cd6cd788b604b3394a163ded6b942052b6f96c23ccf08e1/librt-0.16.0-cp315-cp315t-win32.whl", hash = "sha256:375bfe6b572a8f6cfc398709356046173bf27e64c4c5edaf5f7062f051fb4bf9", size = 104355, upload-time = "2026-09-29T00:55:04.533Z" }, + { url = "https://files.pythonhosted.org/packages/85/6d/7c31a506eb847bc58aeb2402d58e17605e821f68ab5df5e66fe274a6df41/librt-0.16.0-cp315-cp315t-win_amd64.whl", hash = "sha256:bd3150023d3dc2bc70f3784e59ffa1140d56ddba3d8125b3d6f9f85221279bfc", size = 126134, upload-time = "2026-09-29T00:55:06.017Z" }, + { url = "https://files.pythonhosted.org/packages/36/69/7a5d10ac409c4da0355e054a14371871da9b5557fcc42772cd00181c6cce/librt-0.16.0-cp315-cp315t-win_arm64.whl", hash = "sha256:8ceafb70f2a4f0826f11031942e59c0728fd98da112dc346d4352bde1e486866", size = 116391, upload-time = "2026-09-29T00:55:07.484Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz", hash = "sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6", size = 153777, upload-time = "2026-10-02T23:07:22.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/55/18dbb4778b30ada5ce071608503cc3edc9e14e13d868c17a6d178fc30f7a/markupsafe-3.0.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9e25feb9e330b63edb0278a0acdf85e50d0cb0fbf49c3084abbe4e24ae195346", size = 11520, upload-time = "2026-10-02T23:04:36.299Z" }, + { url = "https://files.pythonhosted.org/packages/6c/14/0b05f79b4733e264a18d08fe08fa1df7347630ff32a6cb82180d9dccec55/markupsafe-3.0.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7d3391b2188d18737cb2fa147028b1096236eaa7e156446c650a489fa2cadc91", size = 12063, upload-time = "2026-10-02T23:04:37.291Z" }, + { url = "https://files.pythonhosted.org/packages/ca/3a/63ba10b6c1463216b3e4df669a9f0e5a3b0c3071557d2e8229e3968c79fb/markupsafe-3.0.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849dd2bb0e5e4ab2b71c7191726a4a8d5aa8a610daa584728cbee0b710ddc4ef", size = 24321, upload-time = "2026-10-02T23:04:38.262Z" }, + { url = "https://files.pythonhosted.org/packages/1a/2e/5f015261b76ad633d187ef6f388b413aedd64a8773c4df59e530a0be5525/markupsafe-3.0.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:befb4158af32106b9a93db8d6d1d1cbbd418c0d5aca0cabb7b1780abf0c89169", size = 28845, upload-time = "2026-10-02T23:04:39.295Z" }, + { url = "https://files.pythonhosted.org/packages/33/cf/26e594b26be40c2f1fec63ccf8a8b99d0335a5b2cbe84835c7d82a994375/markupsafe-3.0.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71f88e749ea29f67f21f3b36433c1dc54c7729ed2a6d9e2da2e0d9e0d7b224eb", size = 24701, upload-time = "2026-10-02T23:04:40.348Z" }, + { url = "https://files.pythonhosted.org/packages/81/a5/a513b76c139a3915b43404324e55c0b7979ae4f0d39eb6f075b0282e90a8/markupsafe-3.0.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6da83a088f8ef93b2d483a8232a4dbf4d69d3d8496b568a03c56becac43e1808", size = 22961, upload-time = "2026-10-02T23:04:41.372Z" }, + { url = "https://files.pythonhosted.org/packages/46/cf/4c66192c100b4542bcbe392ae06696b670f66927be3ac38a213234778ff9/markupsafe-3.0.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f0fac8b13d14bb06c68195f849371924ae53dd7b1c00fed24650f704383b692", size = 21920, upload-time = "2026-10-02T23:04:42.429Z" }, + { url = "https://files.pythonhosted.org/packages/cb/17/ac3662678bfbad649893117ada2ba44dc30bf56884e84f13154a792b10f1/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4a7cdc2a420ca01058182da4253329764d4bfa055564d1eced90e6ba1e8b1d3d", size = 23726, upload-time = "2026-10-02T23:04:43.531Z" }, + { url = "https://files.pythonhosted.org/packages/f7/af/fe47cee339180a69ebca3c57fb3483d0f5cbd1e8337d1871fb1d9c1aebee/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:83b3944fea42a8400edf92fd1770fb8d0d4f7de651353bd2d8525a92dba69a21", size = 22539, upload-time = "2026-10-02T23:04:44.618Z" }, + { url = "https://files.pythonhosted.org/packages/12/32/d55440ba140442800e02d799c9cb5ab597bf6ebdb1177b5ea39a11f797bd/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:8138eb83940ec7299024d92d4dee45f601b9e6c5ffde9d25f4e35e326203c707", size = 23963, upload-time = "2026-10-02T23:04:45.656Z" }, + { url = "https://files.pythonhosted.org/packages/50/9d/9c86042cb364c2ad4c971e6d1247929effd25f714cd7ee11b05e6316445b/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:811d02d5122171c1941357efd8f9bf4ffe907b7f0a1a4e729a880e4be3f46e3e", size = 21506, upload-time = "2026-10-02T23:04:46.671Z" }, + { url = "https://files.pythonhosted.org/packages/75/ef/5b824f03ba40c3b3652b6272d083d2fc4fcdd440de519a3a39ba2c3e7262/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:50b5bedc9ed8a94fc8857a42ef4f84a81ea88f8d4f05dc8705fb23ee6d8dcca7", size = 22955, upload-time = "2026-10-02T23:04:47.698Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e8/44cfcb5ea40e5e43cec7793ef90704ed0c475280839076c4345757eb8e59/markupsafe-3.0.4-cp311-cp311-win32.whl", hash = "sha256:2e5a7cd7fdd14fcb1ae5d7d8bf23d24fbd1daefd1fbca2580132e1ea75f098b5", size = 14094, upload-time = "2026-10-02T23:04:48.788Z" }, + { url = "https://files.pythonhosted.org/packages/91/89/f2b509f7bf79352e40117824c1070dbeafd4df67031d3fa98165a3134228/markupsafe-3.0.4-cp311-cp311-win_amd64.whl", hash = "sha256:fdb4ca07ab75ffadab4a8b135ad59cdbb3156b99310f3d565370da74a15d6bd3", size = 14328, upload-time = "2026-10-02T23:04:49.801Z" }, + { url = "https://files.pythonhosted.org/packages/2b/5a/ccf22672a0f64dc682306e288f0dabcb06c7a201f3bb6e6cbf86d9e8ad03/markupsafe-3.0.4-cp311-cp311-win_arm64.whl", hash = "sha256:569d65055d367e3dcdf30c3f41119467b73d9ee9faf332bdf40402644f5ac08e", size = 14188, upload-time = "2026-10-02T23:04:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/81/09/4c59d56b8461ae8eb0d8ba34bb25b7e618547044679d58a82ef9b2479fc1/markupsafe-3.0.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61631e08084be9e21a8967ec3139c7616ed7c5e9368e05c86d1b39562c8a57b6", size = 11658, upload-time = "2026-10-02T23:04:51.876Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f0/d6613774d86fbf6d145751d43c59875e47a6f9f17daee0aef173bd36d90e/markupsafe-3.0.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0930db9bdc62d22944e10b066448bb65dc9abe9112880c7cab8da54db4284d5f", size = 12050, upload-time = "2026-10-02T23:04:52.931Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f2/8f18e0b806eb13c1f8d07d917a720831ead54253a6dec011fbc78098a6f8/markupsafe-3.0.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a45c3d514f2436064db00d7fc8778d888f0236ebfed649b53d13a59e69ad51b", size = 24363, upload-time = "2026-10-02T23:04:53.895Z" }, + { url = "https://files.pythonhosted.org/packages/60/ce/fa07dbe8a5675558fa36dea033e19995bc783de2dec5f540ccb9030b06aa/markupsafe-3.0.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1e1451fab512d1bcc3dc26988ec1edb0b82c2db909132872cd9356070a6b63df", size = 28525, upload-time = "2026-10-02T23:04:54.905Z" }, + { url = "https://files.pythonhosted.org/packages/85/40/be87c01f3868ec217f8a2015089d71c22c8c5a75324822e5ed1cdd87210d/markupsafe-3.0.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bd3ce56ae2cbae3ba82b683bc425cd7e48d2ed8b10f3e818186b6f5646d9271c", size = 24733, upload-time = "2026-10-02T23:04:56.229Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a7/aeedb5140afa41fc74c225e9184ab96723a6e873b6ee1c9fede7283456d8/markupsafe-3.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e124f974786f831d6043728e38296969d3579db8896fe004682f5758e613581", size = 22985, upload-time = "2026-10-02T23:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fc/e91352bb08c6a59da3ef0909d457bf95a5f5908fbf151b30a06d9dbcfbb4/markupsafe-3.0.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c02e8f18bdedba082cef725942ac823b9b60656db07f7e265cb31618dfd00d77", size = 22001, upload-time = "2026-10-02T23:04:58.597Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f8/bffee5e7d2a3deb59748a797650a48af7e672025cf641a79344a771ad106/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f098115c247e11d138ab83a28fa0323c77015007ea2df73ba5fd714dfefd67c", size = 23793, upload-time = "2026-10-02T23:04:59.686Z" }, + { url = "https://files.pythonhosted.org/packages/ed/59/b853d6628ecb4d658e1d637224846d5e9bb4adf4f8df97f3be9f29dce2ec/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d5f93ebbeb8032d47e349328ec8662d973d9b05a70b3c35df1f91fe419b84749", size = 22640, upload-time = "2026-10-02T23:05:00.768Z" }, + { url = "https://files.pythonhosted.org/packages/09/b2/1506df394f0f075797c418d0301498f49e43be194e3ffcb49e6fe6ccf022/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:64511c54db4e4987aef4c41923235927428729e8174c5dba488429be70a998ed", size = 24074, upload-time = "2026-10-02T23:05:01.813Z" }, + { url = "https://files.pythonhosted.org/packages/c7/81/5ed69cda630ac69ef60d06c09ba5a7f84ff66a2e28cf986fd5614ab3c6e6/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e1a622f13970d81f95d0c72f9dc090dce9085fccfa4c9f2174377ee32bd15786", size = 21563, upload-time = "2026-10-02T23:05:03.239Z" }, + { url = "https://files.pythonhosted.org/packages/0c/fe/fb1e79be0fea60aa32602ebefc9c35a82bb42b4df157285ab7dfec12341a/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9a7f43c0b202b334cc9184af09bb8f21d3a209e038efaf106936fb69e6b026e", size = 23048, upload-time = "2026-10-02T23:05:04.479Z" }, + { url = "https://files.pythonhosted.org/packages/c8/52/7632a53360671a9b750cdbabaf9cdd89f18b42248b8e4cb42c0b0296e459/markupsafe-3.0.4-cp312-cp312-win32.whl", hash = "sha256:f0ec3b750b59375eab5b0fb2b9254810c00a3375be6d789899f1055a1d556237", size = 14108, upload-time = "2026-10-02T23:05:05.513Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bf/62495e180b7000aaf30000fff849e933f74264638057176cf46852500adc/markupsafe-3.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:11935df9bf455ed0c04eb87bcd720f02b1fe5e02128a9430f23aed6f93336fc7", size = 14303, upload-time = "2026-10-02T23:05:06.538Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8e/4c24208776a65878d656996945aacfbfe010d3720d1a98fc0eb8491fc03b/markupsafe-3.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9", size = 14174, upload-time = "2026-10-02T23:05:07.617Z" }, + { url = "https://files.pythonhosted.org/packages/6d/18/4bc5ba32499e87bb2b0ef5b3a9bb9c00a131fa961ddf0be548cb550f548b/markupsafe-3.0.4-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:de8b364c423ef0a4bad9069657d617f9a5d2b2062457a89b1fa16ee199c399c1", size = 12786, upload-time = "2026-10-02T23:05:08.709Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6f/17f0c099bf25f3e31e63cc19244d9f6af861a9a4ab778c203997903cfdd0/markupsafe-3.0.4-cp313-cp313-android_24_x86_64.whl", hash = "sha256:34bdde374c5932765d7dc685c4a1d191a3207852d67e8e0a9eb6ea85156181f1", size = 12510, upload-time = "2026-10-02T23:05:09.93Z" }, + { url = "https://files.pythonhosted.org/packages/11/af/1a141081b905036ee904ec4bd945e1f70b4e1b32d33c4e59e8cf1d58b247/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:6bd9e1788e15bfcf6a9082de42e30387e7b85d211ab21e57a939bb8cfaaf8d96", size = 11583, upload-time = "2026-10-02T23:05:10.884Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0a/a89385ae590232622a03e091805cff12f24fabe6c11e0e8bae096cece81c/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5066b244f576f91afc8ee3ba029a89f99d39c79b1853fe9d39bea9f0afbec148", size = 11801, upload-time = "2026-10-02T23:05:11.913Z" }, + { url = "https://files.pythonhosted.org/packages/ed/85/ea548dc013962eb73653124bc595635fbf9e0fa41d1f181a967ccb784dfb/markupsafe-3.0.4-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7a83aa6e4805df46fed18e989d3d16f86ef60cb50bbc8d9ce3a6be89165fbf6e", size = 11443, upload-time = "2026-10-02T23:05:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/cc/72/15f2e5ec9cf2eb00d5cdfe968d94e4156a7bd7303832c3f3b2c403a36839/markupsafe-3.0.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2d1b7d9308288661f56672b1b157d75fc536714d3638487bbea17b6318a78248", size = 11665, upload-time = "2026-10-02T23:05:13.829Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e0/4030bea613677e333c8a2c901fd405055f657f9d06acba5b7357984b6ef7/markupsafe-3.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73e77980c7207854f00fc4e71fb1626868d5740ab4012623d55c7a99ad122a72", size = 12061, upload-time = "2026-10-02T23:05:14.807Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a5/28b76a7449eb702966b88bef599e2360b411fbb3afeee8fe560939be06ec/markupsafe-3.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7018d4af1cd272e847aa5917983ab5e83e4f6579f9dbfecd4a79c0ca80b144c2", size = 24395, upload-time = "2026-10-02T23:05:15.909Z" }, + { url = "https://files.pythonhosted.org/packages/07/6c/21232811afc3a063b5e934b1ae2efda52f46154ec382f585149c020e61fe/markupsafe-3.0.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c90d5b3d4e944e065a301d741b3c1d784f6bd1f503aa68b4967e32b2ba313d85", size = 27183, upload-time = "2026-10-02T23:05:16.976Z" }, + { url = "https://files.pythonhosted.org/packages/14/38/6ccdfa5b59049cb36fb80cbc80aee9cf1fc9bb77d1335ad435f2070b08cf/markupsafe-3.0.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18a801868a884f216e784d7d14db2a4077143ce7610440aee2ce8f734e7cfcde", size = 24772, upload-time = "2026-10-02T23:05:18.209Z" }, + { url = "https://files.pythonhosted.org/packages/63/e0/cec6865dfe88cb48fedd4b20aed6af5158e41092adcbf3e028bcc6ec2108/markupsafe-3.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:434139499bb20b502ed3baa1f169e618f924a97e7a777fea1a49446d80106cf6", size = 23017, upload-time = "2026-10-02T23:05:19.286Z" }, + { url = "https://files.pythonhosted.org/packages/ee/76/6ed4940bb7648a9aac457c14f870cfdd5105f139a0fb1f29cd61fafa47d1/markupsafe-3.0.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9e227f3dbe6bde7491cf0a9965d00b88c6b1a4a95d11480ddf88bb96d397c19f", size = 22036, upload-time = "2026-10-02T23:05:20.352Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4f/ed476226d4fe46a09090a36025bf319296810028df55eb12f1253b540f3a/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b8cd1f918b26fd7b1832ece557cc18f2d8747309ff8b3f0ef9d4250c5ad67a39", size = 23786, upload-time = "2026-10-02T23:05:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/9a/35/66ff30450e35ef5fba9ebc930c9411747e537fd9447b65e44f5007e2b84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a5fcffb37e602b0b3c1638a97746b9b96125caa9bcf6fa41d337a9261de231ee", size = 22672, upload-time = "2026-10-02T23:05:22.922Z" }, + { url = "https://files.pythonhosted.org/packages/32/0b/72f45ce4b4efcbca4b80cf1b06703eff0be8d37e82abb78f66c85a7ead1e/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5989cb26b2e1efc6a42216a9f6b5ee495ce5ace2e5b352a9af489976b32d1ee2", size = 24107, upload-time = "2026-10-02T23:05:24.175Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/71776e5fdcba04614b384cc102e8a4198208579d896fd1394cb7cb9aa900/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:add96447a86d205ab616665d53b2950ee81083757f56e6ea833c8b2917646b46", size = 21615, upload-time = "2026-10-02T23:05:25.215Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/801ce02a02e7aee0f784b1ec7843026178f6adeb9c93ac67eb1992a9a84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2628d3a8cb648ecebb3c5d6b0a1052d400e4d8b7ac0fb786be8d285b50040d17", size = 23076, upload-time = "2026-10-02T23:05:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/4a/85/c43776625428f3bb4a61e8633940400e3efe6409e3c6f5bff26de5e45618/markupsafe-3.0.4-cp313-cp313-win32.whl", hash = "sha256:672d207103e6b16ca098611b0f9efad6bc00afd47c03d6ef62186495ca677dc0", size = 14118, upload-time = "2026-10-02T23:05:27.716Z" }, + { url = "https://files.pythonhosted.org/packages/6f/36/163da64de88a13db79214ef75fa041be7fa13bdb42261cf5b7484de14bfb/markupsafe-3.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:1f1f9477e174582b0a1b583d60b66e1f2cf5d3fe12cee985e4aedf44766600e5", size = 14313, upload-time = "2026-10-02T23:05:28.749Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a8/9b662783ffaa1149221432a923cee562f78b9cbbb8baa3df9b3753e63e1e/markupsafe-3.0.4-cp313-cp313-win_arm64.whl", hash = "sha256:06de8ef6331f6e822c28d577dc8bf43fe398800477c49498f38fc38b67ff33fc", size = 14189, upload-time = "2026-10-02T23:05:29.917Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c3/a944f3b0df22bd129e96915b9f4e98d2eeca6516687d7618304a966c3c74/markupsafe-3.0.4-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:4ed644d75aa94a2baf7ec3a96eaa160ea58c742eb9d27c6506053c5c40fc84ed", size = 12787, upload-time = "2026-10-02T23:05:30.971Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d6/a44863f69d88b6c7e27889108f70d47aed259edf89d5df3c5fca1eac87d6/markupsafe-3.0.4-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6d2a9efe686f9de00d0d1ea32a4a5a86d558a2277501bd78d964214eab625e59", size = 12525, upload-time = "2026-10-02T23:05:32.263Z" }, + { url = "https://files.pythonhosted.org/packages/17/8f/168ba80e532dd6a93f96f8f706f1ad41d7990b6e1aeedc1cc0d211a33497/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8781a792a070cf2bd1b86d3aa943894115faaba6e88122a7bf32d62072742453", size = 11589, upload-time = "2026-10-02T23:05:33.251Z" }, + { url = "https://files.pythonhosted.org/packages/32/b3/aa2c95a574d3af39403a469b295886eb9b6d448da568cbebb5a2cbfdc2e5/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:971a3bbb75d97ae4e2e8f7d4834236f86f85f0c85e04ab2e191db1123b04f80b", size = 11801, upload-time = "2026-10-02T23:05:34.315Z" }, + { url = "https://files.pythonhosted.org/packages/60/d0/34b810107d83840e768bf485de795893ebbae35b26ab061b487adfa0a692/markupsafe-3.0.4-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:8909c2f1c6dd65e054ac4b573a91c8384d1492281e55d82d159d653f7a13adf6", size = 11447, upload-time = "2026-10-02T23:05:35.302Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ab/2f8488f0f817a39fca068d2b17daf446bf5cdb3eae28c3720af534d873b4/markupsafe-3.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cf3468d5ec187ffffcaca8e61929a37448f215dafc1386a12c750a72fe53634", size = 11722, upload-time = "2026-10-02T23:05:36.363Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/e2d117b048d47282ade906fbfd92814cbee5647afc13fda88a3406039372/markupsafe-3.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:52704c5d36eb6dda8866493decd61111fff86244c9b1ad225ca01b9e91e5970f", size = 12061, upload-time = "2026-10-02T23:05:37.397Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a8/73a81135e85ba66217f5af7facb03bbb386807e1a729ab64532e4c802652/markupsafe-3.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1caa2fa5a6184fb233153b35f654e6687bd555476f6170f29d8ee9be1a8b0af9", size = 24434, upload-time = "2026-10-02T23:05:38.407Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ca/fa9216dd01efee2dfdacafe7df32b4d0170fbac694b0c258a193d6e53999/markupsafe-3.0.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:387d8cd30e69b3f0a72877b9ae717033396404e19095b17fe89753a981fda44f", size = 27183, upload-time = "2026-10-02T23:05:39.581Z" }, + { url = "https://files.pythonhosted.org/packages/fa/4e/a469509e538d37af51103b17b073126973f2b1cbf197ff32c7ddf025cfe5/markupsafe-3.0.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:051417f74bcaaefa316276e0ff723f541616ca51043d070da00249d9bddd3e3c", size = 24813, upload-time = "2026-10-02T23:05:40.671Z" }, + { url = "https://files.pythonhosted.org/packages/8f/db/d7282caf7ab03af44d5d6fdbaa019b35c7d7f1c90588b839c07cba640d6a/markupsafe-3.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8e9f292fcda89b324f2f5c91d13f1424a153e40fc2756f38ee23b15835ff300", size = 23049, upload-time = "2026-10-02T23:05:41.864Z" }, + { url = "https://files.pythonhosted.org/packages/30/f3/b6a425206e6964efda6acee544d0eb01d1501784d0b8e2dcc74986f33b17/markupsafe-3.0.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:df1ae86ff54725a01fa1a0510b914ca53a161b7050be74f6204e24aded5971d0", size = 22088, upload-time = "2026-10-02T23:05:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/ea/8a/84d3582fc1f0d5bd466cdf2eebf175e172158a6e70701aacec1de1b35430/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8965520ac587c94a4ac48b729be3d8b8de00af39699b17585dfb599babe77977", size = 23846, upload-time = "2026-10-02T23:05:44.098Z" }, + { url = "https://files.pythonhosted.org/packages/1c/65/db101cce51b7ba4864ac491a9859d297dd1adf0e55b103fee9db9c47c527/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:340cbb1957ba99929cbf19a75626d36ba1ae21d1730b287d1cf7f824a20c4fc7", size = 22674, upload-time = "2026-10-02T23:05:45.23Z" }, + { url = "https://files.pythonhosted.org/packages/e0/49/ddee9813d71db0c7a5c9d97c832125e6758a0c844777f1cf076569bb0e22/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3a93d9616ddecfb393727a0041a562cf0b15a244e20f2bd25efc7949be4c4f17", size = 24104, upload-time = "2026-10-02T23:05:46.398Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0e/7d8518d726726870a2399d69fd30d0fa36c5e57a2132c336b58d7c491073/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d2e56fd3b00222722abfb3f5f0759ddbae4b90811b5ad4343c64030ad1bde70c", size = 21637, upload-time = "2026-10-02T23:05:47.48Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b0/b505e8a361ba557dbf3b3aa7331ea39b00d2022a26e925ff8463b9714bb3/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0d9c47709875fdb321452056622e930c52afbc07a7d780762fbb8b4d91ce6fa4", size = 23081, upload-time = "2026-10-02T23:05:48.611Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/9cc3cea873f980c75cbdb6f4277ce30ee955de38be0b3d02f14c108e0698/markupsafe-3.0.4-cp314-cp314-win32.whl", hash = "sha256:38fc55594dab834470b6733dead2ee9e3f657fb0608c769dcafa0ba5ab52f45c", size = 14272, upload-time = "2026-10-02T23:05:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/80/f0/5792ff768a410f93ee3f84fc19345295ffc352d2c936b424cb37e514714c/markupsafe-3.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:c1bc67752d5f21013cfe430df4062441714eab79f65a6a05e01505957e9c35fe", size = 14501, upload-time = "2026-10-02T23:05:50.788Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cf/3d074a8edffcc6899355232ff2543ae8d929733239596423b7db79698bc9/markupsafe-3.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:7e1636da3d8dfc220b6dd10264db5f2b165e4888c4518594898fbe381049af8a", size = 14364, upload-time = "2026-10-02T23:05:51.857Z" }, + { url = "https://files.pythonhosted.org/packages/d9/31/87ce42159aae2163cf3bbbd0c44bc87780510eecab1ea3859099aed95dcb/markupsafe-3.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:805c8b84534fa10891890f0e4be39f3a99e94615d93e8836bf9fa1fdca2feeb2", size = 11759, upload-time = "2026-10-02T23:05:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/5f/53/b047207eeb7752e960aca3eb1df5fb7eefa7dd4c62ac49bb156456c8a702/markupsafe-3.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fa95848c929b6a75f6848d3c9793e59db365ee436776e57db835cdbfa79ba977", size = 12096, upload-time = "2026-10-02T23:05:54.066Z" }, + { url = "https://files.pythonhosted.org/packages/ee/51/4326c88a13c7b755657d44b4bb986f8c3d9843ecba7e22d98661d87f9a57/markupsafe-3.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e916035e3e9930cbdfdd10abf48861340221857f45509565898e012263f7b289", size = 25699, upload-time = "2026-10-02T23:05:55.15Z" }, + { url = "https://files.pythonhosted.org/packages/f2/bb/990581b7474bfcf2cf34bed6ba5ea23bd87adb9d671213d68e88620e7a6b/markupsafe-3.0.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4d12837e0203bbace818ff4a7461afdcd78bcd782351cea148139180d7bcffe", size = 27804, upload-time = "2026-10-02T23:05:56.29Z" }, + { url = "https://files.pythonhosted.org/packages/6b/89/89491878c28e8291f5aa2fffe2c2d57230d10ae366d55dd810b840513d78/markupsafe-3.0.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5086f9975abb1ab531ee6afca1761e4b59a19b446f3f6522ed776963228cfe5a", size = 25912, upload-time = "2026-10-02T23:05:57.416Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/680998b54efdea06fc114565cd739b6d059f826a0279219b218dfa750d29/markupsafe-3.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4a635a0487774f841cb1fb62e907e7195cc95bc761e053184b8acc3ceb20733", size = 23623, upload-time = "2026-10-02T23:05:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/ae/75/2709f5ac5de9467b40b10e2bb8f89cc63dfb74582e09aa734b1124a217de/markupsafe-3.0.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb96e6e088d6cf71c1ea977510948320234824cf226e32f6f6e044f7a9c82b34", size = 23301, upload-time = "2026-10-02T23:05:59.94Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c8/39eadc6c5b14c9c7679bfb98f4d4c6a97863b5beb91839aca4d2d6e16e55/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8b5d563170ff8ba3181caa967c99a3c804d1dedb702c7cb93a6a7c32247da978", size = 24950, upload-time = "2026-10-02T23:06:01.289Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5e/01037f8a43e8ccb0bffb4fbdc5212db05bf080fdd7286cd392332d58128a/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:396ec4e65cc889f69786b3b89478b471cee5a3bcf468b9d9bb03e1a30fb291fc", size = 23330, upload-time = "2026-10-02T23:06:02.441Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f4/23e83ce0596bb0cbe670502d31df8f757bbd01a392aa486fa3b40d1ed399/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:15ba9e28640feef770374b116a6f019c21f52404aeabe516aa7f800587b98cfc", size = 25239, upload-time = "2026-10-02T23:06:03.579Z" }, + { url = "https://files.pythonhosted.org/packages/88/5b/3708897368073cc683d524750474f41a77d2986152c380dcc55b20fdf340/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d920abdfa61279ba1a2ef9484aab07bf03331f8c08a10120fa332353d06e6932", size = 22767, upload-time = "2026-10-02T23:06:04.699Z" }, + { url = "https://files.pythonhosted.org/packages/c6/61/ebda1307864b409e6b3115757a3d4a09cca46cfb6cc65191b5de226b424b/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a9f54054101545a9a9cccefddf54316aa6e4491611fcbef9e91b3b6bebec04f6", size = 23668, upload-time = "2026-10-02T23:06:05.9Z" }, + { url = "https://files.pythonhosted.org/packages/09/15/98075cceac3b5ba0dbb8e4762a847be967d2befc349a2cf2d0ac77f62c9d/markupsafe-3.0.4-cp314-cp314t-win32.whl", hash = "sha256:12a606a492de952afcb43b59a14aaaaad120e708d3663dd0fdf2d738d427a691", size = 14301, upload-time = "2026-10-02T23:06:07.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a3/768b560fcc4156685cb563d922b217810cfa7bc135773367f62f1f9d2078/markupsafe-3.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a18f38cafc329bac5e3c2b96c765b4c96d3d103421ed22ab7988c1e3fce27464", size = 14526, upload-time = "2026-10-02T23:06:08.276Z" }, + { url = "https://files.pythonhosted.org/packages/93/63/da554b4c97a6b0ea3229ca7fe8cbfb620be81613d517f482e85958550537/markupsafe-3.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:eba154571c16e032112afac0dc2dfe9e63c2ceb7aedd07bb7eecf2ce26d4dd4c", size = 14421, upload-time = "2026-10-02T23:06:09.402Z" }, + { url = "https://files.pythonhosted.org/packages/a9/30/54d11c8ca027114898cab97421fb39e4ffd9ddf47cdbc44df2ec76722da9/markupsafe-3.0.4-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:737c9c3981998eba27f11786f84fddcbabc74068b72a4a1f454ea02094b57b65", size = 12791, upload-time = "2026-10-02T23:06:10.485Z" }, + { url = "https://files.pythonhosted.org/packages/10/6d/97c913e253a14bd3cd0e15a5c56d13203b823fa7ee32498342896a072dc4/markupsafe-3.0.4-cp315-cp315-android_24_x86_64.whl", hash = "sha256:489505b03f692c3f376394e49194fa7a7f9e8558d6e293a7056a0032b0c38163", size = 12527, upload-time = "2026-10-02T23:06:11.834Z" }, + { url = "https://files.pythonhosted.org/packages/26/f9/b86d032042a4d597d9e1997f0e5f63a3eedaf11258e0a05760b0a0a826ea/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:077293e425f28ec737dbcad442a71752e28f8ae27cde3d68acd1fb212091cd92", size = 11602, upload-time = "2026-10-02T23:06:13.122Z" }, + { url = "https://files.pythonhosted.org/packages/f2/dc/73c14c1eedf0ac5fa3292ba43435e6c49d2c2050f33cebde541f8f4807f1/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9348cbb300d224fe3b89793262cb093504d4ae927004468463f745188a193e4a", size = 11817, upload-time = "2026-10-02T23:06:14.227Z" }, + { url = "https://files.pythonhosted.org/packages/8f/69/2c2fcaa5fcee22d72c7819c0d536fd181c74a688e6143845419579cd2863/markupsafe-3.0.4-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:b807e598953730f82e4eae3bd30f6a122cf6b31c398c6b504c0e04c13c170429", size = 11446, upload-time = "2026-10-02T23:06:15.574Z" }, + { url = "https://files.pythonhosted.org/packages/88/54/9e5ec76c62e6e2834d5a93623018c943e8b3bb41d663e3fd4c03303b9b85/markupsafe-3.0.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:799c39bdf5e2f1292fedd3009f7b3c9e760f10b2420cb9638d56920840ff6db8", size = 11719, upload-time = "2026-10-02T23:06:16.701Z" }, + { url = "https://files.pythonhosted.org/packages/96/24/3ec292b44064c16229e064d770b2625bd8ea941aa61f44905a9fa44942c0/markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97", size = 12075, upload-time = "2026-10-02T23:06:17.855Z" }, + { url = "https://files.pythonhosted.org/packages/aa/85/b64fdb1f304848518742136983c24e96d967bfb59a0ea160e92736901ab0/markupsafe-3.0.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b", size = 24656, upload-time = "2026-10-02T23:06:18.963Z" }, + { url = "https://files.pythonhosted.org/packages/9c/18/23997d4c65b355da6390d61cd56e0ab3befd6ba8dda25cb40c602bd0fa6b/markupsafe-3.0.4-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3882fb412298575bae3b9c46868251f15cc69307359f87bb1b382e53d6e5a2c9", size = 27737, upload-time = "2026-10-02T23:06:20.117Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/35998dead3c6af88c38265a56e58100211f036234ab88eb2283fd4cbce44/markupsafe-3.0.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04e7902ba80ee4bac1d50a549606527a1dcf0476cd81403db41099d3b60ec653", size = 25003, upload-time = "2026-10-02T23:06:21.284Z" }, + { url = "https://files.pythonhosted.org/packages/82/96/ef49135ce260db4ca4a12b119ed468449cd248db6b1468e2112b546d7a2e/markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369", size = 23111, upload-time = "2026-10-02T23:06:22.524Z" }, + { url = "https://files.pythonhosted.org/packages/50/7d/83126e338bd88c17a220668235368ad719fd4638e426739858cbb8508f77/markupsafe-3.0.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f68edfc67aabac33708941f26f22a7b8e9f81429bc0cf249fcf7d66b23af8d19", size = 22403, upload-time = "2026-10-02T23:06:23.785Z" }, + { url = "https://files.pythonhosted.org/packages/83/dd/daf7e420de23c8206c365204e7b85e1251d8e19d34196a56336f316e5ed2/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e5c802729725bd07e2bc3ab7b76dc7e0bbfc53129d8f1eb1c002c24cf774717e", size = 24083, upload-time = "2026-10-02T23:06:25.037Z" }, + { url = "https://files.pythonhosted.org/packages/19/3c/11eecdc06bc44ad5570350085b572ebf049e8f9a38d1ece6d76640b739cd/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:55ffd6ce583d97dc71dc92e930324c8c0d25aea7e3ade6ae54ef77cedb096811", size = 22931, upload-time = "2026-10-02T23:06:26.328Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9e/ac0fd77f2a726e56ecc3ca0235d095feace1358d1b822406c2a2ef26a4dc/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:2cb3dd71fc6be918ad4264346a8ed69485f9b7ed7bf35495d8e22807cd6b8bea", size = 24299, upload-time = "2026-10-02T23:06:27.742Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/c6bd842ad58ff5b3bc76eeed7e9a42a6f11adc5d090ec697b72c9672731e/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:94f5407f7bc64fa6463906b896f9904beeeb7dd8dc116ee8e9056c8714ff9916", size = 21948, upload-time = "2026-10-02T23:06:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/a3/46/82f586711fed61e86faa1ee1bc317d68cd45a10c8bdbe3f7d1fdf9026ad8/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2dad610540cb2e6272855c178f08ae9a1c7ac258a7fb71660553a5f104b42741", size = 23150, upload-time = "2026-10-02T23:06:30.583Z" }, + { url = "https://files.pythonhosted.org/packages/19/2d/2dfdce99318abbfa26925195fbc17db188c46a1ec6457be121b6f9cfeb42/markupsafe-3.0.4-cp315-cp315-win32.whl", hash = "sha256:03470d1a8268e692ecf79ecd565593e59d44219377a7ead61f1f1b94c1f7ff6b", size = 14269, upload-time = "2026-10-02T23:06:31.949Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ec/6000fd82e8791e58fcd0456ec20f098957e2b03d5ed02eb73241a577c0ba/markupsafe-3.0.4-cp315-cp315-win_amd64.whl", hash = "sha256:d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214", size = 14496, upload-time = "2026-10-02T23:06:33.258Z" }, + { url = "https://files.pythonhosted.org/packages/bc/66/e73bd5016421d5d6e2fb6de7dd609f9de020942ac8c626526bd8c6eeaf82/markupsafe-3.0.4-cp315-cp315-win_arm64.whl", hash = "sha256:353bd63081912ab8cfa6a0c7d185934cdf8426f04c618bba6bc4b394f2069b67", size = 14361, upload-time = "2026-10-02T23:06:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/90/df/cb8c3dc98d313a951df2f8968f44e4cb5643df6d3cab749a530ce2f7d972/markupsafe-3.0.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c61750fadcd119d0825bcb7d7d675dd264dcc89cc05292aab5be68ebdbb374ad", size = 11760, upload-time = "2026-10-02T23:06:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/d6/bb/4af9b3ca0753d654ac75f9531d5bd741bb77ca6e696f36807c475ffc099a/markupsafe-3.0.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1c0df495a977d10460a94941799c72d5b5ab03d3858d949b55b5a66c8f371c99", size = 12108, upload-time = "2026-10-02T23:06:37.089Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d4/b56429313aee5fd59b079c3df5615299959e25e7113eb6d8caadbdd7d38a/markupsafe-3.0.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02fa4acbc6a3fc5c693c34d4dd8c1130b7fe99cc915181b0ddd6f72aeb296002", size = 25762, upload-time = "2026-10-02T23:06:38.419Z" }, + { url = "https://files.pythonhosted.org/packages/65/f5/34c181e891aa4f7d59c918584672e0c5eb7fffe76c1387d1246008bf4081/markupsafe-3.0.4-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:05295589e619b9bed252a86b532b8e27350abc372d18ba89b59375325e91ec1e", size = 28400, upload-time = "2026-10-02T23:06:39.819Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b5/ad14694fd0ac9a5ce30bc6498f2999378f418583dd1679cca5a1b512957e/markupsafe-3.0.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:be6cb0c799abb0e2ba3e618e6d28ddddf7e485f6c2ce938dfa237daf3905072c", size = 25946, upload-time = "2026-10-02T23:06:41.381Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a8/26b606445387d0ceb1eb1f21840094b84e4e3c3c3983d80d10b89823b490/markupsafe-3.0.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26e9867520db70d37f7fb421a7f0d8adb40171011fb84ce869afa1a83370dfa8", size = 23634, upload-time = "2026-10-02T23:06:42.748Z" }, + { url = "https://files.pythonhosted.org/packages/39/a2/b8814de672f1f0094d498bf646f2fec9d6356b503d28ef500b71c5095377/markupsafe-3.0.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f03460ff076f70ab595bb45a0205ccea1971443575b6920c52e755dec2b3fbfe", size = 23488, upload-time = "2026-10-02T23:06:44.176Z" }, + { url = "https://files.pythonhosted.org/packages/db/c7/287223376fb73335a3cc5d6eb22c6ab01358cf33945a9c39c06b9dac3f4b/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:436e3ffc6310d3c41878c601db29098102fe5d8a467c49da4a4125254e0980f2", size = 25037, upload-time = "2026-10-02T23:06:45.646Z" }, + { url = "https://files.pythonhosted.org/packages/f9/29/4df8355e313426d19e62ba33e0253c009ca12a0894ee77d67fa67255361c/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:4e2c4809c14559aa7ef426f27fb35afbb38104c349a903bf8f3600456764bb38", size = 23543, upload-time = "2026-10-02T23:06:47.264Z" }, + { url = "https://files.pythonhosted.org/packages/71/e5/8377731e8495668dcc768f645e717df18318c841edaf023a99395f6da9b4/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:da2af0d7aebfc2074080d72efa6ab8317c62481ef1f896f65d9999c1c01f4494", size = 25282, upload-time = "2026-10-02T23:06:48.795Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5f/373456e37ceb1478d657d6fe769cbe0a39f0a8dfc1548eeb19c471eefdd9/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:aa2c838cc024642cc04c6854232f32b43e5e22833dd11119c1766c7873b8370d", size = 22993, upload-time = "2026-10-02T23:06:50.31Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/2cbd5628435afb6f541bbaced4bce0c2edac4b09a142e6e928b8b0da9858/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b91cc9d336957239ff200f30097e6fea2dc6d6fb3c81e853eaa09eac904fd894", size = 23712, upload-time = "2026-10-02T23:06:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/81/99/157e10966b033b363aeda5263e82596ee232a0b1d082fdbf90aa417ff083/markupsafe-3.0.4-cp315-cp315t-win32.whl", hash = "sha256:e49fb0d1ce92cfa0cb198cc5b1b11cdf9d0638658e2a2db2687e39db7c87fc78", size = 14300, upload-time = "2026-10-02T23:06:53.241Z" }, + { url = "https://files.pythonhosted.org/packages/33/05/55884815414c9706a23deca150b72c25a62109e65b0b6ce232077802c719/markupsafe-3.0.4-cp315-cp315t-win_amd64.whl", hash = "sha256:4f6e0852a0283b1b1fd776eeb7b766a5f440b3e2bd31ab51af3b400585f3965c", size = 14522, upload-time = "2026-10-02T23:06:54.729Z" }, + { url = "https://files.pythonhosted.org/packages/92/f9/ecbde7149e95b8a0f18e16d5d747f7dc06049d5da2e4f77f6f5e4a1f46a8/markupsafe-3.0.4-cp315-cp315t-win_arm64.whl", hash = "sha256:39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba", size = 14417, upload-time = "2026-10-02T23:06:56.246Z" }, +] + +[[package]] +name = "mccabe" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, +] + +[[package]] +name = "mistletoe" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/17/ea/ee6e364d62197e427f1d260500eac659a8855df207a77f74451658d749f3/mistletoe-1.6.0.tar.gz", hash = "sha256:92f066c4720a25fa24bf260e6413702bbff3a5b8a93af314cb6cd8589726a8e1", size = 113730, upload-time = "2026-07-11T14:14:18.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/5c/3681c676d79726e3b8b72bc78e83ac472b0c96e3c99b28d72b9470f12170/mistletoe-1.6.0-py3-none-any.whl", hash = "sha256:53f9dfb859e9b2d309915d0f06c3876253d1e24d568d0b728a32679eb4f0a187", size = 55931, upload-time = "2026-07-11T14:14:17.056Z" }, +] + +[[package]] +name = "mypy" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/b9/d75b3082b05f1b3028828aeb18e74ae5ab0a0936051bbf1f32f59f654747/mypy-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3419d00717afbc5265b50dd14b1278f29ea4884dd398ab67873489ac093fd329", size = 14838725, upload-time = "2026-07-13T11:32:44.655Z" }, + { url = "https://files.pythonhosted.org/packages/a9/50/79a65c6ea6e115bc73296038a4543b2d5c91f07912b918a2c616a2514bba/mypy-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cfca8ee88544090f86b6dcce05ec55d66eb48a762412ac2507810ba4bd793b6f", size = 13911128, upload-time = "2026-07-13T11:32:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/90/48/e11ed7716c26953ca321f726e452e374dbf81a6f2b8b212ec02af29b6b8f/mypy-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75cbb4b9ef04a0c84a957f07abc4504fbf64b8dcc145675101f2d3a78a4b1d6a", size = 14146742, upload-time = "2026-07-13T11:33:03.313Z" }, + { url = "https://files.pythonhosted.org/packages/06/72/6807565b1c4861ef66f7fdd98b51c61556356eab80235717b46c53bb8627/mypy-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:982e3d53dd23d0a4cef67dd66791fdbede0cf38f9eb617bf47663554c51e1e36", size = 15081418, upload-time = "2026-07-13T11:31:13.899Z" }, + { url = "https://files.pythonhosted.org/packages/00/80/1ea14c5d80e589e415973db3e47c78c2219a305b808b2b506395342c1d79/mypy-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85c5385b93012ffa3b31479ab579aef5415f4f3a32c6cf1ae07a984d2a0ff461", size = 15328164, upload-time = "2026-07-13T11:31:35.723Z" }, + { url = "https://files.pythonhosted.org/packages/37/28/8223157404a3d51920078459c37f80fbdc590e1d8ea049dc5ce48643022a/mypy-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:13b1b16e2fa39f3b2e33fb1c468abc7a69369fa2e886b4b87b5afc81472325cd", size = 11136472, upload-time = "2026-07-13T11:27:37.018Z" }, + { url = "https://files.pythonhosted.org/packages/6f/cc/ea27e5959c5f258585a756b252031f3b313583d81b5064b2bebc41d3706b/mypy-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:b5cd2f027a972a4a5f2278a11fac9747f5f81a53a30b714d74950b6807e55568", size = 10135800, upload-time = "2026-07-13T11:30:08.92Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/0e7e592619e2133596a47cdd642534b0456545c218430bd3b9d8fefdd1b1/mypy-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d53fc67b9d28a43c6199077f49fea0f05839e36cf6158500331c9549225e5a5", size = 15026523, upload-time = "2026-07-13T11:34:49.206Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/1e1731df090a857df2807177a4626863e5ac0f0256513c35780efe53986f/mypy-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fbc00cee7bdbb9291979ddc9d08034a29dfcda4932628c9bbc28c1edd589df0c", size = 14032189, upload-time = "2026-07-13T11:33:57.168Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/cab921f4a806e171f34113e6181dd23c55358ccf6a80741269ef594a410e/mypy-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04e617030eca5221909c8b7d8d7fd1c637948199aa2100b2ad9813feb07e1491", size = 14198696, upload-time = "2026-07-13T11:32:12.767Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/e6d008bb19fe446e3662d85e0e2717bf9f2d611a2164fb29d6e067dbf46c/mypy-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56c184d2c20ca6b6378d58d1960270a767f41f5e44acbbd27f05effef4f4e1d7", size = 15286904, upload-time = "2026-07-13T11:34:27.594Z" }, + { url = "https://files.pythonhosted.org/packages/db/83/94397c9293608a364aa03e8084fb34ede4ae976a260384b9b52929308135/mypy-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3961a4a34b05f7c74b0f05aa51fbfe99a2d1e126038df40318d15c8f558b7ef3", size = 15528342, upload-time = "2026-07-13T11:34:07.819Z" }, + { url = "https://files.pythonhosted.org/packages/cf/96/d8b37d819adec6cfccfb1fd3afc1735d94717ddeafb45536db9c6943e09b/mypy-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:b1942b9314d4c784b8ea1dbab4972603290e5dd5630f06675f13aec97526bc4c", size = 11218346, upload-time = "2026-07-13T11:28:27.745Z" }, + { url = "https://files.pythonhosted.org/packages/2b/cd/cd9f725b19b19e5b530a154cf9bcf9e94279c5d55b3c34fb42b3aa48ea1b/mypy-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:be51653d7669d7d7955d613b8d0bb57d5b652eaf71a873ddf65ac87254dd2595", size = 10204525, upload-time = "2026-07-13T11:31:02.552Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ae/f7d056eb0294586a572d0d0d89580ec633c064db520f11d37d5a2fb833bd/mypy-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:91ad22a52ae2c7e621c2f67c94d5a17f66b3209a4cff5cf8a573579835c69e97", size = 14947298, upload-time = "2026-07-13T11:27:47.734Z" }, + { url = "https://files.pythonhosted.org/packages/32/d5/db3e7af01e7844d21662c6ddc1f7825ec7cb4053f0391ac02faf3638396f/mypy-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99ac767cc5d3b64c8d0ae226ead10c96694f94e4e7da1668642225dcd4e75aac", size = 13950768, upload-time = "2026-07-13T11:27:57.726Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fb/43c031f0190513d1ec248ed037eceb742ddd2a4d74bbf406658a28173837/mypy-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6d2c484742a4d7b0ed6d07b143375624d3b899c5749c7b3c947f56261f48a6", size = 14151586, upload-time = "2026-07-13T11:29:18.615Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c3/f8b2ffc60883084da91be51af58e88a7ffd4ff9795acb7d902ff88d31eb1/mypy-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7da939dd335cfd2ad788bdfd081c9f4e47634ab995e5a45eb15fd1e5bc052f8b", size = 15227411, upload-time = "2026-07-13T11:30:29.904Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/16b917fc7adcf03f1aadddfc93aab804ffb234b1ab09c0ffd6d92a5d34a2/mypy-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7247eb2824f996722a949530183394921ca71deb9680052a338cf53cff7925c2", size = 15478790, upload-time = "2026-07-13T11:33:14.686Z" }, + { url = "https://files.pythonhosted.org/packages/c0/88/aaa65a93c73d0cdae7e42f8adb302bf6885bb281302084f99d0290a35347/mypy-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:75b0984bb3cbd76bb5c9291a8671f7ae66ca3b51c7584c358fc2e923259f0757", size = 11234919, upload-time = "2026-07-13T11:33:39.28Z" }, + { url = "https://files.pythonhosted.org/packages/35/19/b40de63f1a80e63bc2d40f0679a6a8dbd34e95176c8122119bdf406aa552/mypy-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78fcf900b59cb7e82cb7e3a235e31b462d9333d92285bd1e4952d355b8ffba1", size = 10201510, upload-time = "2026-07-13T11:31:52.619Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/fa0ae047da911f540284009b4f44b96fe09d83c076d7c103e9d645f46303/mypy-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea317b060ce83e26050f8f9e4d7d6bf44ed7597c8ff9990bccffbb9d1d8522db", size = 14941909, upload-time = "2026-07-13T11:32:34.332Z" }, + { url = "https://files.pythonhosted.org/packages/15/14/2ba1d61452d7c2a7fe12741e8d374e52b183476b07aa7f9e2a0d02b0720a/mypy-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:094af99f92638aa92852326188b85a89e50f4a472f44827c03362228482f0762", size = 13967581, upload-time = "2026-07-13T11:30:00.587Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5a/483fb9e5ffbbb1a28dccc7b0a13d141b17ac769b6c9f488c0a0c63698962/mypy-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de121747278144fc9ae7caa2e978cf5df12aebc82933182f5b3b86081a30baef", size = 14168807, upload-time = "2026-07-13T11:28:48.6Z" }, + { url = "https://files.pythonhosted.org/packages/ae/77/70d7a10732063beb74ad713682cf871e88f5c5fa39bfc8beff8a524bf9cb/mypy-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37fa4de896a84e2dc9200d91e614c22563b43d1a266789d4bbac7b22ebe6192b", size = 15200144, upload-time = "2026-07-13T11:31:25.283Z" }, + { url = "https://files.pythonhosted.org/packages/56/72/766218ac783be4fdfcd699b90037b63017348a3e86fb2c1fbfb18302637d/mypy-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f1b3a98dfd21058bc759bb3337d5d1f61d0fdf9f3cf9c00f4291790fb5427bff", size = 15460389, upload-time = "2026-07-13T11:29:29.077Z" }, + { url = "https://files.pythonhosted.org/packages/38/4e/8a9db7411ecb8ec0cb1fd05dba432f28bafffcd38b4e887714a4a0506689/mypy-2.3.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:944c665d984157cb96a679dfb7a4a81dd1d36b24b9c284b699514e6e626b82d4", size = 7753664, upload-time = "2026-07-13T11:29:08.147Z" }, + { url = "https://files.pythonhosted.org/packages/65/4c/c3f8bfd6ed0e5e38b5a244403b27f821d433443df5a15a278417c10a3a3c/mypy-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:4359424140d985192c778c1ce2c114a10c1ca58a381ed79cfa70d37df94b299f", size = 11417237, upload-time = "2026-07-13T11:33:47.467Z" }, + { url = "https://files.pythonhosted.org/packages/3c/00/89a32eaf5ccf174bc4f90db0eaea5d70636c01b8d49f384bdab2e8834390/mypy-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:3dd0bed92c4bdec57c42505b96416fb9e6a5aa7be84d2809bcd5f2ecec2860d7", size = 10389252, upload-time = "2026-07-13T11:31:43.81Z" }, + { url = "https://files.pythonhosted.org/packages/31/56/104f93d69aa9f339b6b9d3b0a7faa699b8b466c942cf3ae86cc2a2ec0915/mypy-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:691fdc37132b1ae628d834f672e74de83462d9fb4aff621835767fb43a8dd373", size = 16385495, upload-time = "2026-07-13T11:29:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/f1d2123313f55efafdd27706960f43a771c62f1b68426c76043f3ab9ebf3/mypy-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aec15d465d477558fd842757b487849007311cf3897849cdda0e3162ac0ac556", size = 15098155, upload-time = "2026-07-13T11:30:40.301Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5d/d5f9200399b445e81726c4f23becee33f233aee81c72680b1ef3a258b641/mypy-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b352b7e49f5e6576009e8df730e1ff4f915cb565b851b396d2ffe2f5a6f5da88", size = 15514155, upload-time = "2026-07-13T11:34:38.569Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ce/69977c555f08faa3190cfde44189b89dbd56861b1ab97aa18fc5f3a2e4a3/mypy-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c6c6bf687b17f90dbfcad95b960d32eaa0154c00da45f03ab50bf8952e047fe", size = 16766351, upload-time = "2026-07-13T11:33:29.195Z" }, + { url = "https://files.pythonhosted.org/packages/bc/92/6648b6caa3ab9e00f9ac0c2a78307805f873dd48139b24a6f6f7c3667bbf/mypy-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ed18f111bfe2d599bca7468e7f9251042c1c2118f762c8de2766a56d773c60", size = 17043490, upload-time = "2026-07-13T11:30:53.927Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ab/0dc91d80f3f016634c68d451f294a97320fe903a9b6f90b9e57b3f7f1717/mypy-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0b025a93cffb9781d231f232be07a17912f35f10a313c24f301c81e842870654", size = 12146869, upload-time = "2026-07-13T11:29:38.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/b5/4c964d02634ba81f4d1c84838e5c5b18ab06d13ed568960f5d6318495ccc/mypy-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:adebc76aab4f3495a88b41d48aa4aff0c03f2822501da76625afcca5975f19e5", size = 10965113, upload-time = "2026-07-13T11:28:07.056Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fa/fdc54fe583ba3cafbcedfb70eeeaf03849f75b1827a07096c7bd996f582d/mypy-2.3.0-py3-none-any.whl", hash = "sha256:6b1cdb579446b60432432b2b2403a6201b4b475a004d7f488511c9ba177c9e88", size = 2753292, upload-time = "2026-07-13T11:33:18.48Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +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" } +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" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "plain-parser" +source = { editable = "." } +dependencies = [ + { name = "mistletoe" }, + { name = "python-frontmatter" }, + { name = "python-liquid2" }, + { name = "pyyaml" }, +] + +[package.optional-dependencies] +dev = [ + { name = "black" }, + { name = "flake8" }, + { name = "isort" }, + { name = "mypy" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "black", marker = "extra == 'dev'", specifier = "==26.5.1" }, + { name = "flake8", marker = "extra == 'dev'", specifier = "==7.3.0" }, + { name = "isort", marker = "extra == 'dev'", specifier = "==8.0.1" }, + { name = "mistletoe", specifier = ">=1.6.0,<2" }, + { name = "mypy", marker = "extra == 'dev'", specifier = "==2.3.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = "==9.1.1" }, + { name = "python-frontmatter", specifier = ">=1.3.0,<2" }, + { name = "python-liquid2", specifier = ">=0.4.0,<0.5" }, + { name = "pyyaml", specifier = ">=6,<7" }, +] +provides-extras = ["dev"] + +[[package]] +name = "platformdirs" +version = "4.13.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/e9/bf409046a91b6a9ca652e9b0ac3c39481674f6774e23e58b52315516099a/platformdirs-4.13.7.tar.gz", hash = "sha256:f7f351c027d6632c30471124e1fdb6ac0fef9ccc5cbac1b34cb333431b1fc3e1", size = 63906, upload-time = "2026-10-28T05:49:31.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/ff/8ed4a836cbf7de39708a2d305d6b67575990908d072bc3565dd8ad71748e/platformdirs-4.13.7-py3-none-any.whl", hash = "sha256:47664602fbecad0a6efa561fb690d20dbc26875fe8aedf65ba362935df619bbb", size = 33111, upload-time = "2026-10-28T05:49:30.545Z" }, +] + +[[package]] +name = "pluggy" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" }, +] + +[[package]] +name = "pycodestyle" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", size = 39472, upload-time = "2025-06-20T18:49:48.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" }, +] + +[[package]] +name = "pyflakes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", size = 64669, upload-time = "2025-06-20T18:45:27.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "python-dateutil" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/df/f15f80383f152452d8985b36d95cb8751f901a3e0ff4cd27f7e118f168e2/python_dateutil-3.0.0.tar.gz", hash = "sha256:7ebcb3c29a92f3ceafe79a7a48a5c45677e9e004bbeec5742ac0bbb00560b7a2", size = 271066, upload-time = "2026-10-21T23:41:45.075Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/17/cebee01d1c0c1441174cf0456ddc91b280d79acb65340c05e41d77866ee8/python_dateutil-3.0.0-py2.py3-none-any.whl", hash = "sha256:3c79630202e4152c6add678446984236efe4f57e84d8e892aab0637bab82e9fd", size = 94659, upload-time = "2026-10-21T23:41:43.431Z" }, +] + +[[package]] +name = "python-frontmatter" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/e8/79cbe69864d44f3b48e70ebee0a872a7d5a4e7150c9f8577ed7a5beefff0/python_frontmatter-1.3.0.tar.gz", hash = "sha256:acc73e477a568dc2a25c9e130c6c68ae8daa8c204c8f7e813db47d6a7280dcf2", size = 8322, upload-time = "2026-05-20T19:21:44.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/a3/17c284b4f4d8ad50f0f9ba70ad8fcc35c777aeafcdbbffdd91bbdc5ab379/python_frontmatter-1.3.0-py3-none-any.whl", hash = "sha256:9f7dd9260bec99044219159a329f64f039087f9d1a2124c9442556f2fe6f82ec", size = 10562, upload-time = "2026-05-20T19:21:43.323Z" }, +] + +[[package]] +name = "python-liquid2" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "markupsafe" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/48/11a415c56ac4410ef1c69559eaf5b2e696717252aaebd55beb33582f3ff5/python_liquid2-0.4.0.tar.gz", hash = "sha256:2d9499ab7e7ca1ec10a1c2b2ff2a7a292b9e1eda3ff7ffe8c409ce2a8675e9ab", size = 87961, upload-time = "2026-05-13T06:39:21.639Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/82/eab55b9b0dee18144619f9383db25a5c9916d0aaa5681a5c07d92e307196/python_liquid2-0.4.0-py3-none-any.whl", hash = "sha256:a5ab10e12ebf80c1dc35906d41f7180587c7553a308370d06a7e242d7c41f5f5", size = 129974, upload-time = "2026-05-13T06:39:20.561Z" }, +] + +[[package]] +name = "pytokens" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/92/790ebe03f07b57e53b10884c329b9a1a308648fc083a6d4a39a10a28c8fc/pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440", size = 160864, upload-time = "2026-01-30T01:02:57.882Z" }, + { url = "https://files.pythonhosted.org/packages/13/25/a4f555281d975bfdd1eba731450e2fe3a95870274da73fb12c40aeae7625/pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc", size = 248565, upload-time = "2026-01-30T01:02:59.912Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/bc0394b4ad5b1601be22fa43652173d47e4c9efbf0044c62e9a59b747c56/pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d", size = 260824, upload-time = "2026-01-30T01:03:01.471Z" }, + { url = "https://files.pythonhosted.org/packages/4e/54/3e04f9d92a4be4fc6c80016bc396b923d2a6933ae94b5f557c939c460ee0/pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16", size = 264075, upload-time = "2026-01-30T01:03:04.143Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1b/44b0326cb5470a4375f37988aea5d61b5cc52407143303015ebee94abfd6/pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6", size = 103323, upload-time = "2026-01-30T01:03:05.412Z" }, + { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, + { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, + { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, + { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, + { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, +] + +[[package]] +name = "pytz" +version = "2026.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/14/21/d83d6ef28c4c912c4bb4d1dcf591f7b8c6bde87b9c66f9f454677314e16d/pytz-2026.5.tar.gz", hash = "sha256:fa23724b9c486543b9ff54a327ee7569ac83ade54bb9afd0fc18676620401c86", size = 318572, upload-time = "2026-10-04T02:37:58.719Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/ef/c66110d46fb800dda0bf33164182dfadabe26a90e4476844d502a23dca8e/pytz-2026.5-py2.py3-none-any.whl", hash = "sha256:e658af3757f9e26a9d25dd2aff38335acd92bc9104f890a894b2c1ba28311b03", size = 506342, upload-time = "2026-10-04T02:37:56.814Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { 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 = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/68/f1b440335057bfce71b6e50a9d09445aa2ecbd08359a337976627b8409e7/tzdata-2026.5.tar.gz", hash = "sha256:8cc73c0a0bfca7dbfa59235d60b2eff82231dee33f53d206db1acd9173cfc0a7", size = 200404, upload-time = "2026-10-03T09:23:14.143Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/21/1e5995a1c920cce14e4bffae20c665ec10e7ed03ab25e006cd741092b718/tzdata-2026.5-py2.py3-none-any.whl", hash = "sha256:b683bd1b6659ddcd810ff02ad09ba821d4bf1065072805063eb35c49617905ac", size = 347996, upload-time = "2026-10-03T09:23:12.535Z" }, +] From 037be0dd8cff23bc4e53d98000da6e83d7de6c4c Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Mon, 2 Nov 2026 10:47:16 +0100 Subject: [PATCH 14/15] fix: pass over a test plain file of a module outside the chain A tests folder may serve several self-standing specs, each rendered on its own. Reading one of them stopped on the first file of another, because a file whose module: names a module outside the chain was an error. The same happened to a module chain rendered from the middle, where the files of the modules above it are in the same folder. Such a file is now passed over before it is read, because its content is written against its own spec and must not be held to this one. A module name that is a typo binds the file to nothing, so the functionality it was written for has no test plain file, which check_test_plain_files reports. --- src/plain_parser/project.py | 17 ++++++++++------- tests/test_project.py | 36 +++++++++++++++++++++++++++++------- 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/src/plain_parser/project.py b/src/plain_parser/project.py index ee2d17a..b38c1ce 100644 --- a/src/plain_parser/project.py +++ b/src/plain_parser/project.py @@ -62,8 +62,9 @@ def bind_test_plain_files( ) -> list[BoundTestPlainFile]: """Read every test plain file under the tests folders and add its scenarios to the functionality it binds to. - A `.plain` file under a tests folder without the `module:` and `func_id:` keys is an error, as is a file that binds to - a module outside the chain or to a func-id the module does not have. The concepts of the scenarios must be + A file whose `module:` names a module outside the chain is passed over, so that one tests folder can serve + several self-standing specs. A `.plain` file under a tests folder without the `module:` and `func_id:` keys is + an error, as is a file that binds to a func-id the module does not have. The concepts of the scenarios must be visible to the bound module. Files bound to one functionality are merged in discovery order; a scenario-id is unique across that merged set. """ @@ -84,12 +85,14 @@ def bind_test_plain_files( f"frontmatter needs the keys `module:` and `func_id:`." ) + if metadata[test_plain_file.MODULE_KEY] not in trees: + # The file belongs to a module this render does not build. One tests folder may serve + # several self-standing specs, so such a file is passed over, not read. A module name + # that is a typo binds the file to nothing, and the functionality it was written for + # then has no test plain file, which check_test_plain_files reports. + continue + parsed = test_plain_file.parse_test_plain_file_source(text, name, template_dirs, {}) - if parsed.module not in trees: - raise PlainSyntaxError( - f"Plain syntax error: Test plain file '{name}' binds to module '{parsed.module}', which is not the " - f"entry module or one of its required modules ({', '.join(trees)})." - ) tree = trees[parsed.module] functionalities = tree.get(plain_spec.FUNCTIONAL_REQUIREMENTS) or [] functionality = next((item for item in functionalities if item["id"] == parsed.func_id), None) diff --git a/tests/test_project.py b/tests/test_project.py index 50c78a2..67fb779 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -127,18 +127,40 @@ def test_same_scenario_id_for_two_functionalities_is_allowed(tmp_path): parse(tmp_path) -def test_unknown_module_is_rejected(tmp_path): +def test_a_file_of_a_module_outside_the_chain_is_passed_over(tmp_path): + """One tests folder may serve several self-standing specs, each rendered on its own.""" write(tmp_path / "todo.plain", MODULE) - write(tmp_path / "tests" / "a.plain", scenario_file("tasks", "add-task", "# works\nIt works.\n")) + write(tmp_path / "tests" / "todo" / "add-task.plain", scenario_file("todo", "add-task", "# works\nIt works.\n")) + write(tmp_path / "tests" / "notes" / "add-note.plain", scenario_file("notes", "add-note", "# works\nIt works.\n")) - with pytest.raises(PlainSyntaxError) as exc_info: - parse(tmp_path) + result = parse(tmp_path) - assert str(exc_info.value) == ( - "Plain syntax error: Test plain file 'tests/a.plain' binds to module 'tasks', which is not the entry " - "module or one of its required modules (todo)." + assert [bound.name for bound in result.test_plain_files] == ["tests/todo/add-task.plain"] + assert scenario_ids(functionalities(result)["add-task"], plain_spec.CONFORMANCE_TESTS) == ["works"] + + +def test_a_file_outside_the_chain_is_not_read_at_all(tmp_path): + """Its content is written against its own spec, so it must not be held to this one.""" + write(tmp_path / "todo.plain", MODULE) + write( + tmp_path / "tests" / "notes" / "add-note.plain", + "---\nmodule: notes\nfunc_id: add-note\n---\n\n***conformance tests***\n\n# works\nA :Note: works.\n", ) + result = parse(tmp_path) + + assert result.test_plain_files == [] + + +def test_a_module_name_that_is_a_typo_leaves_the_functionality_without_a_test_plain_file(tmp_path): + write(tmp_path / "todo.plain", MODULE) + write(tmp_path / "tests" / "a.plain", scenario_file("tasks", "add-task", "# works\nIt works.\n")) + + with pytest.raises(MissingTestPlainFileError) as exc_info: + parse(tmp_path, require_test_plain_files=True) + + assert "todo:add-task" in str(exc_info.value) + def test_unknown_func_id_is_rejected(tmp_path): write(tmp_path / "todo.plain", MODULE) From c5ccb550bcd7769d42b092672b3fb5ab78e4f49b Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Mon, 2 Nov 2026 12:51:58 +0100 Subject: [PATCH 15/15] chore: remove uv.lock from the repository The file came in by mistake with the rename of --test-dir. --- uv.lock | 768 -------------------------------------------------------- 1 file changed, 768 deletions(-) delete mode 100644 uv.lock diff --git a/uv.lock b/uv.lock deleted file mode 100644 index 44d98d0..0000000 --- a/uv.lock +++ /dev/null @@ -1,768 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.11" -resolution-markers = [ - "python_full_version >= '3.15'", - "python_full_version < '3.15'", -] - -[[package]] -name = "ast-serialize" -version = "0.13.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/d5/5dc1ee8a80c3b20a0b63c61b43ba6bc53517faecd1def47bcd752d22268e/ast_serialize-0.13.1.tar.gz", hash = "sha256:401cc84e8888d4d76f2c8d296fa006ed1a5ff993b6aff90dd777c03973efd025", size = 955137, upload-time = "2026-10-17T00:04:17.183Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/a8/fbd136b9f70c59e75f59a8e974c84102976f1201828b4f886480ba956936/ast_serialize-0.13.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3e129d44b0cffbea24a7f40117ce49e30993c3d119e584d4817ebd6f87246456", size = 897306, upload-time = "2026-10-17T00:02:52.479Z" }, - { url = "https://files.pythonhosted.org/packages/50/3f/2ad51e9823a790e7a70fd781e9595ab3abeace27f3aefcaa1939457efd72/ast_serialize-0.13.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:523d45bab16ddaf1399c04c4fe81bbf180f81e33c2f7bcbe76be56b0f7311ab4", size = 1236082, upload-time = "2026-10-17T00:02:54.82Z" }, - { url = "https://files.pythonhosted.org/packages/c0/5c/8e959db5cc2237f9d4c560a8e2253c98f9746883fb4774651e4cda458901/ast_serialize-0.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:638333fb8ae72c62753f9e794fda20290883ddc65b0d85e26e08f8bb227567ad", size = 1221242, upload-time = "2026-10-17T00:02:56.395Z" }, - { url = "https://files.pythonhosted.org/packages/d6/05/ab1c56c3b7adcac88764bba609502d6b4989e449b2dd60eb478e1d20e369/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc34c670747d85d4232834665e247009413eaebe47c69eaf60a5c439aaecaed9", size = 1282105, upload-time = "2026-10-17T00:02:58.275Z" }, - { url = "https://files.pythonhosted.org/packages/0d/f4/41981fcd2ddffe0760e5326be9dcbd2eda9478a8430a637e5123bd8fcb26/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8c8ebd419811c435812d242fb2a173e57dfede198c9ac9a0a584666ebc9b9331", size = 1287905, upload-time = "2026-10-17T00:03:00.068Z" }, - { url = "https://files.pythonhosted.org/packages/6a/43/b783addc5681153707121991b7b47892f7e9c8ba58a0d03b71a3b023ba47/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8899c038b1c15ae8b98e379057fb3ef398ceb0b21d1737a8156dd5a0923b7e51", size = 1541806, upload-time = "2026-10-17T00:03:01.503Z" }, - { url = "https://files.pythonhosted.org/packages/47/10/969db095038ee15270f65ad20d1129c5e4da605bf7e93b70ae640f1c8a9f/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e14c4de98f965647a74e6eaf37e20207124e45451ccd36e84a6f6ebab45e0372", size = 1309946, upload-time = "2026-10-17T00:03:02.971Z" }, - { url = "https://files.pythonhosted.org/packages/06/dd/7c623d2fb0b868948952d1787e629a43b866f7d4caefd5445e20aefacec4/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f3e8a3eff69af640db985360fef35073fb46956dfc3e5c614cf00920d2746dd", size = 1302773, upload-time = "2026-10-17T00:03:04.378Z" }, - { url = "https://files.pythonhosted.org/packages/d4/c2/4d49469110a19a44fc6269f4fd59679bcf84df068f185f89b73bee4da19b/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:75d7750fe98f6f35a96c43aba8d98a2e623e22c778a0c17ab9ede92eb10f5d82", size = 1299579, upload-time = "2026-10-17T00:03:05.88Z" }, - { url = "https://files.pythonhosted.org/packages/9d/9a/2d381f1f262dda15affc8ad643fba1619eb07e7d9f8bd904deffef183006/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:43e8ddd63e80fc280c2f790157386f27f9b9f6ccb9c4f8e803b863cbfb085769", size = 1355969, upload-time = "2026-10-17T00:03:07.321Z" }, - { url = "https://files.pythonhosted.org/packages/9d/9c/2266794e6c25fa9ce51ce04a9bf4b7e330e3ac9351ba3fa14e487678acdb/ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9221e09496fb942901993c1812ee87950cc0ecbb514c7c0f1f5fa6d78f701ed8", size = 1460227, upload-time = "2026-10-17T00:03:08.733Z" }, - { url = "https://files.pythonhosted.org/packages/2b/d6/14deb28e5d8f328f626a0778ea2cf98fe908f2bbcccd804cb6f449ee1782/ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:7a5c38bdb93669c596f94fb7204dd6ebe4fadb19058ac9a2faf2948c2a175f96", size = 1562691, upload-time = "2026-10-17T00:03:10.25Z" }, - { url = "https://files.pythonhosted.org/packages/f5/19/7a28c0e5ba54f0d3e054f847a2a549b748cb179ab433321a8dc1b39e398d/ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:acf4e9f055d75058ab01536da330648746f2f82b7697a85230547f2d5ebe895c", size = 1558669, upload-time = "2026-10-17T00:03:11.799Z" }, - { url = "https://files.pythonhosted.org/packages/18/9a/f9e2690eab58217f1d395a964a78efa7eaf813a1e38be8dfd9a8d45d0840/ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87427913c325adc811e81e7c9a757b061f47ef1e29114ac479aa0953a2687a98", size = 1665071, upload-time = "2026-10-17T00:03:13.284Z" }, - { url = "https://files.pythonhosted.org/packages/8e/f3/ef5b159a0d7161b404f05e7ec21cf7735ee7edc10c000cd2cbbc7bb6b69a/ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:ad88921f95b92bc144d9ad059a0e16f8c1ae69f5239f2d3c6536fd2774f73dfe", size = 1473364, upload-time = "2026-10-17T00:03:14.563Z" }, - { url = "https://files.pythonhosted.org/packages/1d/87/6e8ac4a865124a12cc4f915bf1bcfac3bba426cd5f58d3712a873ee61012/ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f8b17e2921e56621bcb46792bd3e0b82ca3b85de78d5936e7b8174ba1758b0aa", size = 1500566, upload-time = "2026-10-17T00:03:15.917Z" }, - { url = "https://files.pythonhosted.org/packages/f5/1e/29155f1621433008013ad5c940a03d41a676be0680c41527040472d37583/ast_serialize-0.13.1-cp314-cp314t-win32.whl", hash = "sha256:2f9a9ce8b0994f757b941f7f7fe384a6666b4162b941ebc5c248081baea18e47", size = 1128674, upload-time = "2026-10-17T00:03:17.481Z" }, - { url = "https://files.pythonhosted.org/packages/bb/64/2506da65143ea138de5822e9173e0b72320a2f70e23bd723f044033175f1/ast_serialize-0.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5d99910e6b9a9b9360bcca2ae99aee36cc912ce058df2a57d1c33099b6cb2593", size = 1163306, upload-time = "2026-10-17T00:03:19.046Z" }, - { url = "https://files.pythonhosted.org/packages/6d/8f/c7cb22638899b0cf15908809c270560e46395e65604bc59e4b623ab9f405/ast_serialize-0.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:a789cbe14d0a0e9ec9b32a456e5abc81ad8799479c3cfd88a29e3442262aebe6", size = 1133775, upload-time = "2026-10-17T00:03:20.377Z" }, - { url = "https://files.pythonhosted.org/packages/2b/e2/b0c140fe50fa39dd249e382f7733af6f816c90021fe7959a0b026f86237f/ast_serialize-0.13.1-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:23d768369b89d551077dc173da6892fad00443338417523b6732f4e8ba4a7ed5", size = 1236160, upload-time = "2026-10-17T00:03:21.945Z" }, - { url = "https://files.pythonhosted.org/packages/23/c4/a8a2ca21e3943555b48221f8fba546877a7ad39efaf6505506524967d7c4/ast_serialize-0.13.1-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:68f8829d114dab6c9db4a8bbb8d0e342958faee1b5e15a29022678df639960f8", size = 1222214, upload-time = "2026-10-17T00:03:23.462Z" }, - { url = "https://files.pythonhosted.org/packages/15/19/2f58f2ba64e4799b39de85c140aef09a03aa6ea53be75dd292a9d9b9d8ae/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e385b17d5bde892a6184003a9aff3518387908b73f8f5ea539313c07c8c0ede", size = 1282979, upload-time = "2026-10-17T00:03:24.695Z" }, - { url = "https://files.pythonhosted.org/packages/b1/af/ca6087fe84210c1ccd43e3182917bb70649fa1e69745a90c1d59ba08fea8/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:02686b281ec9b8e9614f6f0db27e12e4cac515f336c5d05b678bc9eb73ff2af5", size = 1288596, upload-time = "2026-10-17T00:03:26.04Z" }, - { url = "https://files.pythonhosted.org/packages/b0/2c/4ef964c763329a0347125e745ec0451ba4fbaceec9efe2ac729b7cd55f17/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a8ab2c2585b2e22e48cd4aff2136fce4eab4ced569b85f150ef9a5a5d6f34d49", size = 1545540, upload-time = "2026-10-17T00:03:27.52Z" }, - { url = "https://files.pythonhosted.org/packages/bb/85/aa961dbb75813cfa94de0ce9a16a9510c226b95f604c405421e727f44bb0/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c652d9f5d52d8b81eff3ff2c95c9dd0f281de45f6ad5d3fa95527550c396454a", size = 1309374, upload-time = "2026-10-17T00:03:29.091Z" }, - { url = "https://files.pythonhosted.org/packages/b4/e6/f2b67c327d9f9533e41791804403ccc2dc457821195a2e51f0b587a90b90/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:121e1d08d58b75c15491cc3003609cbad71ac2b0016043c36d30a6b2032b6605", size = 1303466, upload-time = "2026-10-17T00:03:30.451Z" }, - { url = "https://files.pythonhosted.org/packages/73/1d/33f18b81d1bb1f5d682943ea92fda8130a6593627f12f6201668d40824fd/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:30cf89b6a6844542d116c758725d13de5b97b9410a51ee577715f761270d0c1c", size = 1300576, upload-time = "2026-10-17T00:03:32.102Z" }, - { url = "https://files.pythonhosted.org/packages/25/cb/64914144925f1a4abdd9217e717b122255c38fc5ebe4342f92052f3bacbc/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:732aaae9e498c973fc63fd7f308009b57c4afa6ff59d0b674d894e57d0bca01a", size = 1355960, upload-time = "2026-10-17T00:03:33.676Z" }, - { url = "https://files.pythonhosted.org/packages/ee/80/bd29379fab2bce19adc0bf9177002b11b23dfe8d8fecf409998b458b4624/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:d9c1a920b2aa0c1e016bed7721689717034ca0dba64ac3a3ff243b003d4e8dd8", size = 1459779, upload-time = "2026-10-17T00:03:35.09Z" }, - { url = "https://files.pythonhosted.org/packages/83/89/012dbe733de05ffdf8e40ca0f9fa5d417cb16ec93a74463c57c001914198/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:847cad4c22953823f5e76d0b126c6f9da670cfa45b1df41b9324e22189d77611", size = 1563399, upload-time = "2026-10-17T00:03:37.285Z" }, - { url = "https://files.pythonhosted.org/packages/69/37/ee4bcb0358bee38744eb0f18560446572d095fe3bf6b63d6f739d08a839c/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:aca07de3ca8e3d9f2b716c3c31f4f384fe4bd2d03e09912417cff7a3e08036f7", size = 1558808, upload-time = "2026-10-17T00:03:38.711Z" }, - { url = "https://files.pythonhosted.org/packages/54/1c/ff44c5355251d61ef22934c4430bb31a61de4781d25a992154aac9b8c82a/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:5db12d843d931b9d1f1519ce66b9cbfe72d8563b75c1204d255322254d1964dd", size = 1666324, upload-time = "2026-10-17T00:03:40.376Z" }, - { url = "https://files.pythonhosted.org/packages/c8/bb/6da4bfc8aa3403e6447607131bf3463acaf954d990c6c8b6787c1902ddfe/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:fb8021f4dc3296de18737e3b8b34994411da725374d74a0b9eb32a738f2a23d0", size = 1474089, upload-time = "2026-10-17T00:03:41.715Z" }, - { url = "https://files.pythonhosted.org/packages/21/97/897e33bef2eefd880e6350e375cc66bc5bcd00a139be480fd655f5b3f095/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:0d764913374ba21df8cd618045b371ed104ce55c6276de7c59e554a4f5ba26fd", size = 1501542, upload-time = "2026-10-17T00:03:43.08Z" }, - { url = "https://files.pythonhosted.org/packages/8d/4c/821211ac83870c27e4412381b7c7838336456b40575bf840e4680b2018d9/ast_serialize-0.13.1-cp315-abi3.abi3t-win32.whl", hash = "sha256:2bad302ae2427454ef9dab71bd5c3ad67188860c39d36daee38f72031edb39c4", size = 1126118, upload-time = "2026-10-17T00:03:44.616Z" }, - { url = "https://files.pythonhosted.org/packages/1f/86/2b2d5e6c4fa352874a80618e742ffd32d249dee6c119f4233b51afc6b8f4/ast_serialize-0.13.1-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:032b09d86c785c3984e975378b7861f2a68ac44e22ba7b07b93a536b2524eeb8", size = 1163766, upload-time = "2026-10-17T00:03:45.986Z" }, - { url = "https://files.pythonhosted.org/packages/77/a3/d610cfcdabaa44fd62a54ca1fc57a7dba120643798b089a14914973562de/ast_serialize-0.13.1-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:929eda0c419e622ab49df3a4c45f81c57dcc10874bbad55d0470a4a03324dac3", size = 1134043, upload-time = "2026-10-17T00:03:48.453Z" }, - { url = "https://files.pythonhosted.org/packages/f0/89/0e30f9eddf7d44ccf9d25979030754fd0457af61fa4279acb8c293a04f97/ast_serialize-0.13.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:bf53a3ba1cbe45399fc1e4ccd684b8f2a11cca450f89706b79122f478534c6f5", size = 897546, upload-time = "2026-10-17T00:03:50.093Z" }, - { url = "https://files.pythonhosted.org/packages/81/92/d705be88ff452b9ba0088b554a0cbd308fd01879da092b33fd2ad7d1aa60/ast_serialize-0.13.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:4bcf1d5f4eefecaa325a34ecf079b2741189e7d0821523f92cb343a389a67489", size = 1242416, upload-time = "2026-10-17T00:03:51.521Z" }, - { url = "https://files.pythonhosted.org/packages/60/c2/98370845a7f35cb8a25782e78284b48e01bf3128339881d48d613f304f8f/ast_serialize-0.13.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1b158f1c9e3a0d386913924c57995d6df78224868a4d0f215bdd41587c97b34a", size = 1229729, upload-time = "2026-10-17T00:03:52.825Z" }, - { url = "https://files.pythonhosted.org/packages/d8/8b/2d7ffed76addd9d3fcf37fb171400942735c8912264c763561a85407f6a8/ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a6201b69ce42e919f2fd9a559138d5410b5d8afff7c3a6b1f500b0481ff7d8", size = 1292255, upload-time = "2026-10-17T00:03:54.148Z" }, - { url = "https://files.pythonhosted.org/packages/6b/2a/bb9e0e719d89cc950fadfcfcc9cb4bc7640419c4228c852f031ab8abf440/ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6eb0be64c9b8f1ed26637eef345abe75ea90351c92c85a8aec7838b3af971011", size = 1298501, upload-time = "2026-10-17T00:03:55.559Z" }, - { url = "https://files.pythonhosted.org/packages/7a/32/13ce07dce48226f5bd9cfd8f708ec101a8be05aa430cda4918ce31ce8c82/ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f2427bddfca208e84de700044fadf9aef2769a70bae740a1bdabb89ee9e1117", size = 1553758, upload-time = "2026-10-17T00:03:56.952Z" }, - { url = "https://files.pythonhosted.org/packages/e2/fb/9b51d186db0c20ca28cbaaefd50f03b573c89327b9a2627820cbce22196b/ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88df960e67184a71874e5b2ee014f87d1254a0dbc1f903c7bd7b1ab7733ecaef", size = 1318258, upload-time = "2026-10-17T00:03:58.418Z" }, - { url = "https://files.pythonhosted.org/packages/c5/fa/da311559dd3dae9af5825cf21660775a929b970f72e3786af3f2cd667944/ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f99786e0d62082a5b121b3437f3167562133711df22128de1e9886f57f0b226", size = 1313260, upload-time = "2026-10-17T00:04:00.194Z" }, - { url = "https://files.pythonhosted.org/packages/2b/11/97a4b9e5b4776d72f47fcd9a09a497bbf68ec8c1ae074307a2c51e410d85/ast_serialize-0.13.1-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:a01b515b25a03184eff4acef25b981ae007916379682e908fc8785b342a0bf3e", size = 1309992, upload-time = "2026-10-17T00:04:01.575Z" }, - { url = "https://files.pythonhosted.org/packages/2e/c4/62f85121a80cd451ef04f550cf0f7ea0ad9ff46cc4e9a5b9ae439b6527b1/ast_serialize-0.13.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:29422d7bcd9e7f39411571c36cec45ce8a7a9b3ad4b2e7aa64497c15a9e397f6", size = 1362715, upload-time = "2026-10-17T00:04:02.881Z" }, - { url = "https://files.pythonhosted.org/packages/c1/fa/c5f4a545f72a7681e3295bab6e3600ee0862ec4391bed961fe9f735b67e0/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6ffb0f548a7dc126067fdfb7b2325ab5dfcdeecc29faf5e0f0461c96d6ebfa3d", size = 1469931, upload-time = "2026-10-17T00:04:04.181Z" }, - { url = "https://files.pythonhosted.org/packages/38/a9/65aed4f8fa7a001a7cfdc03e68e844517ab041ba6fb614b7d78a1d6ac056/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:2876444a5f87a7c76f371af3c07c2fda563a08bc8af52568df812e8aba4bf568", size = 1570945, upload-time = "2026-10-17T00:04:05.778Z" }, - { url = "https://files.pythonhosted.org/packages/fb/8c/44b2da991cede79bfbf52089f33a5f0168945466b3d9cd38b5dcf08e7130/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:885a62ec6481ce58ab24c6357360d7a82023b62dd7e5e7ceccf59e9662a3272b", size = 1569978, upload-time = "2026-10-17T00:04:07.347Z" }, - { url = "https://files.pythonhosted.org/packages/46/e4/109b2a5beca700237767d5add920c2f8e1ba9bd5799b0faf143d0e9cf4ef/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:2015fd0165e463aac3b1aadc6a1fa1e5820c14ca950821f4fe02f8a3f6a388d6", size = 1676082, upload-time = "2026-10-17T00:04:08.7Z" }, - { url = "https://files.pythonhosted.org/packages/73/e0/7df70f6dbb11b5762da5416318f08316a1c51348e0d69ec79ca5f98f836a/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:eb41e1906502ac80d24ff343f45008552a3f5b5158607959fe6a07dbfc6eb037", size = 1483207, upload-time = "2026-10-17T00:04:10.162Z" }, - { url = "https://files.pythonhosted.org/packages/1f/bc/523aa1c08bd69b5654621beb3dc1a9035715a4a63762fb8d219d6fb3eb81/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ca9acc67f796aa2554fa3037dccc022a4b08b38b2ad153edfac9c9bb7cb1f3c6", size = 1512140, upload-time = "2026-10-17T00:04:11.589Z" }, - { url = "https://files.pythonhosted.org/packages/66/83/6607060659998f00afdea0d8375c1e6e2b7d515ab905090d9a5a02f48851/ast_serialize-0.13.1-cp39-abi3-win32.whl", hash = "sha256:4f98da562e5901d69c7dd30885594c5438f859edf354e9ac7517d188fb026c3a", size = 1131889, upload-time = "2026-10-17T00:04:12.953Z" }, - { url = "https://files.pythonhosted.org/packages/9f/6f/bafd6490fe7b355627fabe95f4417e3d02da93102c8552550eb9a0774e1e/ast_serialize-0.13.1-cp39-abi3-win_amd64.whl", hash = "sha256:ddf7cbc9c8abf70102c4a088efe154bb86bfb825b0a10e556d2b15972cf99bd7", size = 1171153, upload-time = "2026-10-17T00:04:14.362Z" }, - { url = "https://files.pythonhosted.org/packages/11/25/4235f4b375a67a37ac595bf478a5407a744eaf7b64113c1c3a5432b8d954/ast_serialize-0.13.1-cp39-abi3-win_arm64.whl", hash = "sha256:48f11d96c9ec71119612f8e79a8c55c12f2b136716d68347433c561d27a25f43", size = 1139035, upload-time = "2026-10-17T00:04:15.767Z" }, -] - -[[package]] -name = "babel" -version = "2.18.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, -] - -[[package]] -name = "black" -version = "26.5.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "mypy-extensions" }, - { name = "packaging" }, - { name = "pathspec" }, - { name = "platformdirs" }, - { name = "pytokens" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c0/37/5628dd55bf2b34257fc7603f0fe97c40e3aaf24265f416a9c85c95ca1436/black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73", size = 679439, upload-time = "2026-05-18T16:53:36.107Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/96/3c3e09f09f44a37aac36b178a279cd19aa7001bd796187a7b162a294c81f/black-26.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96ae2c733b2aabdd9986e2c5df628ff3473676cd1c5faded1ff496cf6d74083c", size = 1970639, upload-time = "2026-05-18T17:05:11.461Z" }, - { url = "https://files.pythonhosted.org/packages/83/ea/5ad117b9ee3ecd933c712bcbae610006e5b7cc9f41c526cd7ed3b6c4124c/black-26.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0e48b87e03bf109288e55cfceadcfa15ff5470aca2851a851950ed2926f450d7", size = 1792130, upload-time = "2026-05-18T17:05:12.983Z" }, - { url = "https://files.pythonhosted.org/packages/06/3a/7c448bc623fcdfa96672531beb5a616ea5e64f6975955254d7731ffb0ad9/black-26.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5119fa92ae61f786e8c3662fd60aece1d0a2dd5cca5d0c79417a95e7a4272a59", size = 1846134, upload-time = "2026-05-18T17:05:14.506Z" }, - { url = "https://files.pythonhosted.org/packages/a1/5b/0b39b3a5917f0657ac014ad2edb58c139553a478adfe7f817abf1622ff6e/black-26.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:30d3c14661f2792e9142cce3eeeb1cbc175b3eb5f733be0c8eeb99651e52b0c3", size = 1478883, upload-time = "2026-05-18T17:05:16.542Z" }, - { url = "https://files.pythonhosted.org/packages/4c/48/dc222692e0f95030db1bbfb6c857e76858bad09058221ea7aae815255327/black-26.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:1ef92b76f7733f282fd096ea406200b5a286c42947412b0eaff3a74e3616cefe", size = 1277776, upload-time = "2026-05-18T17:05:18.029Z" }, - { url = "https://files.pythonhosted.org/packages/24/99/7744b906703228264ef73bdd534df88ec1ef3de45c4e78f6d31b9e32d0c9/black-26.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4ad6fa01f941920f54f2bbb35f3df7673428a0ef98a0b0840c2eaef3b110efa8", size = 2012518, upload-time = "2026-05-18T17:05:20.108Z" }, - { url = "https://files.pythonhosted.org/packages/b7/c0/c5a3b1636dfd09c42534f2b3cf33506814f6d3e066fb0879ffa16c1ae860/black-26.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3915f256e75a2d7cf88d8953d37f780455dc586cc72dee059c528fe77f581217", size = 1816016, upload-time = "2026-05-18T17:05:21.84Z" }, - { url = "https://files.pythonhosted.org/packages/1f/0e/36044316b65ca471d3bb6d3703fd06fb50c6b727c3562f6a5a3153634f88/black-26.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d98d4137277c75dfb898ec8d846c4fd68ba1e9cf77f95e2865c203dc18f4c3d", size = 1884150, upload-time = "2026-05-18T17:05:23.546Z" }, - { url = "https://files.pythonhosted.org/packages/b3/33/dafc5808c2af43672912111d7c3354af1615f7e2be3bed7a878461abbe4d/black-26.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:a1dca32d9f1784af512a13410ec204c6f7f0aa9797a111c42e1c03449821c264", size = 1486825, upload-time = "2026-05-18T17:05:25.004Z" }, - { url = "https://files.pythonhosted.org/packages/82/14/b965ee6ad2a311f28bdbf692def3ee9848d2ae289dab28b27657fcee3e78/black-26.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1037d5ac7b7b310b2632ad867ec8d0e4c4819dcdb0b820f63135da746a24e418", size = 1288646, upload-time = "2026-05-18T17:05:26.477Z" }, - { url = "https://files.pythonhosted.org/packages/3f/5c/c384363980e11e25ca6b93205949bb331fbf35f4e0dbec376dfa6326cec8/black-26.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b36cf2ddf5566e205f6535f782a62194a184d33e175b64ae8c40b1737522be3", size = 2009020, upload-time = "2026-05-18T17:05:28.132Z" }, - { url = "https://files.pythonhosted.org/packages/0b/df/9f31c5e0babbfed77d505fc5d120beb98b21b33feaeded3924ea941fe360/black-26.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f7ea64ebfa01b50f693508fc39f875e264446d3b097088f84f203b9d09618a0", size = 1813335, upload-time = "2026-05-18T17:05:31.266Z" }, - { url = "https://files.pythonhosted.org/packages/fb/24/8e7b9a2fa61b0afd82209efe937557d180a1fa055bd7f6161eb9defc3719/black-26.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294", size = 1881614, upload-time = "2026-05-18T17:05:32.718Z" }, - { url = "https://files.pythonhosted.org/packages/49/ad/b4e0d9365ba8ac34f6bbab62a4b1b2dd5d618fac3fa1b8db968c844201b5/black-26.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:e1a26503279b6b310669fb0b219c39e4820b77e8189fe80f522bb511f247db0a", size = 1488925, upload-time = "2026-05-18T17:05:34.259Z" }, - { url = "https://files.pythonhosted.org/packages/a1/4b/652b859bf5df88a751c30451b09338f7fd26a77d1271c666992f836b7711/black-26.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c34b25da232ead53a6f335b76dbea124f4d152ad568b9080d6f944bc2b34b52", size = 1289883, upload-time = "2026-05-18T17:05:36.019Z" }, - { url = "https://files.pythonhosted.org/packages/a6/16/a8da8eb208c51c7f4ce74609a45d0dcc6d8a2141e45e81ee5289d1bb0d59/black-26.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168", size = 2004800, upload-time = "2026-05-18T17:05:38.182Z" }, - { url = "https://files.pythonhosted.org/packages/11/8a/a479296a19e383b70a725882a6cf3d786540601ff03cabbaaf1cce864c5a/black-26.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3", size = 1815576, upload-time = "2026-05-18T17:05:40.309Z" }, - { url = "https://files.pythonhosted.org/packages/81/6b/cfaf3d39f25132c156a068f6b805576c9103a84086019507c70e1911ee7d/black-26.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18", size = 1877927, upload-time = "2026-05-18T17:05:42.463Z" }, - { url = "https://files.pythonhosted.org/packages/66/76/302e313964bcff7e28df329d39f84f5270095730d85ff0acc260610a0d82/black-26.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:577f21094ea469ef92ec1adaf2c9441a226d2144d01a5be2fa823cecf6543e50", size = 1511860, upload-time = "2026-05-18T17:05:43.943Z" }, - { url = "https://files.pythonhosted.org/packages/27/4e/a3827e35e0e567f9f9ee59e2a0ab979267dca98718f25547ca8c6733afd4/black-26.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae", size = 1316632, upload-time = "2026-05-18T17:05:45.521Z" }, - { url = "https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693, upload-time = "2026-05-18T16:53:33.964Z" }, -] - -[[package]] -name = "click" -version = "8.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } -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 = "flake8" -version = "7.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mccabe" }, - { name = "pycodestyle" }, - { name = "pyflakes" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9b/af/fbfe3c4b5a657d79e5c47a2827a362f9e1b763336a52f926126aa6dc7123/flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872", size = 48326, upload-time = "2025-06-20T19:31:35.838Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" }, -] - -[[package]] -name = "iniconfig" -version = "2.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, -] - -[[package]] -name = "isort" -version = "8.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" }, -] - -[[package]] -name = "librt" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/04/f5/9dc696772d241814bacac7880bac32f2930b5a6ebc1f85317b83161a011c/librt-0.16.0.tar.gz", hash = "sha256:ac38d6d8d66bf3d744148dbbc0b8e193e195a51e364ed55e224631f5721891fc", size = 219839, upload-time = "2026-09-29T00:55:32.891Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/0e/b04fd2d76b6a78a02286938251b2c119fb36ddece92c76cb4886ef7d44b8/librt-0.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fe4372c52d4849096c6cc1cda2817d293ec51440c890474ed59ef38d46556f18", size = 148058, upload-time = "2026-09-29T00:44:40.903Z" }, - { url = "https://files.pythonhosted.org/packages/a3/fc/e1ed24aadb32d38603c0c07f579d739eb4d90e64e90d525b024e4a999b4d/librt-0.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c72c5295a84bd249526da9bdca38f2e176d15c31c13bb0063c5053f4ca023421", size = 153073, upload-time = "2026-09-29T00:44:42.165Z" }, - { url = "https://files.pythonhosted.org/packages/be/38/133ab655409420e0c34a9a9836a7f4e5ae75fba758d7fe177099130d58ba/librt-0.16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be56ba9c884143495b517f23fe794ae367d58cd89ea0fdd6d437e3c024a87f9f", size = 493052, upload-time = "2026-09-29T00:44:44.043Z" }, - { url = "https://files.pythonhosted.org/packages/94/42/aaa663bc9421f5bf7bbbe9d3316d7219749698ec7688ac3b519770a89c04/librt-0.16.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:ef46c1a29ffb8c72e882e22618ec618778eacd0578fb22c6e7cf9c11d15f357b", size = 485506, upload-time = "2026-09-29T00:44:45.479Z" }, - { url = "https://files.pythonhosted.org/packages/7b/59/83a6eb5087ba8b45b083859e2085256963e06d4c2531944cdd962670bfb5/librt-0.16.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d3c94211ee0c4f8d649ec06b7c115c0ec4eadb873a0e3154ca15cef3f814b071", size = 498708, upload-time = "2026-09-29T00:44:46.916Z" }, - { url = "https://files.pythonhosted.org/packages/a0/e4/15a8d62b2bc12935080ec7277e2e0b9b233047781eb784a51d3e25af669a/librt-0.16.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94aed6a8308818b91677957d1bd03188869cd7aeb23c5dba7912a6c0402f7602", size = 515981, upload-time = "2026-09-29T00:44:48.316Z" }, - { url = "https://files.pythonhosted.org/packages/46/4f/287cc282ac5dd815204c0779e79401bb0a568c3a1335fb319bf0838fb566/librt-0.16.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:349c0bcb87ebd07481b6ff781e25cdc699723dbe2212e57dabb27f7a13b7b87d", size = 508670, upload-time = "2026-09-29T00:44:49.726Z" }, - { url = "https://files.pythonhosted.org/packages/bf/5b/ab7292add898ade0d2b5b75d32a923aae315861671aa5c39f5491309f7ae/librt-0.16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:001bfd59a7d45b17e3e75f2a8c6405280b35e7b84471792778e718c4f368950e", size = 530393, upload-time = "2026-09-29T00:44:51.356Z" }, - { url = "https://files.pythonhosted.org/packages/7a/9e/8e6bcf027d4bcbc16a4e864b3d158a1d3e5f55ffa53af0238020cad89271/librt-0.16.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:28e038895b998d7a0c7798922ce8a1dc157675df5cf1c9ef0aca809ed804b7a1", size = 534302, upload-time = "2026-09-29T00:44:52.893Z" }, - { url = "https://files.pythonhosted.org/packages/96/a4/8c5ab4f3b3a8e905ff8305d76976a2e666f52fe049edd398aed2402beb36/librt-0.16.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0dbe4096a7ecc00fa835d24510ad8545a4efef738dac96e0e63516783ccde905", size = 536150, upload-time = "2026-09-29T00:44:54.421Z" }, - { url = "https://files.pythonhosted.org/packages/ea/5e/7c4bc267e3cd4b8bd288de9f62d61fff203829c9cc649ea2aa12e9218cf0/librt-0.16.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5cd5b092441053364af968ea12084692cb9d4a22f3ce9524e377880bf028761e", size = 514316, upload-time = "2026-09-29T00:44:55.998Z" }, - { url = "https://files.pythonhosted.org/packages/e0/75/c8bc6b50f43c73819334233f5a3259c81216742cef56d2140c3dd377c3f0/librt-0.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ddeb3c9dedb461bb457c6c7d9aa7fbf35329da313d1a7543d00c8d0f3473c96", size = 557731, upload-time = "2026-09-29T00:44:57.581Z" }, - { url = "https://files.pythonhosted.org/packages/93/fb/e241f91bc8de6c7ac1c6a20ea6dd381bc52312b44d711ffcd97140db1e57/librt-0.16.0-cp311-cp311-win32.whl", hash = "sha256:e05108e0849966f53a8d2d3112a7af881d0efaa479bc735bba91108f9f2350a7", size = 104922, upload-time = "2026-09-29T00:44:59.078Z" }, - { url = "https://files.pythonhosted.org/packages/d1/38/c892383d2bb1a6a80b2ec8ede84970a265a792c56614a06282bf3008517c/librt-0.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:5f49cff01bd608ef7d97104cb035c75455e79c2d70bf4a506cf773338ac1860d", size = 125093, upload-time = "2026-09-29T00:49:01.621Z" }, - { url = "https://files.pythonhosted.org/packages/e4/56/8bbde1ed0b21480163617648f638fdaec9374cf5e9c846ebcee34ec02a42/librt-0.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:1d28ae980ae2218f9c5b95d191e947296f918c9bf0b400d467a9430275bbe678", size = 115910, upload-time = "2026-09-29T00:52:40.131Z" }, - { url = "https://files.pythonhosted.org/packages/ad/76/bbdaeb87b7c47b5c7343e90222b9bfa4e4a8a83f647e02933ad0129225b1/librt-0.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe52bf4641069e7978a14253b036cb9002def1926317e710f2e249f8a8c47742", size = 151028, upload-time = "2026-09-29T00:52:41.508Z" }, - { url = "https://files.pythonhosted.org/packages/fd/0c/ab8ed3dab0085931aec4a792c7eaac8dc6c5ff4691fda3a5360d9d8a9cd2/librt-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5bcc2c4726ced915b00de0c9856a4eeabfb3fddb93e10e0b8f735b7709358b6d", size = 155283, upload-time = "2026-09-29T00:52:42.873Z" }, - { url = "https://files.pythonhosted.org/packages/eb/36/494e79d460c80c1f030661e8287c9eca5e1ad652dc2b2180b6cd42abce0a/librt-0.16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff7baa55f8e7c69851419e50a666015d02a74198716fd45c0125a2112e0a389f", size = 503185, upload-time = "2026-09-29T00:52:44.638Z" }, - { url = "https://files.pythonhosted.org/packages/9b/34/a8464038dd9db6e4381fa2b6eb73dc9a50888d77102c4c139304ac35cddc/librt-0.16.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b95d5d92ab83d39e760a52091bb1baba664f3a2351e39b1e16801e5747c2f0e9", size = 496738, upload-time = "2026-09-29T00:52:46.441Z" }, - { url = "https://files.pythonhosted.org/packages/6a/53/e0e5e334ef0c6ed27039d323819368b9ef6712be87d55ee2bf9799398afd/librt-0.16.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b6d085d70bce51d43c5c7c36d63490770180d8779e71c49305c87b4213918de7", size = 513818, upload-time = "2026-09-29T00:52:48.068Z" }, - { url = "https://files.pythonhosted.org/packages/ad/f7/7ce72cbf19d0addd05090b339152fd0548a02562c2866a603e6e3b3da2df/librt-0.16.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36e53948e99bbe3ffea257124cfcae1cfb01831555c9a9c903c9f9a72db7fd07", size = 531952, upload-time = "2026-09-29T00:52:49.816Z" }, - { url = "https://files.pythonhosted.org/packages/83/22/0b1bcb6a8e723c8b4fd60dfc8ae8ec6461c54073fbc8685efeb8d900d407/librt-0.16.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:54d11f726aae9df5a6ffbbf0a03a52449bbac84a53ef03669cb41cdfd4ae41bf", size = 524465, upload-time = "2026-09-29T00:52:51.354Z" }, - { url = "https://files.pythonhosted.org/packages/64/2e/e9c23b8b9df1813da1be205deca9606beb7ddd033972246cd426d05374a0/librt-0.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4323193ac0cd025f85af531df8ba91bf24d1973b401697347a6282e8fd3fcf5e", size = 543191, upload-time = "2026-09-29T00:52:53.284Z" }, - { url = "https://files.pythonhosted.org/packages/bc/e5/6a8b21b342c03ed7e230fa3afbfd2edc58e6e87ef1f0d11fa2b9a748c252/librt-0.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e42f8e098b9c5396fefa05fb1cc7e33b0e08fc51da106b5de4a45fd22aac6743", size = 546979, upload-time = "2026-09-29T00:52:54.932Z" }, - { url = "https://files.pythonhosted.org/packages/84/9e/b5129023eced1be01e01c22757f53be551d463b1bb7264f787927404c1d6/librt-0.16.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:39ec1d5a14e37baf1450a6cabf03fe552340808bf1ad9d71824ab90117716459", size = 555317, upload-time = "2026-09-29T00:52:56.869Z" }, - { url = "https://files.pythonhosted.org/packages/71/89/28bba5938c725fe91f06bf93f7fa6c6b150229df53a87b454b0d5c2a796e/librt-0.16.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d1aabe3925cbb4a08d15b7b20ba4011b53019da0c4173a25155139b7b1baed65", size = 535846, upload-time = "2026-09-29T00:52:58.475Z" }, - { url = "https://files.pythonhosted.org/packages/22/92/63773026614f888c5d4e370e395ce42ca604b89f70b3acdfedbf94851b80/librt-0.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:300c3ffdc459f4a779a8411ecb188e3ac0b1ff3a3a7b099642555dedae06c69b", size = 573700, upload-time = "2026-09-29T00:53:00.127Z" }, - { url = "https://files.pythonhosted.org/packages/66/8f/347d4677eefb57cd9f8e01d95a1dcee9a91b4e44e664173c32ff6f3752e5/librt-0.16.0-cp312-cp312-win32.whl", hash = "sha256:c17194318e4c0c0348b36f36c2ec7534436fe0a4c15582403162a4f08c80797a", size = 106158, upload-time = "2026-09-29T00:53:02.031Z" }, - { url = "https://files.pythonhosted.org/packages/f0/2c/5193dc81127cd5ddfad031391b046bf32dda219b38463ab872407ca30646/librt-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:25a58a19ea8d83b68209f04912df765e9260635ef77646542ed4b4abe6bc7940", size = 126176, upload-time = "2026-09-29T00:53:03.445Z" }, - { url = "https://files.pythonhosted.org/packages/ff/3d/9668a400c8dd81d162eba38b33fa49fa6205f1a64493570a13fbb815c3ee/librt-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7be7cf555bc30ec12622e9447299cc4a9b8ff307548b634794353db0c2065dc", size = 116572, upload-time = "2026-09-29T00:53:04.815Z" }, - { url = "https://files.pythonhosted.org/packages/46/cd/ae5e0e9dba45d1399aa04a5395bcc0bead40d9fa06dc903634a7b4d7473d/librt-0.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c5e6144e68b577f157519f2ba88ca20e3ed61c29b00e5cdfa76cd2d45acf059a", size = 151036, upload-time = "2026-09-29T00:53:06.284Z" }, - { url = "https://files.pythonhosted.org/packages/41/5a/48a16e323c5f9447a94cce7b59babf60fa04e62c3365ecf060c77ed8b320/librt-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:33f41443a1f4e1f099331b3d8120e409fbff84b9760bc1cc9ea496f37ddaa5cc", size = 155211, upload-time = "2026-09-29T00:53:07.72Z" }, - { url = "https://files.pythonhosted.org/packages/3f/29/0f59299eb4251a409b2e690ad4b7d9f8a676db829d7817ec961f32b44f7e/librt-0.16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e510b7770bee609617a3374a96548eb114cae048023e3f049ee449e7ff2db32", size = 502587, upload-time = "2026-09-29T00:53:09.191Z" }, - { url = "https://files.pythonhosted.org/packages/de/ba/d6fb4ef8d1537c396079d72289f16be7cd35a366e5065c51253fea2760b6/librt-0.16.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:efc49c462d4516b8a58b00b490078fa64689fd1fe66970cc190131d7afb8027e", size = 496146, upload-time = "2026-09-29T00:53:11.016Z" }, - { url = "https://files.pythonhosted.org/packages/52/fc/8c50dd4d7cc97c0ee8f252c8a3104980f234391cf1519b554e8b9de08b60/librt-0.16.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:92caf82ebef5e12d21c72242b70d1e92536f1711cf2a727a4c276de4b4469087", size = 513336, upload-time = "2026-09-29T00:53:12.655Z" }, - { url = "https://files.pythonhosted.org/packages/a9/59/16c409c56f708eda2db9a0553662845d45e3871c77d70a240dae3f3bdc56/librt-0.16.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17bac7f7a16b328fff77e440287693eb017abde913595b5827ebccbc21ecd8a6", size = 531689, upload-time = "2026-09-29T00:53:14.39Z" }, - { url = "https://files.pythonhosted.org/packages/88/82/d34772a6c29d1446dcca6e64d74062efd508523ab351aa16625a4689d5cc/librt-0.16.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5b976054553670829985ed767feb78fb6bcede0175327c4844dd5c281c1be659", size = 524537, upload-time = "2026-09-29T00:53:16.064Z" }, - { url = "https://files.pythonhosted.org/packages/77/8f/24c5631313746131ccee53bc91fdc8374f9cf25e0082a1fee9c93bb98acc/librt-0.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0058f9d68721094105917254c72ac0569117bb7b13b9769cf45d26d89f9d21cd", size = 543231, upload-time = "2026-09-29T00:53:17.939Z" }, - { url = "https://files.pythonhosted.org/packages/f2/cb/5f8e0d41dbd8b499c2265e939c31acc9ba59845565bf99539ad1c06aebcf/librt-0.16.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:30b7beaf3f4487b7d8adef1f158b49067cb4d5a19fa7a3bf31a4e7a820e435c5", size = 546499, upload-time = "2026-09-29T00:53:19.666Z" }, - { url = "https://files.pythonhosted.org/packages/be/61/063052de441d1385f59cea4223f184bf9e5d125de1ae3239b490aa1e486e/librt-0.16.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:468df902df016a06eb0e40b0747dc8d14e47d7a38b18b63b1fb167d85cb94d63", size = 555261, upload-time = "2026-09-29T00:53:21.292Z" }, - { url = "https://files.pythonhosted.org/packages/14/11/a2ada0529372268d6401afa9d457a095b68cd7753532b6f7f33049a19b43/librt-0.16.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:aea7b1f2b125dad5de85f049136651bff256c883c65e6b9209b2da0a1ac3cdef", size = 536029, upload-time = "2026-09-29T00:53:23.07Z" }, - { url = "https://files.pythonhosted.org/packages/23/9d/5bb6d38853382986dca702fc7e06c8256d30f5fa0676d882773b744610dc/librt-0.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a8afb6557920860b7a3a596eb804cf37e09e7cf8a803db2478c202acc72d8c2e", size = 573748, upload-time = "2026-09-29T00:53:24.696Z" }, - { url = "https://files.pythonhosted.org/packages/99/f6/0025cde35ff7f607684dc775a2b2d732cce2561cec050a6ee1fb2e1fc6fe/librt-0.16.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:77c7a2b4fe2c1369e0d5aa1cade26740a7b14be32fbc9a5535d617d20065c39d", size = 79861, upload-time = "2026-09-29T00:53:26.089Z" }, - { url = "https://files.pythonhosted.org/packages/93/93/303b8592909bd583f83f02818ffbea3f7647ca1e22b1cd5465d04b8145fe/librt-0.16.0-cp313-cp313-win32.whl", hash = "sha256:02d89c813d5ff74b17df72d3a34819d132cd168e56b81bf755b809bd9e46b8c4", size = 106236, upload-time = "2026-09-29T00:53:27.43Z" }, - { url = "https://files.pythonhosted.org/packages/cf/24/80bbb463c60ed18e29cb26aba386ddb76609580e4e7160710e550587018c/librt-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:14ed6ebe3e4f85f326d7920011ad30ff49ed9334e62cf88caef9ba973d9e3a92", size = 126180, upload-time = "2026-09-29T00:53:28.7Z" }, - { url = "https://files.pythonhosted.org/packages/43/80/b1a6fbdd7da825cdd55c71aa81eb6cfa82c513c360774152eacb50b4a771/librt-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:83d4041a3d9b2fd053a8a4e1f22878b3e5833e2712956382d5c048d791454e91", size = 116557, upload-time = "2026-09-29T00:53:30.012Z" }, - { url = "https://files.pythonhosted.org/packages/1e/93/9e0cf7da129a93c3dc7f45bc3cd4a660f2aaa995aa8a6f95c2583ef41239/librt-0.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:931a0bb0fcac88f263e269e46eb30ba8e21402cd3c62ca40cb97034c0693fab1", size = 149844, upload-time = "2026-09-29T00:53:31.391Z" }, - { url = "https://files.pythonhosted.org/packages/8f/26/8a90d2a8f2b2e471bb486b7aec117b8ae622715ed6c39853aec48ea20073/librt-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1bc17e54e5305f8d40b7ca203671ff5a9e59c1d0f8ea0f625dcca53a3984de11", size = 154111, upload-time = "2026-09-29T00:53:32.718Z" }, - { url = "https://files.pythonhosted.org/packages/35/ce/67abb46258da4d3e42ff5b141db6f38c59357bef84c7979f183b22f924f1/librt-0.16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:877698bf6bca5721d8be345f2fe09778e40ecadea8b58c73075f2b1a53666bf2", size = 494234, upload-time = "2026-09-29T00:53:34.466Z" }, - { url = "https://files.pythonhosted.org/packages/12/f9/ea7162414a16f8f1bbd3b493ad6d22b926c5471916e078350bdbca8c4e5f/librt-0.16.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5981c011b306781ce561e18e14230a14524a3d8109b97553666c942c18f31a96", size = 491173, upload-time = "2026-09-29T00:53:36.124Z" }, - { url = "https://files.pythonhosted.org/packages/b1/09/9b3e869060dd33f9989b80ba4fea306f6db8cecefcdfe6d7346ef0603f65/librt-0.16.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:afced3dfc17cd805ecf7a3d77996a71cf5f2c75aa66eb0c21a9930f4fc992f86", size = 505547, upload-time = "2026-09-29T00:53:37.686Z" }, - { url = "https://files.pythonhosted.org/packages/50/07/79007d2165f649ea93e08c0962d1d62c70af9cde77965255095bf9d96f9a/librt-0.16.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca8052401c55d7511dda6760719fda7618067e83535d7d0010096d216c34b667", size = 523057, upload-time = "2026-09-29T00:53:39.301Z" }, - { url = "https://files.pythonhosted.org/packages/61/0c/8fbaff66d0ba376d8864653f5acce1569bae27e89648671f26f7eca67ab8/librt-0.16.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1e511762a074005bb0aa569166779834e75e438370226930d0ce1866d4b6a33b", size = 515158, upload-time = "2026-09-29T00:53:41.012Z" }, - { url = "https://files.pythonhosted.org/packages/df/2e/23ff0dece76f07a4124413a57682efa0bbeb5765ac0122bc0955513f82ca/librt-0.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1e8591bd8a5a628cd7f07954c6a1592359a878bf032957a8e9057a41d644311", size = 534183, upload-time = "2026-09-29T00:53:42.451Z" }, - { url = "https://files.pythonhosted.org/packages/26/c4/e11dea21d9a29486eba78887380374189d472734fa32cc50cb37ca44d3d0/librt-0.16.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4aaefb4ba6c07e1aeebb2795c8958148f1d6f9af3b555b53d23d766edb6d67a", size = 540663, upload-time = "2026-09-29T00:53:44.272Z" }, - { url = "https://files.pythonhosted.org/packages/44/75/e873ae158a8b7f5359be33e7fd6c1fbe02d9a78a3e89a77de6b0e837f476/librt-0.16.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d92db7a0f6aee44f1baee94750457e8d2d1c6ccea41842de6268d34e8dc7eddd", size = 545908, upload-time = "2026-09-29T00:53:45.812Z" }, - { url = "https://files.pythonhosted.org/packages/ba/36/8939d3f6a93e11bd9592e6fe28d2b44f1c2dc4bed6e22e72359a91e18ffb/librt-0.16.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:378dfaffb38e59c24a87cde5713cd865d51ff7383fa12947f3907f306ea1ca55", size = 523302, upload-time = "2026-09-29T00:53:47.596Z" }, - { url = "https://files.pythonhosted.org/packages/71/14/35309f44a077f0f42ade0e2e7cd88c0cea760c661af205c01ea90d3c0e1f/librt-0.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3e0c39bdc85370422e8b637be76eb1fd07d30967551b03e62267dd156f553152", size = 565911, upload-time = "2026-09-29T00:53:49.278Z" }, - { url = "https://files.pythonhosted.org/packages/78/0c/df6255b94967f3159ebc46f08d8e783c12da6ee269ddb74b2efed63c640c/librt-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:1b384b90ab79a7bc30b566895809a636e0666f21f3cf12b54823d025b7e83839", size = 79119, upload-time = "2026-09-29T00:53:50.938Z" }, - { url = "https://files.pythonhosted.org/packages/6b/44/d30d5a5461378c9d33f36736c6791c3b4c4ba4b1ffe0da7350aedcb2c9a0/librt-0.16.0-cp314-cp314-win32.whl", hash = "sha256:52327da75a94012e7f932f913d20d3876bed3c102be00e6c3e8600ff7bdd58a7", size = 100140, upload-time = "2026-09-29T00:53:52.205Z" }, - { url = "https://files.pythonhosted.org/packages/c2/98/769712f356a1e897df3581bb0c3100375d054a000de26099360ea65b5111/librt-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:3f0b8114c44b2ac06ff5dacd08e07e8e807ff4f46083f2a1602685122559be41", size = 120464, upload-time = "2026-09-29T00:53:53.495Z" }, - { url = "https://files.pythonhosted.org/packages/bf/d3/ae2abccc8bdc8b063c1613a77686e17b74e9d3d60cfe6f12c63fa2821b9f/librt-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:8caf96a4ef8fb27d0ac0d1ad8337d26a240acd4a02fe4345d0a8f264753e8f99", size = 110889, upload-time = "2026-09-29T00:53:54.817Z" }, - { url = "https://files.pythonhosted.org/packages/e4/26/0737d4be058dd6376eade7dd8b380d4b869b6394cee929393a5a431c45bb/librt-0.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:953107e2f68d0f3512c48f898b0dbf0ce5cc52bba0f318d847c985dc555ee4cc", size = 159838, upload-time = "2026-09-29T00:53:56.22Z" }, - { url = "https://files.pythonhosted.org/packages/01/96/9bc96531d7c620e9949af904470f02e3fa8f35129ab8e8f281c51eaa3788/librt-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad37d5b9abd49c9a655dcda7ea52a8a752884062ef1ee71ae17c2f2a0f81fe6a", size = 162018, upload-time = "2026-09-29T00:53:57.532Z" }, - { url = "https://files.pythonhosted.org/packages/0e/fa/b0289dcb186eb3f97221ba00da5f4bd3ba7fa5e99752d48aa8d336615334/librt-0.16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c4aa329c17bd1aaea4f6e89335d8ccd494b3a5830b6654462273e50e11023f0", size = 703983, upload-time = "2026-09-29T00:53:59.024Z" }, - { url = "https://files.pythonhosted.org/packages/d9/ab/05ebbbde7530fc5eeb58fbd1581522a9f64f132fa703c4c595eed6e14760/librt-0.16.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0ead24d2562a49473dddd9efef8581f020007eb0054389c3ee3ffad38b1ca4c9", size = 683847, upload-time = "2026-09-29T00:54:00.671Z" }, - { url = "https://files.pythonhosted.org/packages/a3/75/f52aeecd4dbadbddf80725ba7de126d8bd5d0eb66247eae17a81ed90dd4d/librt-0.16.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02118f56a9c36ddd07dfd9b919d9ecc117ba20a90987d56aa4c429fa34509188", size = 698045, upload-time = "2026-09-29T00:54:02.247Z" }, - { url = "https://files.pythonhosted.org/packages/e6/55/fa277a835cd6eb42380591ceb85f48c5b4d2b2d7e2cb9869e1e17d24d237/librt-0.16.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e29522c62e28595ff7e324c6834ade51127707f0e255b18d1c1cf03d39c1048", size = 724640, upload-time = "2026-09-29T00:54:04.078Z" }, - { url = "https://files.pythonhosted.org/packages/25/e4/2cf64354f3fde8ebd591b3b48f96510bee24ac5f7d1e567adbbe210abdbd/librt-0.16.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ff4b2367926b69c6215635902cccb04048e73094e9862900d27cb2c6bbff143", size = 731748, upload-time = "2026-09-29T00:54:05.741Z" }, - { url = "https://files.pythonhosted.org/packages/f8/c9/c180af3e94e01aa529fa93d7733fec2abc47f222345400cf21d5481d5f8a/librt-0.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c6f1b27bf1632a7e016af9f145f82be95e1edd7721a646505c21059257cb5a04", size = 754960, upload-time = "2026-09-29T00:54:07.38Z" }, - { url = "https://files.pythonhosted.org/packages/95/d6/01073aa78c58f356b10d9c57b3fe9abb143df338142bcd316df417b89db0/librt-0.16.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5696d7f52e7b37217cb3a8f92c744fe835942602fdd4c1a8bc4741d3bfdce15e", size = 747000, upload-time = "2026-09-29T00:54:09.06Z" }, - { url = "https://files.pythonhosted.org/packages/f7/d8/1de3783908658d697a8cfc00582f61299ffba7796d7260c112e4700b1109/librt-0.16.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:6072e92dd876ff6ceeb6cf371e35e51f479349837391341f479b08df4564242b", size = 749569, upload-time = "2026-09-29T00:54:10.702Z" }, - { url = "https://files.pythonhosted.org/packages/b7/32/e817f66c96d6caa8bb8435ff93c4c220624d59efc506be59ab98fcd01d0c/librt-0.16.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:39ca4f2f2fe05de8e63493da592d84311adabe5bef52b193851981da9816b302", size = 729483, upload-time = "2026-09-29T00:54:12.25Z" }, - { url = "https://files.pythonhosted.org/packages/7c/c9/23992ccd2b9d22798fdd0f61353183a47414e651da782ad83eb680f50833/librt-0.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9807485a908f00355820f18e91e045ffdcdc5adb68aaec40a1e2b88c5f7bba1", size = 776584, upload-time = "2026-09-29T00:54:13.837Z" }, - { url = "https://files.pythonhosted.org/packages/67/3b/e8af957f08e6e2e8e566b099e43d2748418aa565df6ee1d9d3331209fdfe/librt-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:94be5cb7bca4df6201f4183e9e4fa2086c655283d20b38cd84500a69057575a7", size = 104402, upload-time = "2026-09-29T00:54:15.568Z" }, - { url = "https://files.pythonhosted.org/packages/ca/1c/946e6443d7cd32347a086043395e421e52fd603c9163d2ec970ceab8eed6/librt-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d46ca272b251d033dd4527b0dec5f261a28a52bd5fa0f99c117b0a1f8588cc2d", size = 126227, upload-time = "2026-09-29T00:54:17.165Z" }, - { url = "https://files.pythonhosted.org/packages/c2/a3/bc4f9959d3c62bcbf9e5fd3470a8bbd8bf33224ed4c25d7fba173201b8ac/librt-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b9d6d4b14e92d876f8026b54c20c445f36425214c1081dc76f74e40db386b82b", size = 116493, upload-time = "2026-09-29T00:54:18.567Z" }, - { url = "https://files.pythonhosted.org/packages/b6/4b/10fdb42dfab4c1533e1570e686b18e86ff4328b406361b39fb3016667638/librt-0.16.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:6fe436af2eaf630474f491af5d032cbe45f93fcff5c3b9fe4ab194a7255b20ff", size = 149829, upload-time = "2026-09-29T00:54:19.933Z" }, - { url = "https://files.pythonhosted.org/packages/8e/30/a90ca13f1d3d91af1680000a4038536907018fbd51764767287a05d28b8b/librt-0.16.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:8ff5d26c529336be9bd7ae04483235d77778ee7d6444a95353102b542601ce81", size = 154522, upload-time = "2026-09-29T00:54:21.306Z" }, - { url = "https://files.pythonhosted.org/packages/5c/dd/bcf364eacfa070bb1fc88d503111ae7177914197ba1fa717c748926e7930/librt-0.16.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:909d8e3c1faee44cb762b1c519ff8613dcc5ceae5c99987a00917b5a31fd1d6a", size = 497593, upload-time = "2026-09-29T00:54:22.798Z" }, - { url = "https://files.pythonhosted.org/packages/18/c1/2c4e81e347bdabfe8346bfc6dc37ae5e154d61c88e30848ec0606025a5e0/librt-0.16.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:6d4a64283ee61824b5790de882bc68e2d9d7a5143537cb7a966f7354f71646d4", size = 480459, upload-time = "2026-09-29T00:54:24.364Z" }, - { url = "https://files.pythonhosted.org/packages/ab/d7/fef2a3cb8400701be496f6e459876f650b3451f407e30cb243de571ae615/librt-0.16.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5810ba811297fdf37a1531a57667cb8ace0842013ca8606bf9eb7c24cf4be154", size = 507814, upload-time = "2026-09-29T00:54:25.997Z" }, - { url = "https://files.pythonhosted.org/packages/e1/6f/53762927a32e9dc9eb1d1c1f3528da281290c86d96929a8af652f671266e/librt-0.16.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e56aaf8c167548dc8e5d6f3bd0f48dcdd299a23c73be3f744aab79d99e9c7f5d", size = 525114, upload-time = "2026-09-29T00:54:27.747Z" }, - { url = "https://files.pythonhosted.org/packages/6c/67/0b9d031f303c4e8c691a9a8ef9d272f13b530c11cc563b819df621b6a348/librt-0.16.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f36c58e33b304b525c6c9c5076399c6ebf1109e17b9051a05a407b091b9215b", size = 520465, upload-time = "2026-09-29T00:54:29.331Z" }, - { url = "https://files.pythonhosted.org/packages/ae/d5/2056a3a85864e882eb17a203a10ddb26fa748bc9718ec67e79059ab46cae/librt-0.16.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:242e00b3d4fa37c3d3c1ca5f5c9adb7d909ddb1eac9c41f2787320d00caa0af2", size = 537257, upload-time = "2026-09-29T00:54:30.915Z" }, - { url = "https://files.pythonhosted.org/packages/54/57/e0d79790c163cbc0909e209a6f62e30bb713b647f905a176cdc64848fd4d/librt-0.16.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:0253721561787b8df8443eb347b7a6461015354e5bdd37ee38a41fef220d2bb0", size = 527442, upload-time = "2026-09-29T00:54:32.478Z" }, - { url = "https://files.pythonhosted.org/packages/aa/50/1c0c95aba7af51f4752ea34fbf2eb79b36e7cae3a72536248e8c735de735/librt-0.16.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:3e483a8d69ede8067db70c0e83007423b6925de6fd53afed01d66160f2e9398c", size = 548139, upload-time = "2026-09-29T00:54:34.183Z" }, - { url = "https://files.pythonhosted.org/packages/98/91/a8a43dd5138d4f55f88846b8f0c85454a4fad69952cebfcff9948f831290/librt-0.16.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:69ba927445cfaaffb4081003ef5224c55a5c2ab67ef956f416ef744916e44121", size = 529708, upload-time = "2026-09-29T00:54:35.761Z" }, - { url = "https://files.pythonhosted.org/packages/2d/41/d5226881ab2b7c20d9d587b37bdd4a0ec8775a96d00ca87ac9f385587db4/librt-0.16.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:d6a365f2ab45a984d0e00eee0dd17f599ceab8cadab6ea07b6111c8132fc0e42", size = 567781, upload-time = "2026-09-29T00:54:37.409Z" }, - { url = "https://files.pythonhosted.org/packages/bb/bf/2345ba57a626e8c78c4ddcc724636a8df5a593fe46c1ee76bbf477e32b0d/librt-0.16.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:f01f3805f2dae4781c0c34b440e31740d082950bdaf89a6f601ad589a28af57a", size = 79177, upload-time = "2026-09-29T00:54:38.792Z" }, - { url = "https://files.pythonhosted.org/packages/1e/40/99e77936cc9207f629b077bf7cbc5e2f0827cddd7c29792f04ef881bd2c3/librt-0.16.0-cp315-cp315-win32.whl", hash = "sha256:b0e3e721c75d2e79a76d4422c79d7ba705fe1bbafec907037fe7a657a480a0e3", size = 100127, upload-time = "2026-09-29T00:54:40.251Z" }, - { url = "https://files.pythonhosted.org/packages/56/1e/801fe26bc622061b9dfd010e166d94142cb774b733217e6d98d1c0cf2638/librt-0.16.0-cp315-cp315-win_amd64.whl", hash = "sha256:bc02954b1295de798bbdb0b4e2d8a28c2117de8b5c73dcbeb27dc32572dfb971", size = 120480, upload-time = "2026-09-29T00:54:41.722Z" }, - { url = "https://files.pythonhosted.org/packages/bb/a9/d533983055bd36e112627384c2c038845d1df882540b8bcefb566475640b/librt-0.16.0-cp315-cp315-win_arm64.whl", hash = "sha256:c5db585d43449a5f54303d4b2774e45e1babd975cfe1630a3d708c0b80c3e560", size = 110915, upload-time = "2026-09-29T00:54:43.052Z" }, - { url = "https://files.pythonhosted.org/packages/55/fe/d62238fa9c653b0e0613290349467cb65711b5800e8e474f45e942a0ad95/librt-0.16.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f06c689cb14afd9b612727553a5ec5a40febf113ca41c4413a2b0b334285884b", size = 159714, upload-time = "2026-09-29T00:54:44.497Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ac/f31efe7818700be72ba4f9af8a80fa67c39808c8d26dacc55dc1f6f35172/librt-0.16.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:13b4e8aba90b0b1c82474e9844aa9ffe7ad3faa484350e1da64cb8188d903134", size = 162086, upload-time = "2026-09-29T00:54:45.968Z" }, - { url = "https://files.pythonhosted.org/packages/d9/16/4d7487bf86a9d7e8e18f37ba5538789233ea693637379b75701bd35ec9de/librt-0.16.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a269c46ae327d8e6f8c1f85f7516cb52c0fa48127565a1105a4f4a05ff2a0b4", size = 712727, upload-time = "2026-09-29T00:54:47.486Z" }, - { url = "https://files.pythonhosted.org/packages/77/74/50cd550ccc1a517b9ed62347625c01ddf8c4afad66490312677c011458f6/librt-0.16.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a33e0dae1f8592146a4764d54ce842b278732d21a84e17c3bbe6b1bc158a2248", size = 681106, upload-time = "2026-09-29T00:54:49.126Z" }, - { url = "https://files.pythonhosted.org/packages/df/5d/7293f712975ee6fdd2251411fd9ef1c62bc99c7b83ecbe62dbc999b1fab7/librt-0.16.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:47ada6ea32636492c61aa8ad27ae3b9404bfe7a97e3ba946d1984236cc741da0", size = 705921, upload-time = "2026-09-29T00:54:50.905Z" }, - { url = "https://files.pythonhosted.org/packages/67/f7/8aab946f11d59d1bece9ffc65d994b200c789a8ca5a95c17e17e609f912a/librt-0.16.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43bd6e642d8a248c114327f98dd25ac5a7cb5aa168ef02f0559b91874df16b8", size = 731786, upload-time = "2026-09-29T00:54:52.584Z" }, - { url = "https://files.pythonhosted.org/packages/43/76/1c42ab31e7cb8384ebf6d3af607213c495222474f4940443ae7639ab7685/librt-0.16.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c3d1bb7841a816ace6449bb26d3f9560dbfa20e71c568d23f0f62bf1e68f50b1", size = 744964, upload-time = "2026-09-29T00:54:54.217Z" }, - { url = "https://files.pythonhosted.org/packages/6f/2e/4b19982d933d2dfced671e840b219e4c1cd3f507df6e6dabb51bbd4e3850/librt-0.16.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3931f7a3db322e7f44e02a280e3949326ce9579ad388ee8d691dc7c76da9fb70", size = 765305, upload-time = "2026-09-29T00:54:55.815Z" }, - { url = "https://files.pythonhosted.org/packages/b8/1b/e872583de2dcb3ac7746e7a2321aeeb168274f3952a87dc66e05ddb29faf/librt-0.16.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f4462528b6000afe8f16907b5c7c2553abf1df005ba5140e6eb394541c3624c3", size = 744959, upload-time = "2026-09-29T00:54:57.532Z" }, - { url = "https://files.pythonhosted.org/packages/10/de/a18c6bcfb297af2674233e90b3c3661c3a0af0f1434a0c966d2ef708a835/librt-0.16.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:80039ba9b6a7d5f1a0175a4cca6bbefead87bd854c80abad1cb30afe47a830db", size = 758138, upload-time = "2026-09-29T00:54:59.484Z" }, - { url = "https://files.pythonhosted.org/packages/22/1c/0df1d732539c297bb1a093e1fe3204d2faf76a9022cf4e1d1c2fce059790/librt-0.16.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:7a1d272724b581bb6bc769dfdafed6da2ecc9886ba2450311de55a4ac2e1e9cd", size = 744268, upload-time = "2026-09-29T00:55:01.179Z" }, - { url = "https://files.pythonhosted.org/packages/a2/f7/ccaf31331f20c91a5bd9bd48ffc3f743f9c81bb5720cc7b2a720ca204f0a/librt-0.16.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:fbe4fb8c5445f7496d7f7f6bb0807875d09d47e6771ffa175fb2df2895fb86ba", size = 785783, upload-time = "2026-09-29T00:55:02.938Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ea/7421d9e6db894cd6cd788b604b3394a163ded6b942052b6f96c23ccf08e1/librt-0.16.0-cp315-cp315t-win32.whl", hash = "sha256:375bfe6b572a8f6cfc398709356046173bf27e64c4c5edaf5f7062f051fb4bf9", size = 104355, upload-time = "2026-09-29T00:55:04.533Z" }, - { url = "https://files.pythonhosted.org/packages/85/6d/7c31a506eb847bc58aeb2402d58e17605e821f68ab5df5e66fe274a6df41/librt-0.16.0-cp315-cp315t-win_amd64.whl", hash = "sha256:bd3150023d3dc2bc70f3784e59ffa1140d56ddba3d8125b3d6f9f85221279bfc", size = 126134, upload-time = "2026-09-29T00:55:06.017Z" }, - { url = "https://files.pythonhosted.org/packages/36/69/7a5d10ac409c4da0355e054a14371871da9b5557fcc42772cd00181c6cce/librt-0.16.0-cp315-cp315t-win_arm64.whl", hash = "sha256:8ceafb70f2a4f0826f11031942e59c0728fd98da112dc346d4352bde1e486866", size = 116391, upload-time = "2026-09-29T00:55:07.484Z" }, -] - -[[package]] -name = "markupsafe" -version = "3.0.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz", hash = "sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6", size = 153777, upload-time = "2026-10-02T23:07:22.29Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/55/18dbb4778b30ada5ce071608503cc3edc9e14e13d868c17a6d178fc30f7a/markupsafe-3.0.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9e25feb9e330b63edb0278a0acdf85e50d0cb0fbf49c3084abbe4e24ae195346", size = 11520, upload-time = "2026-10-02T23:04:36.299Z" }, - { url = "https://files.pythonhosted.org/packages/6c/14/0b05f79b4733e264a18d08fe08fa1df7347630ff32a6cb82180d9dccec55/markupsafe-3.0.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7d3391b2188d18737cb2fa147028b1096236eaa7e156446c650a489fa2cadc91", size = 12063, upload-time = "2026-10-02T23:04:37.291Z" }, - { url = "https://files.pythonhosted.org/packages/ca/3a/63ba10b6c1463216b3e4df669a9f0e5a3b0c3071557d2e8229e3968c79fb/markupsafe-3.0.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849dd2bb0e5e4ab2b71c7191726a4a8d5aa8a610daa584728cbee0b710ddc4ef", size = 24321, upload-time = "2026-10-02T23:04:38.262Z" }, - { url = "https://files.pythonhosted.org/packages/1a/2e/5f015261b76ad633d187ef6f388b413aedd64a8773c4df59e530a0be5525/markupsafe-3.0.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:befb4158af32106b9a93db8d6d1d1cbbd418c0d5aca0cabb7b1780abf0c89169", size = 28845, upload-time = "2026-10-02T23:04:39.295Z" }, - { url = "https://files.pythonhosted.org/packages/33/cf/26e594b26be40c2f1fec63ccf8a8b99d0335a5b2cbe84835c7d82a994375/markupsafe-3.0.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71f88e749ea29f67f21f3b36433c1dc54c7729ed2a6d9e2da2e0d9e0d7b224eb", size = 24701, upload-time = "2026-10-02T23:04:40.348Z" }, - { url = "https://files.pythonhosted.org/packages/81/a5/a513b76c139a3915b43404324e55c0b7979ae4f0d39eb6f075b0282e90a8/markupsafe-3.0.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6da83a088f8ef93b2d483a8232a4dbf4d69d3d8496b568a03c56becac43e1808", size = 22961, upload-time = "2026-10-02T23:04:41.372Z" }, - { url = "https://files.pythonhosted.org/packages/46/cf/4c66192c100b4542bcbe392ae06696b670f66927be3ac38a213234778ff9/markupsafe-3.0.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f0fac8b13d14bb06c68195f849371924ae53dd7b1c00fed24650f704383b692", size = 21920, upload-time = "2026-10-02T23:04:42.429Z" }, - { url = "https://files.pythonhosted.org/packages/cb/17/ac3662678bfbad649893117ada2ba44dc30bf56884e84f13154a792b10f1/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4a7cdc2a420ca01058182da4253329764d4bfa055564d1eced90e6ba1e8b1d3d", size = 23726, upload-time = "2026-10-02T23:04:43.531Z" }, - { url = "https://files.pythonhosted.org/packages/f7/af/fe47cee339180a69ebca3c57fb3483d0f5cbd1e8337d1871fb1d9c1aebee/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:83b3944fea42a8400edf92fd1770fb8d0d4f7de651353bd2d8525a92dba69a21", size = 22539, upload-time = "2026-10-02T23:04:44.618Z" }, - { url = "https://files.pythonhosted.org/packages/12/32/d55440ba140442800e02d799c9cb5ab597bf6ebdb1177b5ea39a11f797bd/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:8138eb83940ec7299024d92d4dee45f601b9e6c5ffde9d25f4e35e326203c707", size = 23963, upload-time = "2026-10-02T23:04:45.656Z" }, - { url = "https://files.pythonhosted.org/packages/50/9d/9c86042cb364c2ad4c971e6d1247929effd25f714cd7ee11b05e6316445b/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:811d02d5122171c1941357efd8f9bf4ffe907b7f0a1a4e729a880e4be3f46e3e", size = 21506, upload-time = "2026-10-02T23:04:46.671Z" }, - { url = "https://files.pythonhosted.org/packages/75/ef/5b824f03ba40c3b3652b6272d083d2fc4fcdd440de519a3a39ba2c3e7262/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:50b5bedc9ed8a94fc8857a42ef4f84a81ea88f8d4f05dc8705fb23ee6d8dcca7", size = 22955, upload-time = "2026-10-02T23:04:47.698Z" }, - { url = "https://files.pythonhosted.org/packages/1e/e8/44cfcb5ea40e5e43cec7793ef90704ed0c475280839076c4345757eb8e59/markupsafe-3.0.4-cp311-cp311-win32.whl", hash = "sha256:2e5a7cd7fdd14fcb1ae5d7d8bf23d24fbd1daefd1fbca2580132e1ea75f098b5", size = 14094, upload-time = "2026-10-02T23:04:48.788Z" }, - { url = "https://files.pythonhosted.org/packages/91/89/f2b509f7bf79352e40117824c1070dbeafd4df67031d3fa98165a3134228/markupsafe-3.0.4-cp311-cp311-win_amd64.whl", hash = "sha256:fdb4ca07ab75ffadab4a8b135ad59cdbb3156b99310f3d565370da74a15d6bd3", size = 14328, upload-time = "2026-10-02T23:04:49.801Z" }, - { url = "https://files.pythonhosted.org/packages/2b/5a/ccf22672a0f64dc682306e288f0dabcb06c7a201f3bb6e6cbf86d9e8ad03/markupsafe-3.0.4-cp311-cp311-win_arm64.whl", hash = "sha256:569d65055d367e3dcdf30c3f41119467b73d9ee9faf332bdf40402644f5ac08e", size = 14188, upload-time = "2026-10-02T23:04:50.847Z" }, - { url = "https://files.pythonhosted.org/packages/81/09/4c59d56b8461ae8eb0d8ba34bb25b7e618547044679d58a82ef9b2479fc1/markupsafe-3.0.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61631e08084be9e21a8967ec3139c7616ed7c5e9368e05c86d1b39562c8a57b6", size = 11658, upload-time = "2026-10-02T23:04:51.876Z" }, - { url = "https://files.pythonhosted.org/packages/a2/f0/d6613774d86fbf6d145751d43c59875e47a6f9f17daee0aef173bd36d90e/markupsafe-3.0.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0930db9bdc62d22944e10b066448bb65dc9abe9112880c7cab8da54db4284d5f", size = 12050, upload-time = "2026-10-02T23:04:52.931Z" }, - { url = "https://files.pythonhosted.org/packages/0d/f2/8f18e0b806eb13c1f8d07d917a720831ead54253a6dec011fbc78098a6f8/markupsafe-3.0.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a45c3d514f2436064db00d7fc8778d888f0236ebfed649b53d13a59e69ad51b", size = 24363, upload-time = "2026-10-02T23:04:53.895Z" }, - { url = "https://files.pythonhosted.org/packages/60/ce/fa07dbe8a5675558fa36dea033e19995bc783de2dec5f540ccb9030b06aa/markupsafe-3.0.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1e1451fab512d1bcc3dc26988ec1edb0b82c2db909132872cd9356070a6b63df", size = 28525, upload-time = "2026-10-02T23:04:54.905Z" }, - { url = "https://files.pythonhosted.org/packages/85/40/be87c01f3868ec217f8a2015089d71c22c8c5a75324822e5ed1cdd87210d/markupsafe-3.0.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bd3ce56ae2cbae3ba82b683bc425cd7e48d2ed8b10f3e818186b6f5646d9271c", size = 24733, upload-time = "2026-10-02T23:04:56.229Z" }, - { url = "https://files.pythonhosted.org/packages/4f/a7/aeedb5140afa41fc74c225e9184ab96723a6e873b6ee1c9fede7283456d8/markupsafe-3.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e124f974786f831d6043728e38296969d3579db8896fe004682f5758e613581", size = 22985, upload-time = "2026-10-02T23:04:57.521Z" }, - { url = "https://files.pythonhosted.org/packages/c3/fc/e91352bb08c6a59da3ef0909d457bf95a5f5908fbf151b30a06d9dbcfbb4/markupsafe-3.0.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c02e8f18bdedba082cef725942ac823b9b60656db07f7e265cb31618dfd00d77", size = 22001, upload-time = "2026-10-02T23:04:58.597Z" }, - { url = "https://files.pythonhosted.org/packages/5d/f8/bffee5e7d2a3deb59748a797650a48af7e672025cf641a79344a771ad106/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f098115c247e11d138ab83a28fa0323c77015007ea2df73ba5fd714dfefd67c", size = 23793, upload-time = "2026-10-02T23:04:59.686Z" }, - { url = "https://files.pythonhosted.org/packages/ed/59/b853d6628ecb4d658e1d637224846d5e9bb4adf4f8df97f3be9f29dce2ec/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d5f93ebbeb8032d47e349328ec8662d973d9b05a70b3c35df1f91fe419b84749", size = 22640, upload-time = "2026-10-02T23:05:00.768Z" }, - { url = "https://files.pythonhosted.org/packages/09/b2/1506df394f0f075797c418d0301498f49e43be194e3ffcb49e6fe6ccf022/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:64511c54db4e4987aef4c41923235927428729e8174c5dba488429be70a998ed", size = 24074, upload-time = "2026-10-02T23:05:01.813Z" }, - { url = "https://files.pythonhosted.org/packages/c7/81/5ed69cda630ac69ef60d06c09ba5a7f84ff66a2e28cf986fd5614ab3c6e6/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e1a622f13970d81f95d0c72f9dc090dce9085fccfa4c9f2174377ee32bd15786", size = 21563, upload-time = "2026-10-02T23:05:03.239Z" }, - { url = "https://files.pythonhosted.org/packages/0c/fe/fb1e79be0fea60aa32602ebefc9c35a82bb42b4df157285ab7dfec12341a/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9a7f43c0b202b334cc9184af09bb8f21d3a209e038efaf106936fb69e6b026e", size = 23048, upload-time = "2026-10-02T23:05:04.479Z" }, - { url = "https://files.pythonhosted.org/packages/c8/52/7632a53360671a9b750cdbabaf9cdd89f18b42248b8e4cb42c0b0296e459/markupsafe-3.0.4-cp312-cp312-win32.whl", hash = "sha256:f0ec3b750b59375eab5b0fb2b9254810c00a3375be6d789899f1055a1d556237", size = 14108, upload-time = "2026-10-02T23:05:05.513Z" }, - { url = "https://files.pythonhosted.org/packages/3f/bf/62495e180b7000aaf30000fff849e933f74264638057176cf46852500adc/markupsafe-3.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:11935df9bf455ed0c04eb87bcd720f02b1fe5e02128a9430f23aed6f93336fc7", size = 14303, upload-time = "2026-10-02T23:05:06.538Z" }, - { url = "https://files.pythonhosted.org/packages/c5/8e/4c24208776a65878d656996945aacfbfe010d3720d1a98fc0eb8491fc03b/markupsafe-3.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9", size = 14174, upload-time = "2026-10-02T23:05:07.617Z" }, - { url = "https://files.pythonhosted.org/packages/6d/18/4bc5ba32499e87bb2b0ef5b3a9bb9c00a131fa961ddf0be548cb550f548b/markupsafe-3.0.4-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:de8b364c423ef0a4bad9069657d617f9a5d2b2062457a89b1fa16ee199c399c1", size = 12786, upload-time = "2026-10-02T23:05:08.709Z" }, - { url = "https://files.pythonhosted.org/packages/4e/6f/17f0c099bf25f3e31e63cc19244d9f6af861a9a4ab778c203997903cfdd0/markupsafe-3.0.4-cp313-cp313-android_24_x86_64.whl", hash = "sha256:34bdde374c5932765d7dc685c4a1d191a3207852d67e8e0a9eb6ea85156181f1", size = 12510, upload-time = "2026-10-02T23:05:09.93Z" }, - { url = "https://files.pythonhosted.org/packages/11/af/1a141081b905036ee904ec4bd945e1f70b4e1b32d33c4e59e8cf1d58b247/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:6bd9e1788e15bfcf6a9082de42e30387e7b85d211ab21e57a939bb8cfaaf8d96", size = 11583, upload-time = "2026-10-02T23:05:10.884Z" }, - { url = "https://files.pythonhosted.org/packages/e7/0a/a89385ae590232622a03e091805cff12f24fabe6c11e0e8bae096cece81c/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5066b244f576f91afc8ee3ba029a89f99d39c79b1853fe9d39bea9f0afbec148", size = 11801, upload-time = "2026-10-02T23:05:11.913Z" }, - { url = "https://files.pythonhosted.org/packages/ed/85/ea548dc013962eb73653124bc595635fbf9e0fa41d1f181a967ccb784dfb/markupsafe-3.0.4-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7a83aa6e4805df46fed18e989d3d16f86ef60cb50bbc8d9ce3a6be89165fbf6e", size = 11443, upload-time = "2026-10-02T23:05:12.887Z" }, - { url = "https://files.pythonhosted.org/packages/cc/72/15f2e5ec9cf2eb00d5cdfe968d94e4156a7bd7303832c3f3b2c403a36839/markupsafe-3.0.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2d1b7d9308288661f56672b1b157d75fc536714d3638487bbea17b6318a78248", size = 11665, upload-time = "2026-10-02T23:05:13.829Z" }, - { url = "https://files.pythonhosted.org/packages/ca/e0/4030bea613677e333c8a2c901fd405055f657f9d06acba5b7357984b6ef7/markupsafe-3.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73e77980c7207854f00fc4e71fb1626868d5740ab4012623d55c7a99ad122a72", size = 12061, upload-time = "2026-10-02T23:05:14.807Z" }, - { url = "https://files.pythonhosted.org/packages/f3/a5/28b76a7449eb702966b88bef599e2360b411fbb3afeee8fe560939be06ec/markupsafe-3.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7018d4af1cd272e847aa5917983ab5e83e4f6579f9dbfecd4a79c0ca80b144c2", size = 24395, upload-time = "2026-10-02T23:05:15.909Z" }, - { url = "https://files.pythonhosted.org/packages/07/6c/21232811afc3a063b5e934b1ae2efda52f46154ec382f585149c020e61fe/markupsafe-3.0.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c90d5b3d4e944e065a301d741b3c1d784f6bd1f503aa68b4967e32b2ba313d85", size = 27183, upload-time = "2026-10-02T23:05:16.976Z" }, - { url = "https://files.pythonhosted.org/packages/14/38/6ccdfa5b59049cb36fb80cbc80aee9cf1fc9bb77d1335ad435f2070b08cf/markupsafe-3.0.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18a801868a884f216e784d7d14db2a4077143ce7610440aee2ce8f734e7cfcde", size = 24772, upload-time = "2026-10-02T23:05:18.209Z" }, - { url = "https://files.pythonhosted.org/packages/63/e0/cec6865dfe88cb48fedd4b20aed6af5158e41092adcbf3e028bcc6ec2108/markupsafe-3.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:434139499bb20b502ed3baa1f169e618f924a97e7a777fea1a49446d80106cf6", size = 23017, upload-time = "2026-10-02T23:05:19.286Z" }, - { url = "https://files.pythonhosted.org/packages/ee/76/6ed4940bb7648a9aac457c14f870cfdd5105f139a0fb1f29cd61fafa47d1/markupsafe-3.0.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9e227f3dbe6bde7491cf0a9965d00b88c6b1a4a95d11480ddf88bb96d397c19f", size = 22036, upload-time = "2026-10-02T23:05:20.352Z" }, - { url = "https://files.pythonhosted.org/packages/a1/4f/ed476226d4fe46a09090a36025bf319296810028df55eb12f1253b540f3a/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b8cd1f918b26fd7b1832ece557cc18f2d8747309ff8b3f0ef9d4250c5ad67a39", size = 23786, upload-time = "2026-10-02T23:05:21.576Z" }, - { url = "https://files.pythonhosted.org/packages/9a/35/66ff30450e35ef5fba9ebc930c9411747e537fd9447b65e44f5007e2b84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a5fcffb37e602b0b3c1638a97746b9b96125caa9bcf6fa41d337a9261de231ee", size = 22672, upload-time = "2026-10-02T23:05:22.922Z" }, - { url = "https://files.pythonhosted.org/packages/32/0b/72f45ce4b4efcbca4b80cf1b06703eff0be8d37e82abb78f66c85a7ead1e/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5989cb26b2e1efc6a42216a9f6b5ee495ce5ace2e5b352a9af489976b32d1ee2", size = 24107, upload-time = "2026-10-02T23:05:24.175Z" }, - { url = "https://files.pythonhosted.org/packages/d2/03/71776e5fdcba04614b384cc102e8a4198208579d896fd1394cb7cb9aa900/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:add96447a86d205ab616665d53b2950ee81083757f56e6ea833c8b2917646b46", size = 21615, upload-time = "2026-10-02T23:05:25.215Z" }, - { url = "https://files.pythonhosted.org/packages/ab/5f/801ce02a02e7aee0f784b1ec7843026178f6adeb9c93ac67eb1992a9a84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2628d3a8cb648ecebb3c5d6b0a1052d400e4d8b7ac0fb786be8d285b50040d17", size = 23076, upload-time = "2026-10-02T23:05:26.423Z" }, - { url = "https://files.pythonhosted.org/packages/4a/85/c43776625428f3bb4a61e8633940400e3efe6409e3c6f5bff26de5e45618/markupsafe-3.0.4-cp313-cp313-win32.whl", hash = "sha256:672d207103e6b16ca098611b0f9efad6bc00afd47c03d6ef62186495ca677dc0", size = 14118, upload-time = "2026-10-02T23:05:27.716Z" }, - { url = "https://files.pythonhosted.org/packages/6f/36/163da64de88a13db79214ef75fa041be7fa13bdb42261cf5b7484de14bfb/markupsafe-3.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:1f1f9477e174582b0a1b583d60b66e1f2cf5d3fe12cee985e4aedf44766600e5", size = 14313, upload-time = "2026-10-02T23:05:28.749Z" }, - { url = "https://files.pythonhosted.org/packages/9f/a8/9b662783ffaa1149221432a923cee562f78b9cbbb8baa3df9b3753e63e1e/markupsafe-3.0.4-cp313-cp313-win_arm64.whl", hash = "sha256:06de8ef6331f6e822c28d577dc8bf43fe398800477c49498f38fc38b67ff33fc", size = 14189, upload-time = "2026-10-02T23:05:29.917Z" }, - { url = "https://files.pythonhosted.org/packages/5c/c3/a944f3b0df22bd129e96915b9f4e98d2eeca6516687d7618304a966c3c74/markupsafe-3.0.4-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:4ed644d75aa94a2baf7ec3a96eaa160ea58c742eb9d27c6506053c5c40fc84ed", size = 12787, upload-time = "2026-10-02T23:05:30.971Z" }, - { url = "https://files.pythonhosted.org/packages/d4/d6/a44863f69d88b6c7e27889108f70d47aed259edf89d5df3c5fca1eac87d6/markupsafe-3.0.4-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6d2a9efe686f9de00d0d1ea32a4a5a86d558a2277501bd78d964214eab625e59", size = 12525, upload-time = "2026-10-02T23:05:32.263Z" }, - { url = "https://files.pythonhosted.org/packages/17/8f/168ba80e532dd6a93f96f8f706f1ad41d7990b6e1aeedc1cc0d211a33497/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8781a792a070cf2bd1b86d3aa943894115faaba6e88122a7bf32d62072742453", size = 11589, upload-time = "2026-10-02T23:05:33.251Z" }, - { url = "https://files.pythonhosted.org/packages/32/b3/aa2c95a574d3af39403a469b295886eb9b6d448da568cbebb5a2cbfdc2e5/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:971a3bbb75d97ae4e2e8f7d4834236f86f85f0c85e04ab2e191db1123b04f80b", size = 11801, upload-time = "2026-10-02T23:05:34.315Z" }, - { url = "https://files.pythonhosted.org/packages/60/d0/34b810107d83840e768bf485de795893ebbae35b26ab061b487adfa0a692/markupsafe-3.0.4-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:8909c2f1c6dd65e054ac4b573a91c8384d1492281e55d82d159d653f7a13adf6", size = 11447, upload-time = "2026-10-02T23:05:35.302Z" }, - { url = "https://files.pythonhosted.org/packages/6c/ab/2f8488f0f817a39fca068d2b17daf446bf5cdb3eae28c3720af534d873b4/markupsafe-3.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cf3468d5ec187ffffcaca8e61929a37448f215dafc1386a12c750a72fe53634", size = 11722, upload-time = "2026-10-02T23:05:36.363Z" }, - { url = "https://files.pythonhosted.org/packages/ad/40/e2d117b048d47282ade906fbfd92814cbee5647afc13fda88a3406039372/markupsafe-3.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:52704c5d36eb6dda8866493decd61111fff86244c9b1ad225ca01b9e91e5970f", size = 12061, upload-time = "2026-10-02T23:05:37.397Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a8/73a81135e85ba66217f5af7facb03bbb386807e1a729ab64532e4c802652/markupsafe-3.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1caa2fa5a6184fb233153b35f654e6687bd555476f6170f29d8ee9be1a8b0af9", size = 24434, upload-time = "2026-10-02T23:05:38.407Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ca/fa9216dd01efee2dfdacafe7df32b4d0170fbac694b0c258a193d6e53999/markupsafe-3.0.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:387d8cd30e69b3f0a72877b9ae717033396404e19095b17fe89753a981fda44f", size = 27183, upload-time = "2026-10-02T23:05:39.581Z" }, - { url = "https://files.pythonhosted.org/packages/fa/4e/a469509e538d37af51103b17b073126973f2b1cbf197ff32c7ddf025cfe5/markupsafe-3.0.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:051417f74bcaaefa316276e0ff723f541616ca51043d070da00249d9bddd3e3c", size = 24813, upload-time = "2026-10-02T23:05:40.671Z" }, - { url = "https://files.pythonhosted.org/packages/8f/db/d7282caf7ab03af44d5d6fdbaa019b35c7d7f1c90588b839c07cba640d6a/markupsafe-3.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8e9f292fcda89b324f2f5c91d13f1424a153e40fc2756f38ee23b15835ff300", size = 23049, upload-time = "2026-10-02T23:05:41.864Z" }, - { url = "https://files.pythonhosted.org/packages/30/f3/b6a425206e6964efda6acee544d0eb01d1501784d0b8e2dcc74986f33b17/markupsafe-3.0.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:df1ae86ff54725a01fa1a0510b914ca53a161b7050be74f6204e24aded5971d0", size = 22088, upload-time = "2026-10-02T23:05:43.014Z" }, - { url = "https://files.pythonhosted.org/packages/ea/8a/84d3582fc1f0d5bd466cdf2eebf175e172158a6e70701aacec1de1b35430/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8965520ac587c94a4ac48b729be3d8b8de00af39699b17585dfb599babe77977", size = 23846, upload-time = "2026-10-02T23:05:44.098Z" }, - { url = "https://files.pythonhosted.org/packages/1c/65/db101cce51b7ba4864ac491a9859d297dd1adf0e55b103fee9db9c47c527/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:340cbb1957ba99929cbf19a75626d36ba1ae21d1730b287d1cf7f824a20c4fc7", size = 22674, upload-time = "2026-10-02T23:05:45.23Z" }, - { url = "https://files.pythonhosted.org/packages/e0/49/ddee9813d71db0c7a5c9d97c832125e6758a0c844777f1cf076569bb0e22/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3a93d9616ddecfb393727a0041a562cf0b15a244e20f2bd25efc7949be4c4f17", size = 24104, upload-time = "2026-10-02T23:05:46.398Z" }, - { url = "https://files.pythonhosted.org/packages/aa/0e/7d8518d726726870a2399d69fd30d0fa36c5e57a2132c336b58d7c491073/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d2e56fd3b00222722abfb3f5f0759ddbae4b90811b5ad4343c64030ad1bde70c", size = 21637, upload-time = "2026-10-02T23:05:47.48Z" }, - { url = "https://files.pythonhosted.org/packages/b4/b0/b505e8a361ba557dbf3b3aa7331ea39b00d2022a26e925ff8463b9714bb3/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0d9c47709875fdb321452056622e930c52afbc07a7d780762fbb8b4d91ce6fa4", size = 23081, upload-time = "2026-10-02T23:05:48.611Z" }, - { url = "https://files.pythonhosted.org/packages/1c/ea/9cc3cea873f980c75cbdb6f4277ce30ee955de38be0b3d02f14c108e0698/markupsafe-3.0.4-cp314-cp314-win32.whl", hash = "sha256:38fc55594dab834470b6733dead2ee9e3f657fb0608c769dcafa0ba5ab52f45c", size = 14272, upload-time = "2026-10-02T23:05:49.707Z" }, - { url = "https://files.pythonhosted.org/packages/80/f0/5792ff768a410f93ee3f84fc19345295ffc352d2c936b424cb37e514714c/markupsafe-3.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:c1bc67752d5f21013cfe430df4062441714eab79f65a6a05e01505957e9c35fe", size = 14501, upload-time = "2026-10-02T23:05:50.788Z" }, - { url = "https://files.pythonhosted.org/packages/5f/cf/3d074a8edffcc6899355232ff2543ae8d929733239596423b7db79698bc9/markupsafe-3.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:7e1636da3d8dfc220b6dd10264db5f2b165e4888c4518594898fbe381049af8a", size = 14364, upload-time = "2026-10-02T23:05:51.857Z" }, - { url = "https://files.pythonhosted.org/packages/d9/31/87ce42159aae2163cf3bbbd0c44bc87780510eecab1ea3859099aed95dcb/markupsafe-3.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:805c8b84534fa10891890f0e4be39f3a99e94615d93e8836bf9fa1fdca2feeb2", size = 11759, upload-time = "2026-10-02T23:05:52.951Z" }, - { url = "https://files.pythonhosted.org/packages/5f/53/b047207eeb7752e960aca3eb1df5fb7eefa7dd4c62ac49bb156456c8a702/markupsafe-3.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fa95848c929b6a75f6848d3c9793e59db365ee436776e57db835cdbfa79ba977", size = 12096, upload-time = "2026-10-02T23:05:54.066Z" }, - { url = "https://files.pythonhosted.org/packages/ee/51/4326c88a13c7b755657d44b4bb986f8c3d9843ecba7e22d98661d87f9a57/markupsafe-3.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e916035e3e9930cbdfdd10abf48861340221857f45509565898e012263f7b289", size = 25699, upload-time = "2026-10-02T23:05:55.15Z" }, - { url = "https://files.pythonhosted.org/packages/f2/bb/990581b7474bfcf2cf34bed6ba5ea23bd87adb9d671213d68e88620e7a6b/markupsafe-3.0.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4d12837e0203bbace818ff4a7461afdcd78bcd782351cea148139180d7bcffe", size = 27804, upload-time = "2026-10-02T23:05:56.29Z" }, - { url = "https://files.pythonhosted.org/packages/6b/89/89491878c28e8291f5aa2fffe2c2d57230d10ae366d55dd810b840513d78/markupsafe-3.0.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5086f9975abb1ab531ee6afca1761e4b59a19b446f3f6522ed776963228cfe5a", size = 25912, upload-time = "2026-10-02T23:05:57.416Z" }, - { url = "https://files.pythonhosted.org/packages/30/77/680998b54efdea06fc114565cd739b6d059f826a0279219b218dfa750d29/markupsafe-3.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4a635a0487774f841cb1fb62e907e7195cc95bc761e053184b8acc3ceb20733", size = 23623, upload-time = "2026-10-02T23:05:58.557Z" }, - { url = "https://files.pythonhosted.org/packages/ae/75/2709f5ac5de9467b40b10e2bb8f89cc63dfb74582e09aa734b1124a217de/markupsafe-3.0.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb96e6e088d6cf71c1ea977510948320234824cf226e32f6f6e044f7a9c82b34", size = 23301, upload-time = "2026-10-02T23:05:59.94Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c8/39eadc6c5b14c9c7679bfb98f4d4c6a97863b5beb91839aca4d2d6e16e55/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8b5d563170ff8ba3181caa967c99a3c804d1dedb702c7cb93a6a7c32247da978", size = 24950, upload-time = "2026-10-02T23:06:01.289Z" }, - { url = "https://files.pythonhosted.org/packages/1a/5e/01037f8a43e8ccb0bffb4fbdc5212db05bf080fdd7286cd392332d58128a/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:396ec4e65cc889f69786b3b89478b471cee5a3bcf468b9d9bb03e1a30fb291fc", size = 23330, upload-time = "2026-10-02T23:06:02.441Z" }, - { url = "https://files.pythonhosted.org/packages/d4/f4/23e83ce0596bb0cbe670502d31df8f757bbd01a392aa486fa3b40d1ed399/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:15ba9e28640feef770374b116a6f019c21f52404aeabe516aa7f800587b98cfc", size = 25239, upload-time = "2026-10-02T23:06:03.579Z" }, - { url = "https://files.pythonhosted.org/packages/88/5b/3708897368073cc683d524750474f41a77d2986152c380dcc55b20fdf340/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d920abdfa61279ba1a2ef9484aab07bf03331f8c08a10120fa332353d06e6932", size = 22767, upload-time = "2026-10-02T23:06:04.699Z" }, - { url = "https://files.pythonhosted.org/packages/c6/61/ebda1307864b409e6b3115757a3d4a09cca46cfb6cc65191b5de226b424b/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a9f54054101545a9a9cccefddf54316aa6e4491611fcbef9e91b3b6bebec04f6", size = 23668, upload-time = "2026-10-02T23:06:05.9Z" }, - { url = "https://files.pythonhosted.org/packages/09/15/98075cceac3b5ba0dbb8e4762a847be967d2befc349a2cf2d0ac77f62c9d/markupsafe-3.0.4-cp314-cp314t-win32.whl", hash = "sha256:12a606a492de952afcb43b59a14aaaaad120e708d3663dd0fdf2d738d427a691", size = 14301, upload-time = "2026-10-02T23:06:07.109Z" }, - { url = "https://files.pythonhosted.org/packages/0b/a3/768b560fcc4156685cb563d922b217810cfa7bc135773367f62f1f9d2078/markupsafe-3.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a18f38cafc329bac5e3c2b96c765b4c96d3d103421ed22ab7988c1e3fce27464", size = 14526, upload-time = "2026-10-02T23:06:08.276Z" }, - { url = "https://files.pythonhosted.org/packages/93/63/da554b4c97a6b0ea3229ca7fe8cbfb620be81613d517f482e85958550537/markupsafe-3.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:eba154571c16e032112afac0dc2dfe9e63c2ceb7aedd07bb7eecf2ce26d4dd4c", size = 14421, upload-time = "2026-10-02T23:06:09.402Z" }, - { url = "https://files.pythonhosted.org/packages/a9/30/54d11c8ca027114898cab97421fb39e4ffd9ddf47cdbc44df2ec76722da9/markupsafe-3.0.4-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:737c9c3981998eba27f11786f84fddcbabc74068b72a4a1f454ea02094b57b65", size = 12791, upload-time = "2026-10-02T23:06:10.485Z" }, - { url = "https://files.pythonhosted.org/packages/10/6d/97c913e253a14bd3cd0e15a5c56d13203b823fa7ee32498342896a072dc4/markupsafe-3.0.4-cp315-cp315-android_24_x86_64.whl", hash = "sha256:489505b03f692c3f376394e49194fa7a7f9e8558d6e293a7056a0032b0c38163", size = 12527, upload-time = "2026-10-02T23:06:11.834Z" }, - { url = "https://files.pythonhosted.org/packages/26/f9/b86d032042a4d597d9e1997f0e5f63a3eedaf11258e0a05760b0a0a826ea/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:077293e425f28ec737dbcad442a71752e28f8ae27cde3d68acd1fb212091cd92", size = 11602, upload-time = "2026-10-02T23:06:13.122Z" }, - { url = "https://files.pythonhosted.org/packages/f2/dc/73c14c1eedf0ac5fa3292ba43435e6c49d2c2050f33cebde541f8f4807f1/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9348cbb300d224fe3b89793262cb093504d4ae927004468463f745188a193e4a", size = 11817, upload-time = "2026-10-02T23:06:14.227Z" }, - { url = "https://files.pythonhosted.org/packages/8f/69/2c2fcaa5fcee22d72c7819c0d536fd181c74a688e6143845419579cd2863/markupsafe-3.0.4-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:b807e598953730f82e4eae3bd30f6a122cf6b31c398c6b504c0e04c13c170429", size = 11446, upload-time = "2026-10-02T23:06:15.574Z" }, - { url = "https://files.pythonhosted.org/packages/88/54/9e5ec76c62e6e2834d5a93623018c943e8b3bb41d663e3fd4c03303b9b85/markupsafe-3.0.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:799c39bdf5e2f1292fedd3009f7b3c9e760f10b2420cb9638d56920840ff6db8", size = 11719, upload-time = "2026-10-02T23:06:16.701Z" }, - { url = "https://files.pythonhosted.org/packages/96/24/3ec292b44064c16229e064d770b2625bd8ea941aa61f44905a9fa44942c0/markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97", size = 12075, upload-time = "2026-10-02T23:06:17.855Z" }, - { url = "https://files.pythonhosted.org/packages/aa/85/b64fdb1f304848518742136983c24e96d967bfb59a0ea160e92736901ab0/markupsafe-3.0.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b", size = 24656, upload-time = "2026-10-02T23:06:18.963Z" }, - { url = "https://files.pythonhosted.org/packages/9c/18/23997d4c65b355da6390d61cd56e0ab3befd6ba8dda25cb40c602bd0fa6b/markupsafe-3.0.4-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3882fb412298575bae3b9c46868251f15cc69307359f87bb1b382e53d6e5a2c9", size = 27737, upload-time = "2026-10-02T23:06:20.117Z" }, - { url = "https://files.pythonhosted.org/packages/d4/36/35998dead3c6af88c38265a56e58100211f036234ab88eb2283fd4cbce44/markupsafe-3.0.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04e7902ba80ee4bac1d50a549606527a1dcf0476cd81403db41099d3b60ec653", size = 25003, upload-time = "2026-10-02T23:06:21.284Z" }, - { url = "https://files.pythonhosted.org/packages/82/96/ef49135ce260db4ca4a12b119ed468449cd248db6b1468e2112b546d7a2e/markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369", size = 23111, upload-time = "2026-10-02T23:06:22.524Z" }, - { url = "https://files.pythonhosted.org/packages/50/7d/83126e338bd88c17a220668235368ad719fd4638e426739858cbb8508f77/markupsafe-3.0.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f68edfc67aabac33708941f26f22a7b8e9f81429bc0cf249fcf7d66b23af8d19", size = 22403, upload-time = "2026-10-02T23:06:23.785Z" }, - { url = "https://files.pythonhosted.org/packages/83/dd/daf7e420de23c8206c365204e7b85e1251d8e19d34196a56336f316e5ed2/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e5c802729725bd07e2bc3ab7b76dc7e0bbfc53129d8f1eb1c002c24cf774717e", size = 24083, upload-time = "2026-10-02T23:06:25.037Z" }, - { url = "https://files.pythonhosted.org/packages/19/3c/11eecdc06bc44ad5570350085b572ebf049e8f9a38d1ece6d76640b739cd/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:55ffd6ce583d97dc71dc92e930324c8c0d25aea7e3ade6ae54ef77cedb096811", size = 22931, upload-time = "2026-10-02T23:06:26.328Z" }, - { url = "https://files.pythonhosted.org/packages/0d/9e/ac0fd77f2a726e56ecc3ca0235d095feace1358d1b822406c2a2ef26a4dc/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:2cb3dd71fc6be918ad4264346a8ed69485f9b7ed7bf35495d8e22807cd6b8bea", size = 24299, upload-time = "2026-10-02T23:06:27.742Z" }, - { url = "https://files.pythonhosted.org/packages/d7/09/c6bd842ad58ff5b3bc76eeed7e9a42a6f11adc5d090ec697b72c9672731e/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:94f5407f7bc64fa6463906b896f9904beeeb7dd8dc116ee8e9056c8714ff9916", size = 21948, upload-time = "2026-10-02T23:06:29.274Z" }, - { url = "https://files.pythonhosted.org/packages/a3/46/82f586711fed61e86faa1ee1bc317d68cd45a10c8bdbe3f7d1fdf9026ad8/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2dad610540cb2e6272855c178f08ae9a1c7ac258a7fb71660553a5f104b42741", size = 23150, upload-time = "2026-10-02T23:06:30.583Z" }, - { url = "https://files.pythonhosted.org/packages/19/2d/2dfdce99318abbfa26925195fbc17db188c46a1ec6457be121b6f9cfeb42/markupsafe-3.0.4-cp315-cp315-win32.whl", hash = "sha256:03470d1a8268e692ecf79ecd565593e59d44219377a7ead61f1f1b94c1f7ff6b", size = 14269, upload-time = "2026-10-02T23:06:31.949Z" }, - { url = "https://files.pythonhosted.org/packages/5b/ec/6000fd82e8791e58fcd0456ec20f098957e2b03d5ed02eb73241a577c0ba/markupsafe-3.0.4-cp315-cp315-win_amd64.whl", hash = "sha256:d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214", size = 14496, upload-time = "2026-10-02T23:06:33.258Z" }, - { url = "https://files.pythonhosted.org/packages/bc/66/e73bd5016421d5d6e2fb6de7dd609f9de020942ac8c626526bd8c6eeaf82/markupsafe-3.0.4-cp315-cp315-win_arm64.whl", hash = "sha256:353bd63081912ab8cfa6a0c7d185934cdf8426f04c618bba6bc4b394f2069b67", size = 14361, upload-time = "2026-10-02T23:06:34.539Z" }, - { url = "https://files.pythonhosted.org/packages/90/df/cb8c3dc98d313a951df2f8968f44e4cb5643df6d3cab749a530ce2f7d972/markupsafe-3.0.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c61750fadcd119d0825bcb7d7d675dd264dcc89cc05292aab5be68ebdbb374ad", size = 11760, upload-time = "2026-10-02T23:06:35.807Z" }, - { url = "https://files.pythonhosted.org/packages/d6/bb/4af9b3ca0753d654ac75f9531d5bd741bb77ca6e696f36807c475ffc099a/markupsafe-3.0.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1c0df495a977d10460a94941799c72d5b5ab03d3858d949b55b5a66c8f371c99", size = 12108, upload-time = "2026-10-02T23:06:37.089Z" }, - { url = "https://files.pythonhosted.org/packages/3f/d4/b56429313aee5fd59b079c3df5615299959e25e7113eb6d8caadbdd7d38a/markupsafe-3.0.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02fa4acbc6a3fc5c693c34d4dd8c1130b7fe99cc915181b0ddd6f72aeb296002", size = 25762, upload-time = "2026-10-02T23:06:38.419Z" }, - { url = "https://files.pythonhosted.org/packages/65/f5/34c181e891aa4f7d59c918584672e0c5eb7fffe76c1387d1246008bf4081/markupsafe-3.0.4-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:05295589e619b9bed252a86b532b8e27350abc372d18ba89b59375325e91ec1e", size = 28400, upload-time = "2026-10-02T23:06:39.819Z" }, - { url = "https://files.pythonhosted.org/packages/ce/b5/ad14694fd0ac9a5ce30bc6498f2999378f418583dd1679cca5a1b512957e/markupsafe-3.0.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:be6cb0c799abb0e2ba3e618e6d28ddddf7e485f6c2ce938dfa237daf3905072c", size = 25946, upload-time = "2026-10-02T23:06:41.381Z" }, - { url = "https://files.pythonhosted.org/packages/d6/a8/26b606445387d0ceb1eb1f21840094b84e4e3c3c3983d80d10b89823b490/markupsafe-3.0.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26e9867520db70d37f7fb421a7f0d8adb40171011fb84ce869afa1a83370dfa8", size = 23634, upload-time = "2026-10-02T23:06:42.748Z" }, - { url = "https://files.pythonhosted.org/packages/39/a2/b8814de672f1f0094d498bf646f2fec9d6356b503d28ef500b71c5095377/markupsafe-3.0.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f03460ff076f70ab595bb45a0205ccea1971443575b6920c52e755dec2b3fbfe", size = 23488, upload-time = "2026-10-02T23:06:44.176Z" }, - { url = "https://files.pythonhosted.org/packages/db/c7/287223376fb73335a3cc5d6eb22c6ab01358cf33945a9c39c06b9dac3f4b/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:436e3ffc6310d3c41878c601db29098102fe5d8a467c49da4a4125254e0980f2", size = 25037, upload-time = "2026-10-02T23:06:45.646Z" }, - { url = "https://files.pythonhosted.org/packages/f9/29/4df8355e313426d19e62ba33e0253c009ca12a0894ee77d67fa67255361c/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:4e2c4809c14559aa7ef426f27fb35afbb38104c349a903bf8f3600456764bb38", size = 23543, upload-time = "2026-10-02T23:06:47.264Z" }, - { url = "https://files.pythonhosted.org/packages/71/e5/8377731e8495668dcc768f645e717df18318c841edaf023a99395f6da9b4/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:da2af0d7aebfc2074080d72efa6ab8317c62481ef1f896f65d9999c1c01f4494", size = 25282, upload-time = "2026-10-02T23:06:48.795Z" }, - { url = "https://files.pythonhosted.org/packages/ed/5f/373456e37ceb1478d657d6fe769cbe0a39f0a8dfc1548eeb19c471eefdd9/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:aa2c838cc024642cc04c6854232f32b43e5e22833dd11119c1766c7873b8370d", size = 22993, upload-time = "2026-10-02T23:06:50.31Z" }, - { url = "https://files.pythonhosted.org/packages/d7/93/2cbd5628435afb6f541bbaced4bce0c2edac4b09a142e6e928b8b0da9858/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b91cc9d336957239ff200f30097e6fea2dc6d6fb3c81e853eaa09eac904fd894", size = 23712, upload-time = "2026-10-02T23:06:51.759Z" }, - { url = "https://files.pythonhosted.org/packages/81/99/157e10966b033b363aeda5263e82596ee232a0b1d082fdbf90aa417ff083/markupsafe-3.0.4-cp315-cp315t-win32.whl", hash = "sha256:e49fb0d1ce92cfa0cb198cc5b1b11cdf9d0638658e2a2db2687e39db7c87fc78", size = 14300, upload-time = "2026-10-02T23:06:53.241Z" }, - { url = "https://files.pythonhosted.org/packages/33/05/55884815414c9706a23deca150b72c25a62109e65b0b6ce232077802c719/markupsafe-3.0.4-cp315-cp315t-win_amd64.whl", hash = "sha256:4f6e0852a0283b1b1fd776eeb7b766a5f440b3e2bd31ab51af3b400585f3965c", size = 14522, upload-time = "2026-10-02T23:06:54.729Z" }, - { url = "https://files.pythonhosted.org/packages/92/f9/ecbde7149e95b8a0f18e16d5d747f7dc06049d5da2e4f77f6f5e4a1f46a8/markupsafe-3.0.4-cp315-cp315t-win_arm64.whl", hash = "sha256:39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba", size = 14417, upload-time = "2026-10-02T23:06:56.246Z" }, -] - -[[package]] -name = "mccabe" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, -] - -[[package]] -name = "mistletoe" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/17/ea/ee6e364d62197e427f1d260500eac659a8855df207a77f74451658d749f3/mistletoe-1.6.0.tar.gz", hash = "sha256:92f066c4720a25fa24bf260e6413702bbff3a5b8a93af314cb6cd8589726a8e1", size = 113730, upload-time = "2026-07-11T14:14:18.593Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/5c/3681c676d79726e3b8b72bc78e83ac472b0c96e3c99b28d72b9470f12170/mistletoe-1.6.0-py3-none-any.whl", hash = "sha256:53f9dfb859e9b2d309915d0f06c3876253d1e24d568d0b728a32679eb4f0a187", size = 55931, upload-time = "2026-07-11T14:14:17.056Z" }, -] - -[[package]] -name = "mypy" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ast-serialize" }, - { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, - { name = "mypy-extensions" }, - { name = "pathspec" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/b9/d75b3082b05f1b3028828aeb18e74ae5ab0a0936051bbf1f32f59f654747/mypy-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3419d00717afbc5265b50dd14b1278f29ea4884dd398ab67873489ac093fd329", size = 14838725, upload-time = "2026-07-13T11:32:44.655Z" }, - { url = "https://files.pythonhosted.org/packages/a9/50/79a65c6ea6e115bc73296038a4543b2d5c91f07912b918a2c616a2514bba/mypy-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cfca8ee88544090f86b6dcce05ec55d66eb48a762412ac2507810ba4bd793b6f", size = 13911128, upload-time = "2026-07-13T11:32:02.021Z" }, - { url = "https://files.pythonhosted.org/packages/90/48/e11ed7716c26953ca321f726e452e374dbf81a6f2b8b212ec02af29b6b8f/mypy-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75cbb4b9ef04a0c84a957f07abc4504fbf64b8dcc145675101f2d3a78a4b1d6a", size = 14146742, upload-time = "2026-07-13T11:33:03.313Z" }, - { url = "https://files.pythonhosted.org/packages/06/72/6807565b1c4861ef66f7fdd98b51c61556356eab80235717b46c53bb8627/mypy-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:982e3d53dd23d0a4cef67dd66791fdbede0cf38f9eb617bf47663554c51e1e36", size = 15081418, upload-time = "2026-07-13T11:31:13.899Z" }, - { url = "https://files.pythonhosted.org/packages/00/80/1ea14c5d80e589e415973db3e47c78c2219a305b808b2b506395342c1d79/mypy-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85c5385b93012ffa3b31479ab579aef5415f4f3a32c6cf1ae07a984d2a0ff461", size = 15328164, upload-time = "2026-07-13T11:31:35.723Z" }, - { url = "https://files.pythonhosted.org/packages/37/28/8223157404a3d51920078459c37f80fbdc590e1d8ea049dc5ce48643022a/mypy-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:13b1b16e2fa39f3b2e33fb1c468abc7a69369fa2e886b4b87b5afc81472325cd", size = 11136472, upload-time = "2026-07-13T11:27:37.018Z" }, - { url = "https://files.pythonhosted.org/packages/6f/cc/ea27e5959c5f258585a756b252031f3b313583d81b5064b2bebc41d3706b/mypy-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:b5cd2f027a972a4a5f2278a11fac9747f5f81a53a30b714d74950b6807e55568", size = 10135800, upload-time = "2026-07-13T11:30:08.92Z" }, - { url = "https://files.pythonhosted.org/packages/dc/94/0e7e592619e2133596a47cdd642534b0456545c218430bd3b9d8fefdd1b1/mypy-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d53fc67b9d28a43c6199077f49fea0f05839e36cf6158500331c9549225e5a5", size = 15026523, upload-time = "2026-07-13T11:34:49.206Z" }, - { url = "https://files.pythonhosted.org/packages/f6/d2/1e1731df090a857df2807177a4626863e5ac0f0256513c35780efe53986f/mypy-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fbc00cee7bdbb9291979ddc9d08034a29dfcda4932628c9bbc28c1edd589df0c", size = 14032189, upload-time = "2026-07-13T11:33:57.168Z" }, - { url = "https://files.pythonhosted.org/packages/44/95/cab921f4a806e171f34113e6181dd23c55358ccf6a80741269ef594a410e/mypy-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04e617030eca5221909c8b7d8d7fd1c637948199aa2100b2ad9813feb07e1491", size = 14198696, upload-time = "2026-07-13T11:32:12.767Z" }, - { url = "https://files.pythonhosted.org/packages/66/80/e6d008bb19fe446e3662d85e0e2717bf9f2d611a2164fb29d6e067dbf46c/mypy-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56c184d2c20ca6b6378d58d1960270a767f41f5e44acbbd27f05effef4f4e1d7", size = 15286904, upload-time = "2026-07-13T11:34:27.594Z" }, - { url = "https://files.pythonhosted.org/packages/db/83/94397c9293608a364aa03e8084fb34ede4ae976a260384b9b52929308135/mypy-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3961a4a34b05f7c74b0f05aa51fbfe99a2d1e126038df40318d15c8f558b7ef3", size = 15528342, upload-time = "2026-07-13T11:34:07.819Z" }, - { url = "https://files.pythonhosted.org/packages/cf/96/d8b37d819adec6cfccfb1fd3afc1735d94717ddeafb45536db9c6943e09b/mypy-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:b1942b9314d4c784b8ea1dbab4972603290e5dd5630f06675f13aec97526bc4c", size = 11218346, upload-time = "2026-07-13T11:28:27.745Z" }, - { url = "https://files.pythonhosted.org/packages/2b/cd/cd9f725b19b19e5b530a154cf9bcf9e94279c5d55b3c34fb42b3aa48ea1b/mypy-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:be51653d7669d7d7955d613b8d0bb57d5b652eaf71a873ddf65ac87254dd2595", size = 10204525, upload-time = "2026-07-13T11:31:02.552Z" }, - { url = "https://files.pythonhosted.org/packages/6e/ae/f7d056eb0294586a572d0d0d89580ec633c064db520f11d37d5a2fb833bd/mypy-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:91ad22a52ae2c7e621c2f67c94d5a17f66b3209a4cff5cf8a573579835c69e97", size = 14947298, upload-time = "2026-07-13T11:27:47.734Z" }, - { url = "https://files.pythonhosted.org/packages/32/d5/db3e7af01e7844d21662c6ddc1f7825ec7cb4053f0391ac02faf3638396f/mypy-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99ac767cc5d3b64c8d0ae226ead10c96694f94e4e7da1668642225dcd4e75aac", size = 13950768, upload-time = "2026-07-13T11:27:57.726Z" }, - { url = "https://files.pythonhosted.org/packages/d9/fb/43c031f0190513d1ec248ed037eceb742ddd2a4d74bbf406658a28173837/mypy-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6d2c484742a4d7b0ed6d07b143375624d3b899c5749c7b3c947f56261f48a6", size = 14151586, upload-time = "2026-07-13T11:29:18.615Z" }, - { url = "https://files.pythonhosted.org/packages/ec/c3/f8b2ffc60883084da91be51af58e88a7ffd4ff9795acb7d902ff88d31eb1/mypy-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7da939dd335cfd2ad788bdfd081c9f4e47634ab995e5a45eb15fd1e5bc052f8b", size = 15227411, upload-time = "2026-07-13T11:30:29.904Z" }, - { url = "https://files.pythonhosted.org/packages/83/2e/16b917fc7adcf03f1aadddfc93aab804ffb234b1ab09c0ffd6d92a5d34a2/mypy-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7247eb2824f996722a949530183394921ca71deb9680052a338cf53cff7925c2", size = 15478790, upload-time = "2026-07-13T11:33:14.686Z" }, - { url = "https://files.pythonhosted.org/packages/c0/88/aaa65a93c73d0cdae7e42f8adb302bf6885bb281302084f99d0290a35347/mypy-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:75b0984bb3cbd76bb5c9291a8671f7ae66ca3b51c7584c358fc2e923259f0757", size = 11234919, upload-time = "2026-07-13T11:33:39.28Z" }, - { url = "https://files.pythonhosted.org/packages/35/19/b40de63f1a80e63bc2d40f0679a6a8dbd34e95176c8122119bdf406aa552/mypy-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78fcf900b59cb7e82cb7e3a235e31b462d9333d92285bd1e4952d355b8ffba1", size = 10201510, upload-time = "2026-07-13T11:31:52.619Z" }, - { url = "https://files.pythonhosted.org/packages/a4/58/fa0ae047da911f540284009b4f44b96fe09d83c076d7c103e9d645f46303/mypy-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea317b060ce83e26050f8f9e4d7d6bf44ed7597c8ff9990bccffbb9d1d8522db", size = 14941909, upload-time = "2026-07-13T11:32:34.332Z" }, - { url = "https://files.pythonhosted.org/packages/15/14/2ba1d61452d7c2a7fe12741e8d374e52b183476b07aa7f9e2a0d02b0720a/mypy-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:094af99f92638aa92852326188b85a89e50f4a472f44827c03362228482f0762", size = 13967581, upload-time = "2026-07-13T11:30:00.587Z" }, - { url = "https://files.pythonhosted.org/packages/ed/5a/483fb9e5ffbbb1a28dccc7b0a13d141b17ac769b6c9f488c0a0c63698962/mypy-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de121747278144fc9ae7caa2e978cf5df12aebc82933182f5b3b86081a30baef", size = 14168807, upload-time = "2026-07-13T11:28:48.6Z" }, - { url = "https://files.pythonhosted.org/packages/ae/77/70d7a10732063beb74ad713682cf871e88f5c5fa39bfc8beff8a524bf9cb/mypy-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37fa4de896a84e2dc9200d91e614c22563b43d1a266789d4bbac7b22ebe6192b", size = 15200144, upload-time = "2026-07-13T11:31:25.283Z" }, - { url = "https://files.pythonhosted.org/packages/56/72/766218ac783be4fdfcd699b90037b63017348a3e86fb2c1fbfb18302637d/mypy-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f1b3a98dfd21058bc759bb3337d5d1f61d0fdf9f3cf9c00f4291790fb5427bff", size = 15460389, upload-time = "2026-07-13T11:29:29.077Z" }, - { url = "https://files.pythonhosted.org/packages/38/4e/8a9db7411ecb8ec0cb1fd05dba432f28bafffcd38b4e887714a4a0506689/mypy-2.3.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:944c665d984157cb96a679dfb7a4a81dd1d36b24b9c284b699514e6e626b82d4", size = 7753664, upload-time = "2026-07-13T11:29:08.147Z" }, - { url = "https://files.pythonhosted.org/packages/65/4c/c3f8bfd6ed0e5e38b5a244403b27f821d433443df5a15a278417c10a3a3c/mypy-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:4359424140d985192c778c1ce2c114a10c1ca58a381ed79cfa70d37df94b299f", size = 11417237, upload-time = "2026-07-13T11:33:47.467Z" }, - { url = "https://files.pythonhosted.org/packages/3c/00/89a32eaf5ccf174bc4f90db0eaea5d70636c01b8d49f384bdab2e8834390/mypy-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:3dd0bed92c4bdec57c42505b96416fb9e6a5aa7be84d2809bcd5f2ecec2860d7", size = 10389252, upload-time = "2026-07-13T11:31:43.81Z" }, - { url = "https://files.pythonhosted.org/packages/31/56/104f93d69aa9f339b6b9d3b0a7faa699b8b466c942cf3ae86cc2a2ec0915/mypy-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:691fdc37132b1ae628d834f672e74de83462d9fb4aff621835767fb43a8dd373", size = 16385495, upload-time = "2026-07-13T11:29:49.818Z" }, - { url = "https://files.pythonhosted.org/packages/d2/03/f1d2123313f55efafdd27706960f43a771c62f1b68426c76043f3ab9ebf3/mypy-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aec15d465d477558fd842757b487849007311cf3897849cdda0e3162ac0ac556", size = 15098155, upload-time = "2026-07-13T11:30:40.301Z" }, - { url = "https://files.pythonhosted.org/packages/e5/5d/d5f9200399b445e81726c4f23becee33f233aee81c72680b1ef3a258b641/mypy-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b352b7e49f5e6576009e8df730e1ff4f915cb565b851b396d2ffe2f5a6f5da88", size = 15514155, upload-time = "2026-07-13T11:34:38.569Z" }, - { url = "https://files.pythonhosted.org/packages/cd/ce/69977c555f08faa3190cfde44189b89dbd56861b1ab97aa18fc5f3a2e4a3/mypy-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c6c6bf687b17f90dbfcad95b960d32eaa0154c00da45f03ab50bf8952e047fe", size = 16766351, upload-time = "2026-07-13T11:33:29.195Z" }, - { url = "https://files.pythonhosted.org/packages/bc/92/6648b6caa3ab9e00f9ac0c2a78307805f873dd48139b24a6f6f7c3667bbf/mypy-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ed18f111bfe2d599bca7468e7f9251042c1c2118f762c8de2766a56d773c60", size = 17043490, upload-time = "2026-07-13T11:30:53.927Z" }, - { url = "https://files.pythonhosted.org/packages/7c/ab/0dc91d80f3f016634c68d451f294a97320fe903a9b6f90b9e57b3f7f1717/mypy-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0b025a93cffb9781d231f232be07a17912f35f10a313c24f301c81e842870654", size = 12146869, upload-time = "2026-07-13T11:29:38.874Z" }, - { url = "https://files.pythonhosted.org/packages/85/b5/4c964d02634ba81f4d1c84838e5c5b18ab06d13ed568960f5d6318495ccc/mypy-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:adebc76aab4f3495a88b41d48aa4aff0c03f2822501da76625afcca5975f19e5", size = 10965113, upload-time = "2026-07-13T11:28:07.056Z" }, - { url = "https://files.pythonhosted.org/packages/2c/fa/fdc54fe583ba3cafbcedfb70eeeaf03849f75b1827a07096c7bd996f582d/mypy-2.3.0-py3-none-any.whl", hash = "sha256:6b1cdb579446b60432432b2b2403a6201b4b475a004d7f488511c9ba177c9e88", size = 2753292, upload-time = "2026-07-13T11:33:18.48Z" }, -] - -[[package]] -name = "mypy-extensions" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, -] - -[[package]] -name = "packaging" -version = "26.3" -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" } -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" }, -] - -[[package]] -name = "pathspec" -version = "1.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, -] - -[[package]] -name = "plain-parser" -source = { editable = "." } -dependencies = [ - { name = "mistletoe" }, - { name = "python-frontmatter" }, - { name = "python-liquid2" }, - { name = "pyyaml" }, -] - -[package.optional-dependencies] -dev = [ - { name = "black" }, - { name = "flake8" }, - { name = "isort" }, - { name = "mypy" }, - { name = "pytest" }, -] - -[package.metadata] -requires-dist = [ - { name = "black", marker = "extra == 'dev'", specifier = "==26.5.1" }, - { name = "flake8", marker = "extra == 'dev'", specifier = "==7.3.0" }, - { name = "isort", marker = "extra == 'dev'", specifier = "==8.0.1" }, - { name = "mistletoe", specifier = ">=1.6.0,<2" }, - { name = "mypy", marker = "extra == 'dev'", specifier = "==2.3.0" }, - { name = "pytest", marker = "extra == 'dev'", specifier = "==9.1.1" }, - { name = "python-frontmatter", specifier = ">=1.3.0,<2" }, - { name = "python-liquid2", specifier = ">=0.4.0,<0.5" }, - { name = "pyyaml", specifier = ">=6,<7" }, -] -provides-extras = ["dev"] - -[[package]] -name = "platformdirs" -version = "4.13.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/35/e9/bf409046a91b6a9ca652e9b0ac3c39481674f6774e23e58b52315516099a/platformdirs-4.13.7.tar.gz", hash = "sha256:f7f351c027d6632c30471124e1fdb6ac0fef9ccc5cbac1b34cb333431b1fc3e1", size = 63906, upload-time = "2026-10-28T05:49:31.898Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/46/ff/8ed4a836cbf7de39708a2d305d6b67575990908d072bc3565dd8ad71748e/platformdirs-4.13.7-py3-none-any.whl", hash = "sha256:47664602fbecad0a6efa561fb690d20dbc26875fe8aedf65ba362935df619bbb", size = 33111, upload-time = "2026-10-28T05:49:30.545Z" }, -] - -[[package]] -name = "pluggy" -version = "1.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" }, -] - -[[package]] -name = "pycodestyle" -version = "2.14.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", size = 39472, upload-time = "2025-06-20T18:49:48.75Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" }, -] - -[[package]] -name = "pyflakes" -version = "3.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", size = 64669, upload-time = "2025-06-20T18:45:27.834Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" }, -] - -[[package]] -name = "pygments" -version = "2.21.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, -] - -[[package]] -name = "pytest" -version = "9.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, -] - -[[package]] -name = "python-dateutil" -version = "3.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, - { name = "tzdata" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0e/df/f15f80383f152452d8985b36d95cb8751f901a3e0ff4cd27f7e118f168e2/python_dateutil-3.0.0.tar.gz", hash = "sha256:7ebcb3c29a92f3ceafe79a7a48a5c45677e9e004bbeec5742ac0bbb00560b7a2", size = 271066, upload-time = "2026-10-21T23:41:45.075Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/17/cebee01d1c0c1441174cf0456ddc91b280d79acb65340c05e41d77866ee8/python_dateutil-3.0.0-py2.py3-none-any.whl", hash = "sha256:3c79630202e4152c6add678446984236efe4f57e84d8e892aab0637bab82e9fd", size = 94659, upload-time = "2026-10-21T23:41:43.431Z" }, -] - -[[package]] -name = "python-frontmatter" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyyaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9d/e8/79cbe69864d44f3b48e70ebee0a872a7d5a4e7150c9f8577ed7a5beefff0/python_frontmatter-1.3.0.tar.gz", hash = "sha256:acc73e477a568dc2a25c9e130c6c68ae8daa8c204c8f7e813db47d6a7280dcf2", size = 8322, upload-time = "2026-05-20T19:21:44.164Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/a3/17c284b4f4d8ad50f0f9ba70ad8fcc35c777aeafcdbbffdd91bbdc5ab379/python_frontmatter-1.3.0-py3-none-any.whl", hash = "sha256:9f7dd9260bec99044219159a329f64f039087f9d1a2124c9442556f2fe6f82ec", size = 10562, upload-time = "2026-05-20T19:21:43.323Z" }, -] - -[[package]] -name = "python-liquid2" -version = "0.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "babel" }, - { name = "markupsafe" }, - { name = "python-dateutil" }, - { name = "pytz" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9a/48/11a415c56ac4410ef1c69559eaf5b2e696717252aaebd55beb33582f3ff5/python_liquid2-0.4.0.tar.gz", hash = "sha256:2d9499ab7e7ca1ec10a1c2b2ff2a7a292b9e1eda3ff7ffe8c409ce2a8675e9ab", size = 87961, upload-time = "2026-05-13T06:39:21.639Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/82/eab55b9b0dee18144619f9383db25a5c9916d0aaa5681a5c07d92e307196/python_liquid2-0.4.0-py3-none-any.whl", hash = "sha256:a5ab10e12ebf80c1dc35906d41f7180587c7553a308370d06a7e242d7c41f5f5", size = 129974, upload-time = "2026-05-13T06:39:20.561Z" }, -] - -[[package]] -name = "pytokens" -version = "0.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/92/790ebe03f07b57e53b10884c329b9a1a308648fc083a6d4a39a10a28c8fc/pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440", size = 160864, upload-time = "2026-01-30T01:02:57.882Z" }, - { url = "https://files.pythonhosted.org/packages/13/25/a4f555281d975bfdd1eba731450e2fe3a95870274da73fb12c40aeae7625/pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc", size = 248565, upload-time = "2026-01-30T01:02:59.912Z" }, - { url = "https://files.pythonhosted.org/packages/17/50/bc0394b4ad5b1601be22fa43652173d47e4c9efbf0044c62e9a59b747c56/pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d", size = 260824, upload-time = "2026-01-30T01:03:01.471Z" }, - { url = "https://files.pythonhosted.org/packages/4e/54/3e04f9d92a4be4fc6c80016bc396b923d2a6933ae94b5f557c939c460ee0/pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16", size = 264075, upload-time = "2026-01-30T01:03:04.143Z" }, - { url = "https://files.pythonhosted.org/packages/d1/1b/44b0326cb5470a4375f37988aea5d61b5cc52407143303015ebee94abfd6/pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6", size = 103323, upload-time = "2026-01-30T01:03:05.412Z" }, - { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, - { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, - { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, - { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, - { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, - { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, - { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, - { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, - { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, - { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, - { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, - { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, - { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, - { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, - { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, - { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, - { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, - { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, - { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, - { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, - { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, -] - -[[package]] -name = "pytz" -version = "2026.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/14/21/d83d6ef28c4c912c4bb4d1dcf591f7b8c6bde87b9c66f9f454677314e16d/pytz-2026.5.tar.gz", hash = "sha256:fa23724b9c486543b9ff54a327ee7569ac83ade54bb9afd0fc18676620401c86", size = 318572, upload-time = "2026-10-04T02:37:58.719Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/ef/c66110d46fb800dda0bf33164182dfadabe26a90e4476844d502a23dca8e/pytz-2026.5-py2.py3-none-any.whl", hash = "sha256:e658af3757f9e26a9d25dd2aff38335acd92bc9104f890a894b2c1ba28311b03", size = 506342, upload-time = "2026-10-04T02:37:56.814Z" }, -] - -[[package]] -name = "pyyaml" -version = "6.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, - { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, - { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, - { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, - { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, - { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, - { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, - { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, - { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, - { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, - { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, - { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, - { 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 = "six" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, -] - -[[package]] -name = "typing-extensions" -version = "4.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, -] - -[[package]] -name = "tzdata" -version = "2026.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d9/68/f1b440335057bfce71b6e50a9d09445aa2ecbd08359a337976627b8409e7/tzdata-2026.5.tar.gz", hash = "sha256:8cc73c0a0bfca7dbfa59235d60b2eff82231dee33f53d206db1acd9173cfc0a7", size = 200404, upload-time = "2026-10-03T09:23:14.143Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/21/1e5995a1c920cce14e4bffae20c665ec10e7ed03ab25e006cd741092b718/tzdata-2026.5-py2.py3-none-any.whl", hash = "sha256:b683bd1b6659ddcd810ff02ad09ba821d4bf1065072805063eb35c49617905ac", size = 347996, upload-time = "2026-10-03T09:23:12.535Z" }, -]