From 9f7f10a8db7ffcd7658e7e7a37e513cc9a0cc870 Mon Sep 17 00:00:00 2001 From: Ana Date: Thu, 29 Oct 2026 14:27:22 +0100 Subject: [PATCH] refactor: use plain-parser for spec parsing Parser code moved to the plain-parser package, pinned to 0.1.4. plain_file, plain_spec, concept_utils and plain2code_nodes are now re-exports; removing them is a follow-up. mistletoe and python-frontmatter arrive through plain-parser but stay pinned here so an automatic mistletoe upgrade cannot change the spec hashes stored by partial rendering. --- concept_utils.py | 222 +---- file_utils.py | 112 +-- plain2code.py | 43 +- plain2code_exceptions.py | 54 +- plain2code_nodes.py | 120 +-- plain2code_utils.py | 17 +- plain_file.py | 872 +----------------- plain_spec.py | 422 +-------- pyproject.toml | 9 +- requirements.txt | 7 +- tests/data/imports/circular_imports_1.plain | 8 - tests/data/imports/circular_imports_2.plain | 8 - .../data/imports/circular_imports_main.plain | 12 - tests/data/imports/diamond_import_1.plain | 16 - tests/data/imports/diamond_import_2.plain | 16 - .../data/imports/diamond_import_common.plain | 11 - tests/data/imports/diamond_imports_main.plain | 13 - .../imports/import_with_functionalities.plain | 7 - .../imports_module_with_functionalities.plain | 12 - tests/data/imports/non_existent_import.plain | 12 - .../add_new_task_modal_specification.yaml | 1 - .../duplicate_specification_heading.plain | 7 - .../empty_functional_specs_section.plain | 4 - .../invalid_specification_order.plain | 7 - .../missing_non_functional_requirements.plain | 5 - .../no_functional_specs_section.plain | 5 - .../plain_source_with_absolute_link.plain | 9 - .../plain_source_with_missing_link.plain | 9 - .../plain_source_with_url_link.plain | 9 - .../plainfile/task_list_ui_specification.yaml | 1 - .../task_manager_with_reference_links.plain | 40 - .../without_non_functional_requirement.plain | 5 - .../concept_validation_acceptance_tests.plain | 20 - ...lidation_acceptance_tests_nondefined.plain | 20 - ...oncept_validation_defined_nondefined.plain | 18 - ...cept_validation_defined_nondefined_2.plain | 18 - .../concept_validation_definition.plain | 16 - .../concept_validation_noconcepts.plain | 18 - .../concept_validation_nonconcept.plain | 15 - .../concept_validation_nondefined.plain | 18 - .../concept_validation_redefinition.plain | 19 - .../concept_validation_several_concepts.plain | 13 - .../concept_validation_valid.plain | 16 - .../plainfileparser/cyclic_definitions.plain | 9 - .../plainfileparser/exported_concepts.plain | 13 - .../exported_concepts_base.plain | 2 - .../exported_concepts_defaults_exported.plain | 13 - .../exported_concepts_example.plain | 13 - .../exported_concepts_imported_exported.plain | 12 - ...ported_concepts_inherited_reexported.plain | 15 - .../exported_concepts_malformed.plain | 13 - ...exported_concepts_missing_definition.plain | 14 - ..._concepts_missing_definition_example.plain | 13 - ...xported_concepts_multiple_per_bullet.plain | 14 - ...ncepts_multiple_per_bullet_undefined.plain | 13 - .../exported_concepts_nested.plain | 15 - .../exported_concepts_nested_example.plain | 13 - .../exported_concepts_no_definitions.plain | 10 - .../exported_concepts_not_declared.plain | 8 - ...ported_concepts_not_declared_example.plain | 13 - ...exported_concepts_transitive_example.plain | 13 - .../exported_concepts_transitive_l1.plain | 13 - .../exported_concepts_transitive_l2.plain | 13 - .../plain_file_parser_with_comments.plain | 8 - .../plain_file_with_comments_indented.plain | 10 - .../regular_plain_source.plain | 11 - .../plainfileparser/required_concepts.plain | 7 - .../required_concepts_defs.plain | 2 - .../required_concepts_example.plain | 13 - .../required_concepts_l1.plain | 7 - .../required_concepts_l2.plain | 5 - .../required_concepts_missing.plain | 10 - .../required_concepts_module.plain | 11 - .../required_concepts_partial.plain | 13 - .../required_concepts_partial_duplicate.plain | 14 - ...requires_without_definitions_example.plain | 10 - .../plainfileparser/topological_sort.plain | 14 - .../topological_sort_not_referenced.plain | 22 - .../requires/circular_requires_main.plain | 18 - .../data/requires/circular_requires_sub.plain | 12 - tests/data/requires/diamond_requires_1.plain | 12 - tests/data/requires/diamond_requires_2.plain | 12 - .../requires/diamond_requires_common.plain | 7 - .../data/requires/diamond_requires_main.plain | 19 - .../requires/independent_requires_1.plain | 7 - .../requires/independent_requires_2.plain | 7 - .../requires/independent_requires_main.plain | 19 - .../data/requires/non_existent_require.plain | 18 - tests/data/requires/normal_requires_1.plain | 12 - tests/data/requires/normal_requires_2.plain | 12 - .../requires/normal_requires_common.plain | 7 - .../data/requires/normal_requires_main.plain | 19 - tests/data/sample_base64_image.txt | 1 - tests/data/simple.plain | 7 - .../data/templates/block_level_include.plain | 12 - tests/data/templates/code_variables.plain | 10 - tests/data/templates/header.plain | 7 - tests/data/templates/implement.plain | 3 - tests/data/templates/implement_2.plain | 3 - tests/data/templates/template_include.plain | 4 - .../data/templates/test_hardest_problem.plain | 2 - tests/test_file_utils.py | 71 +- tests/test_imports.py | 53 -- tests/test_plainfile.py | 271 ------ tests/test_plainfileparser.py | 775 ---------------- tests/test_plainspec.py | 103 --- tests/test_render_range.py | 42 + tests/test_requires.py | 28 - tests/test_user_error_message.py | 36 + 109 files changed, 162 insertions(+), 4132 deletions(-) delete mode 100644 tests/data/imports/circular_imports_1.plain delete mode 100644 tests/data/imports/circular_imports_2.plain delete mode 100644 tests/data/imports/circular_imports_main.plain delete mode 100644 tests/data/imports/diamond_import_1.plain delete mode 100644 tests/data/imports/diamond_import_2.plain delete mode 100644 tests/data/imports/diamond_import_common.plain delete mode 100644 tests/data/imports/diamond_imports_main.plain delete mode 100644 tests/data/imports/import_with_functionalities.plain delete mode 100644 tests/data/imports/imports_module_with_functionalities.plain delete mode 100644 tests/data/imports/non_existent_import.plain delete mode 100644 tests/data/plainfile/add_new_task_modal_specification.yaml delete mode 100644 tests/data/plainfile/duplicate_specification_heading.plain delete mode 100644 tests/data/plainfile/empty_functional_specs_section.plain delete mode 100644 tests/data/plainfile/invalid_specification_order.plain delete mode 100644 tests/data/plainfile/missing_non_functional_requirements.plain delete mode 100644 tests/data/plainfile/no_functional_specs_section.plain delete mode 100644 tests/data/plainfile/plain_source_with_absolute_link.plain delete mode 100644 tests/data/plainfile/plain_source_with_missing_link.plain delete mode 100644 tests/data/plainfile/plain_source_with_url_link.plain delete mode 100644 tests/data/plainfile/task_list_ui_specification.yaml delete mode 100644 tests/data/plainfile/task_manager_with_reference_links.plain delete mode 100644 tests/data/plainfile/without_non_functional_requirement.plain delete mode 100644 tests/data/plainfileparser/concept_validation_acceptance_tests.plain delete mode 100644 tests/data/plainfileparser/concept_validation_acceptance_tests_nondefined.plain delete mode 100644 tests/data/plainfileparser/concept_validation_defined_nondefined.plain delete mode 100644 tests/data/plainfileparser/concept_validation_defined_nondefined_2.plain delete mode 100644 tests/data/plainfileparser/concept_validation_definition.plain delete mode 100644 tests/data/plainfileparser/concept_validation_noconcepts.plain delete mode 100644 tests/data/plainfileparser/concept_validation_nonconcept.plain delete mode 100644 tests/data/plainfileparser/concept_validation_nondefined.plain delete mode 100644 tests/data/plainfileparser/concept_validation_redefinition.plain delete mode 100644 tests/data/plainfileparser/concept_validation_several_concepts.plain delete mode 100644 tests/data/plainfileparser/concept_validation_valid.plain delete mode 100644 tests/data/plainfileparser/cyclic_definitions.plain delete mode 100644 tests/data/plainfileparser/exported_concepts.plain delete mode 100644 tests/data/plainfileparser/exported_concepts_base.plain delete mode 100644 tests/data/plainfileparser/exported_concepts_defaults_exported.plain delete mode 100644 tests/data/plainfileparser/exported_concepts_example.plain delete mode 100644 tests/data/plainfileparser/exported_concepts_imported_exported.plain delete mode 100644 tests/data/plainfileparser/exported_concepts_inherited_reexported.plain delete mode 100644 tests/data/plainfileparser/exported_concepts_malformed.plain delete mode 100644 tests/data/plainfileparser/exported_concepts_missing_definition.plain delete mode 100644 tests/data/plainfileparser/exported_concepts_missing_definition_example.plain delete mode 100644 tests/data/plainfileparser/exported_concepts_multiple_per_bullet.plain delete mode 100644 tests/data/plainfileparser/exported_concepts_multiple_per_bullet_undefined.plain delete mode 100644 tests/data/plainfileparser/exported_concepts_nested.plain delete mode 100644 tests/data/plainfileparser/exported_concepts_nested_example.plain delete mode 100644 tests/data/plainfileparser/exported_concepts_no_definitions.plain delete mode 100644 tests/data/plainfileparser/exported_concepts_not_declared.plain delete mode 100644 tests/data/plainfileparser/exported_concepts_not_declared_example.plain delete mode 100644 tests/data/plainfileparser/exported_concepts_transitive_example.plain delete mode 100644 tests/data/plainfileparser/exported_concepts_transitive_l1.plain delete mode 100644 tests/data/plainfileparser/exported_concepts_transitive_l2.plain delete mode 100644 tests/data/plainfileparser/plain_file_parser_with_comments.plain delete mode 100644 tests/data/plainfileparser/plain_file_with_comments_indented.plain delete mode 100644 tests/data/plainfileparser/regular_plain_source.plain delete mode 100644 tests/data/plainfileparser/required_concepts.plain delete mode 100644 tests/data/plainfileparser/required_concepts_defs.plain delete mode 100644 tests/data/plainfileparser/required_concepts_example.plain delete mode 100644 tests/data/plainfileparser/required_concepts_l1.plain delete mode 100644 tests/data/plainfileparser/required_concepts_l2.plain delete mode 100644 tests/data/plainfileparser/required_concepts_missing.plain delete mode 100644 tests/data/plainfileparser/required_concepts_module.plain delete mode 100644 tests/data/plainfileparser/required_concepts_partial.plain delete mode 100644 tests/data/plainfileparser/required_concepts_partial_duplicate.plain delete mode 100644 tests/data/plainfileparser/requires_without_definitions_example.plain delete mode 100644 tests/data/plainfileparser/topological_sort.plain delete mode 100644 tests/data/plainfileparser/topological_sort_not_referenced.plain delete mode 100644 tests/data/requires/circular_requires_main.plain delete mode 100644 tests/data/requires/circular_requires_sub.plain delete mode 100644 tests/data/requires/diamond_requires_1.plain delete mode 100644 tests/data/requires/diamond_requires_2.plain delete mode 100644 tests/data/requires/diamond_requires_common.plain delete mode 100644 tests/data/requires/diamond_requires_main.plain delete mode 100644 tests/data/requires/independent_requires_1.plain delete mode 100644 tests/data/requires/independent_requires_2.plain delete mode 100644 tests/data/requires/independent_requires_main.plain delete mode 100644 tests/data/requires/non_existent_require.plain delete mode 100644 tests/data/requires/normal_requires_1.plain delete mode 100644 tests/data/requires/normal_requires_2.plain delete mode 100644 tests/data/requires/normal_requires_common.plain delete mode 100644 tests/data/requires/normal_requires_main.plain delete mode 100644 tests/data/sample_base64_image.txt delete mode 100644 tests/data/simple.plain delete mode 100644 tests/data/templates/block_level_include.plain delete mode 100644 tests/data/templates/code_variables.plain delete mode 100644 tests/data/templates/header.plain delete mode 100644 tests/data/templates/implement.plain delete mode 100644 tests/data/templates/implement_2.plain delete mode 100644 tests/data/templates/template_include.plain delete mode 100644 tests/data/templates/test_hardest_problem.plain delete mode 100644 tests/test_imports.py delete mode 100644 tests/test_plainfile.py delete mode 100644 tests/test_plainfileparser.py delete mode 100644 tests/test_plainspec.py create mode 100644 tests/test_render_range.py delete mode 100644 tests/test_requires.py create mode 100644 tests/test_user_error_message.py diff --git a/concept_utils.py b/concept_utils.py index b5311c03..5ef7f44e 100644 --- a/concept_utils.py +++ b/concept_utils.py @@ -1,221 +1,3 @@ -import re -from copy import deepcopy -from typing import Optional +"""Compatibility shim: the parser now lives in the ``plain-parser`` package.""" -import mistletoe.block_token -import networkx as nx - -import plain_spec -from plain2code_exceptions import PlainSyntaxError - -# These are reserved concept names that are pre-defined and must not be redefined in the definitions section. -DEFAULT_CONCEPTS = { - ":ConformanceTests:", - ":UnitTests:", - ":AcceptanceTest:", - ":AcceptanceTests:", - ":Implementation:", - ":plainDefinitions:", - ":plainImplementationReqs:", - ":plainFunctionality:", - ":plainTestReqs:", - ":plainImplementationCode:", -} - - -def extract_concepts_from_definition(text: str) -> tuple[list[str], list[str]]: - pattern = r"-\s(:[^\:]+:)(?:,\s*:[^\:]+:)*" - match = re.search(pattern, text) - - if not match: - return list(), [ - f"Syntax error: Invalid definition specification text: {text}. Should start with `- ::` (where is any string that does not contain `:`)." - ] - - concept_candidates = re.findall(r":[^\:]+:", match.group()) - valid_concepts = list[str]() - concept_re = r":[+\-\.0-9A-Z_a-z]+:" - errors = list[str]() - for candidate in concept_candidates: - if re.match(concept_re, candidate): - valid_concepts.append(candidate) - else: - errors.append( - f"Syntax error: Invalid concept: {candidate}. Should contain only letters, numbers, hyphens and dots." - ) - - return valid_concepts, errors - - -def extract_concepts_from_spec_text(text: str) -> str: - return re.findall(r":[+\-\.0-9A-Z_a-z]+:", text) - - -def collect_concepts(plain_source) -> tuple[set[str], list[str]]: - if plain_spec.DEFINITIONS not in plain_source: - return set(), list() - - concepts = list[str]() - errors = list[str]() - for definition in plain_source[plain_spec.DEFINITIONS]: - spec_text = definition["markdown"] - new_concepts, new_errors = extract_concepts_from_definition(spec_text) - errors.extend(new_errors) - concepts.extend(new_concepts) - - return concepts, errors - - -def validate_concepts_in_spec(spec: dict, concepts: set[str], spec_group: str) -> list[str]: - spec_text = spec["markdown"] - used_concepts = extract_concepts_from_spec_text(spec_text) - errors = [] - for used_concept in used_concepts: - if used_concept not in concepts and used_concept not in DEFAULT_CONCEPTS: - errors.append( - f"Syntax error: Concept {used_concept} is not defined in the definitions. {spec_group}: {spec_text}" - ) - - return errors - - -def validate_concepts_in_spec_group(spec_group, plain_source, concepts: set[str]) -> list[str]: - if spec_group not in plain_source: - return [] - - errors = [] - for spec in plain_source[spec_group]: - errors.extend(validate_concepts_in_spec(spec, concepts, spec_group)) - - return errors - - -def validate_concepts(marshalled_plain_source) -> list[str]: - errors = list[str]() - concepts = deepcopy(DEFAULT_CONCEPTS) - tree_based_concepts = deepcopy(DEFAULT_CONCEPTS) - - new_concepts, new_errors = collect_concepts(marshalled_plain_source) - errors.extend(new_errors) - - unique_new_concepts = set(new_concepts) - intersection = unique_new_concepts.intersection(concepts) - - if len(unique_new_concepts) < len(new_concepts) or (len(intersection) > 0): - misused_concepts = {concept for concept in new_concepts if new_concepts.count(concept) > 1} - misused_concepts.update(intersection) - errors.append(f"Syntax error: Concepts were defined multiple times: {', '.join(misused_concepts)} .") - - concepts.update(unique_new_concepts) - tmp_tree_based_concepts = tree_based_concepts.union(unique_new_concepts) - - for spec_group in [ - plain_spec.NON_FUNCTIONAL_REQUIREMENTS, - plain_spec.TEST_REQUIREMENTS, - plain_spec.FUNCTIONAL_REQUIREMENTS, - plain_spec.DEFINITIONS, - ]: - 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 - - -def find_concept_definitions_in_plain_source( - concept: str, - plain_source: dict, - renderer, -) -> list[mistletoe.block_token.token]: - definitions = list[mistletoe.block_token.token]() - if plain_source[plain_spec.DEFINITIONS] is None: - return definitions - - for definition in plain_source[plain_spec.DEFINITIONS].children: - rendered_definition = renderer.render(definition) - defined_concepts, _ = extract_concepts_from_definition(rendered_definition) - if concept in defined_concepts: - definitions.append(definition) - used_concepts = [ - used_concept - for used_concept in extract_concepts_from_spec_text(rendered_definition) - if used_concept != concept and used_concept not in DEFAULT_CONCEPTS - ] - for used_concept in used_concepts: - definitions.extend(find_concept_definitions_in_plain_source(used_concept, plain_source, renderer)) - - return definitions - - -def build_adjacency_list(definitions: Optional[list[dict]]) -> tuple[dict, dict]: - adjacency_list = dict[str, list[str]]() - concept_definitions = dict[str, dict]() - if definitions is None or len(definitions) == 0: - return adjacency_list, concept_definitions - - for definition in definitions: - def_text = definition["markdown"] - new_concepts, _ = extract_concepts_from_definition(def_text) - used_concepts = [ - used_concept - for used_concept in extract_concepts_from_spec_text(def_text) - if used_concept not in new_concepts and used_concept not in DEFAULT_CONCEPTS - ] - used_concepts = set(used_concepts) - - for new_concept in new_concepts: - if new_concept not in adjacency_list: - adjacency_list[new_concept] = [] - - for used_concept in used_concepts: - if used_concept not in adjacency_list: - adjacency_list[used_concept] = [] - - adjacency_list[used_concept].extend(new_concepts) - - for new_concept in new_concepts: - concept_definitions[new_concept] = definition - - return adjacency_list, concept_definitions - - -def sort_definitions(definitions: list[dict]) -> list[dict]: - if len(definitions) <= 1: - return - - adjacency_list, concept_definitions = build_adjacency_list(definitions) - - concept_graph = nx.DiGraph() - for node, neighbors in adjacency_list.items(): - for neighbour in neighbors: - concept_graph.add_edge(node, neighbour) - if not neighbors: - concept_graph.add_node(node) - - if not nx.is_directed_acyclic_graph(concept_graph): - msg = "Found cycles in the concept graph. Cycles are not allowed." - all_cycles = list(nx.simple_cycles(concept_graph)) - - for cycle in all_cycles: - cyclic_definitions = [] - cyclic_definitions.append(concept_definitions[cycle[0]]["markdown"]) - cyclic_definitions.append(concept_definitions[cycle[1]]["markdown"]) - msg += "Cyclic definitons:\n" - msg += "\n".join(cyclic_definitions) - msg += "\n" - - raise PlainSyntaxError(f"Plain syntax error: {msg}") - - order = list(nx.topological_sort(concept_graph)) - if len(order) > 0: - definition_to_order_idx = { - plain_spec.hash_text(str(concept_definitions[concept])): idx for idx, concept in enumerate(order) - } - definitions.sort(key=lambda definition: definition_to_order_idx[plain_spec.hash_text(str(definition))]) +from plain_parser.concept_utils import * # noqa: F401,F403 diff --git a/file_utils.py b/file_utils.py index c86bfdd9..e11791ba 100644 --- a/file_utils.py +++ b/file_utils.py @@ -3,14 +3,14 @@ import stat from pathlib import Path -from liquid2 import Environment, FileSystemLoader, StrictUndefined -from liquid2.exceptions import UndefinedError +from plain_parser.loaders import ( # noqa: F401 + TrackingFileSystemLoader, + get_loaded_templates, + load_linked_resources, + open_from, +) -import plain_spec from plain2code_console import console -from plain2code_exceptions import UnsupportedBase64Content, UnsupportedResourceType -from plain2code_nodes import Plain2CodeIncludeTag, Plain2CodeLoaderMixin -from plain2code_utils import find_large_base64_blob from plain_modules import CODEPLAIN_MEMORY_SUBFOLDER, CODEPLAIN_METADATA_FOLDER BINARY_FILE_EXTENSIONS = [".pyc"] @@ -183,106 +183,6 @@ def store_response_files(target_folder, response_files, existing_files): return existing_files -def open_from(dirs, file_name): - for dir in dirs: - full_file_name = os.path.join(dir, file_name) - if not os.path.isfile(full_file_name): - continue - - with open(full_file_name, "rb") as f: - content = f.read() - return content.decode("utf-8") - - return None - - -def resolve_linked_resource(dirs, file_name): - """Return the first existing path for file_name across dirs, or None if not found. - - Mirrors the search order used by open_from so that validating a linked resource and - loading it can never disagree. - """ - for dir in dirs: - full_file_name = os.path.join(dir, file_name) - if os.path.exists(full_file_name): - return full_file_name - - return None - - -def load_linked_resources(template_dirs: list[str], resources_list, module_name: str): - linked_resources = {} - - for resource in resources_list: - file_name = resource["target"] - if file_name in linked_resources: - continue - - try: - content = open_from(template_dirs, file_name) - except UnicodeDecodeError: - raise UnsupportedResourceType( - f"Referenced resource '{file_name}' in module '{module_name}' is a binary file. " - f"Only text files (e.g. .md, .txt, .json, .yaml) can be referenced from a .plain file." - ) - - if content is None: - raise FileNotFoundError(f""" - Resource file {file_name} not found. Resource files are searched in the following order (highest to lowest precedence): - - 1. The directory containing your .plain file - 2. The directory specified by --template-dir (if provided) - 3. The built-in 'standard_template_library' directory - - Please ensure that the resource exists in one of these locations, or specify the correct --template-dir if using custom templates. - """) - - blob = find_large_base64_blob(content) - if blob is not None: - raise UnsupportedBase64Content( - f"Referenced resource '{file_name}' in module '{module_name}' contains a large " - f"base64-encoded blob ({len(blob)} characters), such as an embedded image. Inline " - "base64 data is not supported. Remove the data from the resource. " - "If the data should be used by the end software, " - "save the data to a separate file and include the file path in the specification without it being a reference file." - ) - - linked_resources[file_name] = content - - return linked_resources - - -class TrackingFileSystemLoader(Plain2CodeLoaderMixin, FileSystemLoader): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.loaded_templates = {} - - def get_source(self, environment, template_name, **kwargs): - source = super().get_source(environment, template_name, **kwargs) - self.loaded_templates[template_name] = source.source - return source - - -def get_loaded_templates(source_path, plain_source): - # Render the plain source with Liquid templating engine - # to identify the templates that are being loaded - - liquid_loader = TrackingFileSystemLoader(source_path) - liquid_env = Environment(loader=liquid_loader, undefined=StrictUndefined) - liquid_env.tags["include"] = Plain2CodeIncludeTag(liquid_env) - - liquid_env.filters["code_variable"] = plain_spec.code_variable_liquid_filter - liquid_env.filters["prohibited_chars"] = plain_spec.prohibited_chars_liquid_filter - - plain_source_template = liquid_env.from_string(plain_source) - try: - plain_source = plain_source_template.render() - except UndefinedError as e: - raise Exception(f"Undefined liquid variable: {str(e)}") - - return plain_source, liquid_loader.loaded_templates - - def update_build_folder_with_rendered_files(build_folder, existing_files, response_files): changed_files = set() changed_files.update(response_files.keys()) diff --git a/plain2code.py b/plain2code.py index a7c93045..18ca4ea9 100644 --- a/plain2code.py +++ b/plain2code.py @@ -59,6 +59,39 @@ from tui.plain_module_render_choice_tui import PlainModuleRenderChoiceTUI DEFAULT_TEMPLATE_DIRS = "standard_template_library" + +# Search-order guidance appended to lookup failures raised by the parser, which only +# reports the missing file. The wording is the CLI's: it knows what the template +# directories are and how the user changes them. +TEMPLATE_NOT_FOUND_HINT = f""" +The required template could not be found. Templates are searched in the following order (highest to lowest precedence): + + 1. The directory containing your .plain file + 2. The directory specified by --template-dir (if provided) + 3. The built-in '{DEFAULT_TEMPLATE_DIRS}' directory + +Please ensure that the missing template exists in one of these locations, or specify the correct --template-dir if using custom templates. +""" + +RESOURCE_NOT_FOUND_HINT = f""" Resource files are searched in the following order (highest to lowest precedence): + +1. The directory containing your .plain file +2. The directory specified by --template-dir (if provided) +3. The built-in '{DEFAULT_TEMPLATE_DIRS}' directory + +Please ensure that the resource exists in one of these locations, or specify the correct --template-dir if using custom templates.""" + + +def user_error_message(e: BaseException) -> str: + """Format an exception for the user, appending search-order guidance to lookup failures.""" + message = str(e) if str(e) else repr(e) + if isinstance(e, TemplateNotFoundError): + return message + TEMPLATE_NOT_FOUND_HINT + if isinstance(e, FileNotFoundError): + return message + RESOURCE_NOT_FOUND_HINT + return message + + RENDER_THREAD_SHUTDOWN_TIMEOUT = 0.7 # The terminal states a render can reach, as the API names them. @@ -371,7 +404,7 @@ def run_render(): except Exception as e: run_state.set_render_succeeded(False) render_error.append(e) - event_bus.publish(RenderFailed(error_message=str(e))) + event_bus.publish(RenderFailed(error_message=user_error_message(e))) if args.headless: console.info(f"Render started. Render ID: {run_state.render_id}") @@ -446,7 +479,7 @@ def main(): # noqa: C901 console.info("Full plain text:\n") console.info(full_plain_source) except Exception as e: - console.error(f"Error: {str(e)}") + console.error(f"Error: {user_error_message(e)}") return # Parse the plain file (and its required modules) once; reused by dry-run and rendering. @@ -457,14 +490,14 @@ def main(): # noqa: C901 template_dirs, ) except Exception as e: - console.error(f"Error: {str(e)}") + console.error(f"Error: {user_error_message(e)}") return if args.dry_run: try: validate_linked_resources(plain_module) except Exception as e: - console.error(f"Error: {str(e)}") + console.error(f"Error: {user_error_message(e)}") return console.info("Printing dry run output...\n") @@ -517,7 +550,7 @@ def main(): # noqa: C901 error_message = "Keyboard interrupt" interrupted = True else: - error_message = str(e) if str(e) else repr(e) + error_message = user_error_message(e) if not isinstance(e, EXPECTED_EXCEPTIONS): exc_info = sys.exc_info() diff --git a/plain2code_exceptions.py b/plain2code_exceptions.py index 10d672bd..26d0148c 100644 --- a/plain2code_exceptions.py +++ b/plain2code_exceptions.py @@ -1,3 +1,15 @@ +from plain_parser.exceptions import ( # noqa: F401 + ImportedModuleWithFunctionalitiesError, + InvalidFridArgument, + InvalidLiquidVariableName, + MissingFunctionalitiesError, + ModuleDoesNotExistError, + PlainSyntaxError, + UnsupportedBase64Content, + UnsupportedResourceType, +) + + class FunctionalRequirementTooComplex(Exception): def __init__(self, message, proposed_breakdown=None): self.message = message @@ -21,19 +33,6 @@ class MissingResource(Exception): pass -class UnsupportedResourceType(Exception): - pass - - -class PlainSyntaxError(Exception): - pass - - -class UnsupportedBase64Content(Exception): - - pass - - class InternalClientError(Exception): pass @@ -50,24 +49,12 @@ class OutdatedClientVersion(Exception): pass -class InvalidFridArgument(Exception): - pass - - class InvalidGitRepositoryError(Exception): """Raised when the git repository is in an invalid state.""" pass -class InvalidLiquidVariableName(Exception): - pass - - -class ModuleDoesNotExistError(Exception): - pass - - class InternalServerError(Exception): pass @@ -78,23 +65,6 @@ class MissingPreviousFunctionalitiesError(Exception): pass -class MissingFunctionalitiesError(Exception): - """Raised when a module to be rendered has no functionalities specified at all.""" - - pass - - -class ImportedModuleWithFunctionalitiesError(Exception): - """Raised when a module brought in via ``import`` contains functional specs. - - This is a usage error, not a syntax error: the module is syntactically valid - and would be fine as a render target, but functionalities are not allowed in - the import role. - """ - - pass - - class NetworkConnectionError(Exception): """Raised when there is a network connectivity issue with the API server.""" diff --git a/plain2code_nodes.py b/plain2code_nodes.py index b3cad37c..0cf96a24 100644 --- a/plain2code_nodes.py +++ b/plain2code_nodes.py @@ -1,119 +1,3 @@ -from pathlib import Path -from typing import Mapping, Sequence, TextIO +"""Compatibility shim: the parser now lives in the ``plain-parser`` package.""" -from liquid2 import Environment, RenderContext, Template, TemplateNotFoundError -from liquid2.builtin import IncludeTag -from liquid2.builtin.tags.include_tag import IncludeNode - - -class Plain2CodeIncludeNode(IncludeNode): - def render_to_output(self, context: RenderContext, buffer: TextIO) -> int: - """Render the node to the output buffer.""" - name = self.name.evaluate(context) - whitespaces = 0 - is_comment = False - i = self.token.start - while self.token.source[i] != "\n" and i >= 0: - if self.token.source[i] == " ": - whitespaces += 1 - elif self.token.source[i] == ">": - is_comment = True - break - else: - whitespaces = 0 - i -= 1 - - if is_comment: - return buffer.write(str(self)) - - try: - template = context.env.get_template(str(name), context=context, tag=self.tag, whitespaces=whitespaces) - except TemplateNotFoundError as err: - detail = str(err) - long_message = f"""Template not found: {detail} -The required template could not be found. Templates are searched in the following order (highest to lowest precedence): - - 1. The directory containing your .plain file - 2. The directory specified by --template-dir (if provided) - 3. The built-in 'standard_template_library' directory - -Please ensure that the missing template exists in one of these locations, or specify the correct --template-dir if using custom templates. -""" - wrapped = TemplateNotFoundError(long_message) - wrapped.token = self.name.token - wrapped.template_name = context.template.full_name() - raise wrapped from err - - namespace: dict[str, object] = dict(arg.evaluate(context) for arg in self.args) - - character_count = 0 - - with context.extend(namespace, template=template): - if self.var: - val = self.var.evaluate(context) - key = self.alias or template.name.split(".")[0] - - if isinstance(val, Sequence) and not isinstance(val, str): - context.raise_for_loop_limit(len(val)) - for itm in val: - namespace[key] = itm - character_count += template.render_with_context(context, buffer, partial=True) - else: - namespace[key] = val - character_count = template.render_with_context(context, buffer, partial=True) - else: - - character_count = template.render_with_context(context, buffer, partial=True) - - return character_count - - -class Plain2CodeIncludeTag(IncludeTag): - node_class = Plain2CodeIncludeNode - - -class Plain2CodeLoaderMixin: - def __init__(self, *args, **kwargs): - if not hasattr(self, "get_source"): - raise NotImplementedError("Class must implement get_source") - super().__init__(*args, **kwargs) - - def load( - self, - env: Environment, - name: str, - *, - globals: Mapping[str, object] | None = None, - context: RenderContext | None = None, - **kwargs: object, - ) -> Template: - """ - Find and parse template source code. - - Args: - env: The `Environment` attempting to load the template source text. - name: A name or identifier for a template's source text. - globals: A mapping of render context variables attached to the - resulting template. - context: An optional render context that can be used to narrow the template - source search space. - kwargs: Arbitrary arguments that can be used to narrow the template source - search space. - """ - source, full_name, uptodate, matter = self.get_source(env, name, context=context, **kwargs) - whitespaces = kwargs.get("whitespaces", 0) - assert isinstance(whitespaces, int) - source = source.rstrip().replace("\n", "\n" + " " * whitespaces) - - path = Path(full_name) - - template = env.from_string( - source, - name=path.name, - path=path, - globals=globals, - overlay_data=matter, - ) - - template.uptodate = uptodate - return template +from plain_parser.liquid_nodes import * # noqa: F401,F403 diff --git a/plain2code_utils.py b/plain2code_utils.py index 9425cf2c..df048b24 100644 --- a/plain2code_utils.py +++ b/plain2code_utils.py @@ -1,19 +1,4 @@ -import re -from typing import Optional - -# -MAX_BASE64_BLOB_LENGTH = 8192 - -# Matches a long contiguous base64 / base64url run, optionally preceded by a data: URI header. -_BASE64_BLOB_PATTERN = re.compile( - r"(?:data:[\w.+-]+/[\w.+-]+;base64,)?[A-Za-z0-9+/_-]{%d,}={0,2}" % MAX_BASE64_BLOB_LENGTH -) - - -def find_large_base64_blob(text: str) -> Optional[str]: - """Return the first contiguous base64 blob at or above the threshold, or None.""" - match = _BASE64_BLOB_PATTERN.search(text) - return match.group(0) if match else None +from plain_parser.loaders import MAX_BASE64_BLOB_LENGTH, find_large_base64_blob # noqa: F401 def format_duration_hms(total_seconds: float) -> str: diff --git a/plain_file.py b/plain_file.py index 4583f5a7..dd63292a 100644 --- a/plain_file.py +++ b/plain_file.py @@ -1,871 +1,3 @@ -import io -import os -from dataclasses import dataclass -from pathlib import Path -from typing import Iterable, Sequence, cast -from urllib.parse import urlparse +"""Compatibility shim: the parser now lives in the ``plain-parser`` package.""" -import frontmatter -import mistletoe -import mistletoe.block_token -from liquid2 import DictLoader, Environment -from mistletoe.block_token import 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 -from mistletoe.utils import traverse - -import concept_utils -import file_utils -import plain_spec -from plain2code_exceptions import ( - ImportedModuleWithFunctionalitiesError, - MissingFunctionalitiesError, - ModuleDoesNotExistError, - PlainSyntaxError, - UnsupportedBase64Content, -) -from plain2code_nodes import Plain2CodeIncludeTag, Plain2CodeLoaderMixin -from plain2code_utils import find_large_base64_blob - -RESOURCE_MARKER = "[resource]" - -IMPORT_DIRECTIVE = "import" -REQUIRES_DIRECTIVE = "requires" -REQUIRED_CONCEPTS_DIRECTIVE = "required_concepts" -EXPORTED_CONCEPTS_DIRECTIVE = "exported_concepts" - -PLAIN_SOURCE_FILE_EXTENSION = ".plain" - -PLAIN_SOURCE_TEMPLATE = { - plain_spec.DEFINITIONS: None, - plain_spec.NON_FUNCTIONAL_REQUIREMENTS: None, - plain_spec.TEST_REQUIREMENTS: None, -} - - -@dataclass -class PlainFileParseResult: - plain_source: dict - plain_source_obj: frontmatter.Post - required_modules: list[str] - required_concepts: list[str] - - -class PlainRenderer(MarkdownRenderer): - def render_link(self, token: Link) -> Iterable[Fragment]: - yield from self.embed_span( - Fragment(f"`{RESOURCE_MARKER}"), - cast("Iterable[SpanToken]", token.children), - Fragment("`"), - ) - - -def get_filename_from_module_name(module_name: str) -> str: - return f"{module_name}{PLAIN_SOURCE_FILE_EXTENSION}" - - -def get_module_name_from_filename(filename: str) -> str: - return filename.replace(PLAIN_SOURCE_FILE_EXTENSION, "") - - -def remove_quotes(token): - # If the token has no children, there's nothing to remove. - if not hasattr(token, "children") or token.children is None: - return - - # Convert children to a list for easy filtering. - children_list = list(token.children) - - # Build a filtered list that excludes Quote tokens. - new_children = [] - for child in children_list: - if not isinstance(child, Quote): - # Recursively remove quotes in any nested children. - remove_quotes(child) - new_children.append(child) - - # Convert the filtered list back to a tuple (or a list, if you prefer). - # Mistletoe tokens often expect a tuple, so tuple is safest. - token.children = tuple(new_children) - - -def check_section_for_linked_resources(section, template_dirs): - linked_resources = [] - for link in traverse(section, 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})." - ) - - resolved_target = file_utils.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"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})." - ) - - if len(link.node.children) != 1: - raise PlainSyntaxError(f"Plain syntax error: Link 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"Referencing .plain files through Linked Resources is not supported. " - "Please use the import section to include definitions or implementation/test requirements from another module." - ) - - linked_resources.append({"text": link.node.children[0].content, "target": link.node.target}) - - 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: - 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) - - -def process_section_code_variables(section, code_variables): - if "markdown" not in section: - return - - code_variables_array = [] - for key, value in code_variables.items(): - if key not in section["markdown"]: - continue - - variable_name = next(iter(value)) - section["markdown"] = section["markdown"].replace(key, f"{{{{ {variable_name} }}}}") - - code_variables_array.append({"name": variable_name, "value": value[variable_name]}) - - if code_variables_array: - section["code_variables"] = code_variables_array - - -def process_code_variables(plain_source, code_variables): - for specification_heading in plain_spec.ALLOWED_SPECIFICATION_HEADINGS: - if specification_heading in plain_source: - for requirement in plain_source[specification_heading]: - process_section_code_variables(requirement, code_variables) - - -def has_functional_specs_section(plain_source) -> bool: - """Whether the module declares a ***functional specs*** section (even if it is empty).""" - return plain_spec.FUNCTIONAL_REQUIREMENTS in plain_source - - -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 - - -def validate_functionalities_have_implementation_reqs(plain_source, module_name, has_requires=False) -> None: - """Raise if the module has functionalities but no implementation reqs are specified. - - ``has_requires`` indicates whether the module depends on another module via ``requires``. - Unlike ``import``, ``requires`` does not inherit implementation reqs, so a hint is added to - steer users who expected inheritance. - """ - implementation_reqs = plain_source[plain_spec.NON_FUNCTIONAL_REQUIREMENTS] - has_implementation_reqs = ( - implementation_reqs is not None - and hasattr(implementation_reqs, "children") - and len(implementation_reqs.children) > 0 - ) - if not has_implementation_reqs: - message = ( - f"Plain syntax error: Module '{module_name}' defines functionalities but specifies no " - f"{plain_spec.NON_FUNCTIONAL_REQUIREMENTS}. At least one implementation req is required." - ) - if has_requires: - message += ( - " Implementation reqs are not inherited through 'requires' — " - "add them to this module, or 'import' a module that provides them." - ) - 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 - elif hasattr(token, "children"): - if token.children is None: - return - for child in token.children: - yield from get_raw_text(child) - elif hasattr(token, "content"): - yield token.content - else: - raise Exception(f"Unknown token type: {type(token)}") - - -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: - if specification_heading in input_plain_source and hasattr( - input_plain_source[specification_heading], "children" - ): - list_of_requirements = [] - for requirement in input_plain_source[specification_heading].children: - requirement_section = {"markdown": renderer.render(requirement).strip()} - 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 - - return plain_source - - -class Plain2CodeDictLoader(Plain2CodeLoaderMixin, DictLoader): - pass - - -def render_plain_source(plain_source, loaded_templates, code_variables): - env = Environment(loader=Plain2CodeDictLoader(loaded_templates)) - env.tags["include"] = Plain2CodeIncludeTag(env) - env.filters["code_variable"] = plain_spec.code_variable_liquid_filter - env.filters["prohibited_chars"] = plain_spec.prohibited_chars_liquid_filter - - template = env.from_string(plain_source) - - return template.render(code_variables=code_variables) - - -def process_imports( - plain_source: dict, - imports: list[str], - code_variables: dict, - template_dirs: list[str], - imported_modules: list[str], - modules_trace: list[str], -) -> list[str]: - required_concepts = list[str]() - for module_name in imports: - if module_name in modules_trace: - raise PlainSyntaxError(f"Plain syntax error: Circular import detected: {module_name}.") - - if module_name in imported_modules: - continue - - plain_file_parse_result = parse_plain_file( - module_name, code_variables, template_dirs, imported_modules, modules_trace - ) - - if has_functional_specs_section(plain_file_parse_result.plain_source): - raise ImportedModuleWithFunctionalitiesError( - f"Module '{module_name}' is imported but contains functional specs. " - f"Imported modules may only provide definitions, implementation reqs, and test reqs — " - f"use 'requires' instead of 'import' if this module's functionalities should be built on." - ) - - for specification_heading in plain_file_parse_result.plain_source: - if specification_heading not in plain_spec.ALLOWED_IMPORT_SPECIFICATION_HEADINGS: - raise PlainSyntaxError( - f"Plain syntax error: Invalid specification heading (`{specification_heading}`). Allowed headings: {', '.join(plain_spec.ALLOWED_IMPORT_SPECIFICATION_HEADINGS)}" - ) - - if plain_source[specification_heading] is None: - plain_source[specification_heading] = plain_file_parse_result.plain_source[specification_heading] - elif plain_file_parse_result.plain_source[specification_heading] is not None: - plain_source[specification_heading].children.extend( - plain_file_parse_result.plain_source[specification_heading].children - ) - - if REQUIRED_CONCEPTS_DIRECTIVE in plain_file_parse_result.plain_source_obj.metadata: - for item in plain_file_parse_result.plain_source_obj.metadata[REQUIRED_CONCEPTS_DIRECTIVE]: - assert isinstance( - item, str - ), f"Syntax error: Invalid {REQUIRED_CONCEPTS_DIRECTIVE} metadata. Expected a string." - new_concepts, _ = concept_utils.extract_concepts_from_definition(item) - required_concepts.extend(new_concepts) - - required_concepts.extend(plain_file_parse_result.required_concepts) - - imported_modules.append(module_name) - - return required_concepts - - -def normalize_line_endings(plain_source_text: str) -> str: - return plain_source_text.replace("\r\n", "\n").replace("\r", "\n") - - -def restore_stripped_lines(plain_source_text: str, content: str) -> str: - stripped_source = plain_source_text.rstrip() - if not content or not stripped_source.endswith(content): - return content - - stripped_line_count = stripped_source[: len(stripped_source) - len(content)].count("\n") - return "\n" * stripped_line_count + content - - -def read_plain_source_metadata(plain_source_text): - try: - plain_source_obj = frontmatter.loads(plain_source_text) - except Exception as e: - raise PlainSyntaxError(f"Plain syntax error: Invalid frontmatter: {e}") - - for directive in [EXPORTED_CONCEPTS_DIRECTIVE, REQUIRED_CONCEPTS_DIRECTIVE]: - if directive in plain_source_obj.metadata: - assert isinstance( - plain_source_obj.metadata[directive], list - ), f"Syntax error: Invalid {directive} metadata. Expected a list." - prepared_metadata = [] - for item in plain_source_obj.metadata[directive]: - if isinstance(item, dict): - for k, v in item.items(): - prepared_metadata.append(f"- {k}: {v}") - elif isinstance(item, str): - prepared_metadata.append(f"- {item}") - else: - raise PlainSyntaxError( - f"Plain syntax error: Invalid {directive} metadata. Expected a dictionary or a string." - ) - - plain_source_obj.metadata[directive] = prepared_metadata - - 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], -) -> 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] = file_utils.get_loaded_templates(template_dirs, plain_source_text) - - 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) - - plain_file = mistletoe.Document(io.StringIO(plain_source_full_text)) - - remove_quotes(plain_file) - - current_specification_heading = 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 () - ) - if not ( - len(paragraph_children) == 1 - and isinstance(paragraph_children[0], Emphasis) - and len(emphasis_children) == 1 - and isinstance(emphasis_children[0], Strong) - ): - - raise PlainSyntaxError( - f"Plain syntax error: Syntax error at line {token.line_number}: Invalid specification (`{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: - 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." - ) - - 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)}" - ) - - 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}`)" - ) - - 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}`)" - ) - - current_specification_heading = specification_heading - if plain_source[current_specification_heading] is None: - plain_source[current_specification_heading] = mistletoe.Document("") - - processed_specification_headings.add(current_specification_heading) - elif isinstance(token, List): - if current_specification_heading is None: - raise PlainSyntaxError( - f"Plain syntax error: Syntax error at line {token.line_number}: Missing specification heading (`{token_text}`)" - ) - plain_source[current_specification_heading].children.extend(token.children) - - else: - raise PlainSyntaxError( - f"Plain syntax error: Syntax error at line {token.line_number}: 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." - ) - - defined_concepts = set[str]() - if plain_source[plain_spec.DEFINITIONS] is not None: - with PlainRenderer() as renderer: - for token in plain_source[plain_spec.DEFINITIONS].children: - rendered_token = renderer.render(token) - new_concepts, _ = concept_utils.extract_concepts_from_definition(rendered_token) - defined_concepts.update(new_concepts) - for concept in new_concepts: - if concept in required_concepts: - required_concepts.remove(concept) - - if EXPORTED_CONCEPTS_DIRECTIVE in plain_source_obj.metadata: - module_name = modules_trace[-1] if modules_trace else None - subject = f"Module '{module_name}'" if module_name else "Module" - - for entry in plain_source_obj.metadata[EXPORTED_CONCEPTS_DIRECTIVE]: - exported_concepts, errors = concept_utils.extract_concepts_from_definition(entry) - if errors: - raise PlainSyntaxError( - f"Plain syntax error: {subject} exports an invalid concept (`{entry}`). " - f"Expected a concept token, e.g. :ConceptName:." - ) - - for exported_concept in exported_concepts: - if exported_concept in concept_utils.DEFAULT_CONCEPTS: - raise PlainSyntaxError( - f"Plain syntax error: {subject} cannot export default concept {exported_concept}. " - f"Only user-defined concepts can be exported." - ) - - if exported_concept not in defined_concepts: - message = f"Plain syntax error: {subject} exports undefined concept {exported_concept}." - if REQUIRES_DIRECTIVE in plain_source_obj.metadata: - message += ( - " An exported concept must be defined in this module's definitions or in an " - "imported module; a concept inherited through requires cannot be re-exported." - ) - raise PlainSyntaxError(message) - - required_modules = [] - if REQUIRES_DIRECTIVE in plain_source_obj.metadata: - required_modules = plain_source_obj.metadata[REQUIRES_DIRECTIVE] - - return PlainFileParseResult( - plain_source=plain_source, - plain_source_obj=plain_source_obj, - required_modules=required_modules, - required_concepts=required_concepts, - ) - - -def read_module_plain_source(module_name: str, template_dirs: list[str]) -> str: - plain_source_text = file_utils.open_from(template_dirs, module_name + PLAIN_SOURCE_FILE_EXTENSION) - if plain_source_text is None: - raise ModuleDoesNotExistError(f"Module does not exist ({module_name}).") - - blob = find_large_base64_blob(plain_source_text) - if blob is not None: - raise UnsupportedBase64Content( - f"Module '{module_name}' contains a base64-encoded blob ({len(blob)} characters) " - "inlined in the specification. This is not supported." - "Remove the base64 data from the .plain file or if necessary," - "include the binary file path in the specification." - ) - - return plain_source_text - - -def parse_plain_file( - module_name: str, - code_variables: dict, - template_dirs: list[str], - imported_modules: list[str], - modules_trace: list[str], -) -> PlainFileParseResult: # noqa: C901 - plain_source_text = read_module_plain_source(module_name, template_dirs) - - return parse_plain_source( - plain_source_text, - code_variables, - template_dirs, - imported_modules, - modules_trace + [module_name], - ) - - -def process_required_modules( - required_modules: list[str], - code_variables: dict, - template_dirs: list[str], - all_required_modules: list[str], - modules_trace: list[str], -) -> list[mistletoe.block_token.token]: - exported_definitions = list[mistletoe.block_token.token]() - for module_name in required_modules: - if module_name in modules_trace: - raise PlainSyntaxError(f"Plain syntax error: Circular required module detected: {module_name}.") - - if len(all_required_modules) > 0 and module_name == all_required_modules[-1]: - continue - - plain_file_parse_result = parse_plain_file( - module_name, code_variables, template_dirs, imported_modules=[], modules_trace=[] - ) - - if len(plain_file_parse_result.required_modules) == 0: - if len(all_required_modules) > 0: - # For now we require that there is fixed order how required modules are dependent. - # In the future we will support the cases where required modules can be rendered independently - # and then merged (somehow). - raise PlainSyntaxError( - f"Plain syntax error: There must be a fixed order how required modules are dependent ({module_name})." - ) - else: - process_required_modules( - plain_file_parse_result.required_modules, - code_variables, - template_dirs, - all_required_modules, - modules_trace + [module_name], - ) - - if EXPORTED_CONCEPTS_DIRECTIVE in plain_file_parse_result.plain_source_obj.metadata: - exported_concepts = list[str]() - for concept in plain_file_parse_result.plain_source_obj.metadata[EXPORTED_CONCEPTS_DIRECTIVE]: - exported_concepts.extend(concept_utils.extract_concepts_from_definition(concept)[0]) - - with PlainRenderer() as renderer: - for exported_concept in exported_concepts: - exported_definitions.extend( - concept_utils.find_concept_definitions_in_plain_source( - exported_concept, plain_file_parse_result.plain_source, renderer - ) - ) - - all_required_modules.append(module_name) - - return exported_definitions - - -def process_exported_definitions(plain_source: dict, exported_definitions: list[mistletoe.block_token.token]) -> None: - if len(exported_definitions) == 0: - return - - if plain_source[plain_spec.DEFINITIONS] is None: - plain_source[plain_spec.DEFINITIONS] = mistletoe.Document("") - - with PlainRenderer() as renderer: - for exported_definition in exported_definitions: - add_defintion = True - - exported_rendered_definition = renderer.render(exported_definition).strip() - for definition in plain_source[plain_spec.DEFINITIONS].children: - rendered_definition = renderer.render(definition).strip() - if exported_rendered_definition == rendered_definition: - add_defintion = False - break - - if add_defintion: - plain_source[plain_spec.DEFINITIONS].children.append(exported_definition) - - -def plain_file_parser( # noqa: C901 - plain_source_file_name: str, - template_dirs: list[str], -) -> tuple[str, dict, list[str]]: - # code_variables are used to pass code variables to the plain source - # they need to be passed as an argument to the function because they populated when liquid templating is applied - # and we need to pass them to the marshalled_plain_source_tree after it's rendered - plain_source_file_path = Path(plain_source_file_name) - if plain_source_file_path.suffix != PLAIN_SOURCE_FILE_EXTENSION: - raise PlainSyntaxError( - f"Plain syntax error: Invalid plain file extension: {plain_source_file_path.suffix}. Expected: {PLAIN_SOURCE_FILE_EXTENSION}." - ) - - module_name = ( - plain_source_file_path.stem - if plain_source_file_path.is_absolute() - else plain_source_file_path.with_suffix("").as_posix() - ) - - code_variables = {} - - plain_file_parse_result = parse_plain_file( - module_name, - code_variables, - template_dirs, - imported_modules=[], - modules_trace=[], - ) - - if len(plain_file_parse_result.required_concepts) > 0: - missing_required_concepts_msg = "Missing required concepts: " - missing_required_concepts_msg += ", ".join(plain_file_parse_result.required_concepts) - raise PlainSyntaxError( - f"Plain syntax error: Not all required concepts were defined. {missing_required_concepts_msg}." - ) - - if not has_functional_specs_section(plain_file_parse_result.plain_source): - # No ***functional specs*** section at all: valid as an import, but not renderable. Usage error. - raise MissingFunctionalitiesError( - f"Module '{module_name}' does not have any functionality specified. " - f"At least one functionality is required for rendering." - ) - - 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." - ) - - validate_functionalities_have_implementation_reqs( - plain_file_parse_result.plain_source, - module_name, - has_requires=bool(plain_file_parse_result.required_modules), - ) - - exported_definitions = process_required_modules( - plain_file_parse_result.required_modules, - code_variables={}, - template_dirs=template_dirs, - all_required_modules=[], - modules_trace=[], - ) - - 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) - - process_code_variables(marshalled_plain_source, code_variables) - - validation_errors = concept_utils.validate_concepts(marshalled_plain_source) - if len(validation_errors) > 0: - errors_msg = "\n".join(validation_errors) - msg = f"Found {len(validation_errors)} errors in the plain file:\n{errors_msg}" - raise PlainSyntaxError(f"Plain syntax error: {msg}") - - if plain_spec.DEFINITIONS in marshalled_plain_source: - concept_utils.sort_definitions(marshalled_plain_source[plain_spec.DEFINITIONS]) - - return module_name, marshalled_plain_source, plain_file_parse_result.required_modules +from plain_parser.plain_file import * # noqa: F401,F403 diff --git a/plain_spec.py b/plain_spec.py index 003519e3..5e0d51f3 100644 --- a/plain_spec.py +++ b/plain_spec.py @@ -1,403 +1,23 @@ -import hashlib -import json -import uuid -from typing import Optional +"""Compatibility shim: the parser now lives in the ``plain-parser`` package. -from liquid2.filter import with_context +The render-range helpers below stay here because they read codeplain's CLI +arguments (``--render-range`` / ``--render-from``); the FRID slicing they wrap +is ``plain_parser.plain_spec.get_frids_range``. +""" -from plain2code_exceptions import InvalidFridArgument, InvalidLiquidVariableName - -DEFINITIONS = "definitions" -NON_FUNCTIONAL_REQUIREMENTS = "implementation reqs" -TEST_REQUIREMENTS = "test reqs" -FUNCTIONAL_REQUIREMENTS = "functional specs" -ACCEPTANCE_TESTS = "acceptance_tests" -ACCEPTANCE_TEST_HEADING = "acceptance tests" - -ALLOWED_SPECIFICATION_HEADINGS = [ - DEFINITIONS, - NON_FUNCTIONAL_REQUIREMENTS, - TEST_REQUIREMENTS, - FUNCTIONAL_REQUIREMENTS, - ACCEPTANCE_TEST_HEADING, -] - -ALLOWED_IMPORT_SPECIFICATION_HEADINGS = [DEFINITIONS, NON_FUNCTIONAL_REQUIREMENTS, TEST_REQUIREMENTS] - - -def has_acceptance_tests(plain_source_tree) -> bool: - """Return True if any functional requirement in the plain source defines acceptance tests.""" - functional_requirements = plain_source_tree.get(FUNCTIONAL_REQUIREMENTS) or [] - for functional_requirement in functional_requirements: - if functional_requirement.get(ACCEPTANCE_TESTS): - return True - return False - - -def collect_specification_linked_resources(specification, specification_heading, linked_resources_list): - linked_resources = [] - if "linked_resources" in specification: - linked_resources.extend(specification["linked_resources"]) - - for resource in linked_resources: - resource_found = False - for resource_map in linked_resources_list: - if resource["text"] == resource_map["text"]: - if resource["target"] != resource_map["target"]: - raise Exception( - f"The file {resource['target']} is linked to multiple linked resources with the same text: {resource['text']}" - ) - elif resource["target"] == resource_map["target"]: - if resource["text"] != resource_map["text"]: - raise Exception( - f"The linked resource {resource['text']} is linked to multiple files: {resource_map['target']}" - ) - else: - continue - - if resource_found: - raise Exception( - "Duplicate linked resource found: " + resource["text"] + " (" + resource["target"] + ")" - ) - - resource_found = True - resource_map["sections"].append(specification_heading) - - if not resource_found: - linked_resources_list.append( - {"text": resource["text"], "target": resource["target"], "sections": [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 -): - - 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.") - - result = collect_linked_resources_in_section( - plain_source_tree, linked_resources_list, specifications_list, include_acceptance_tests, frid - ) - - # Sort linked_resources_list by the "text" field - linked_resources_list.sort(key=lambda x: x["text"]) - - return result - - -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) - - -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: - if temp_frid == frid: - return next(functional_requirements, None) - - raise Exception(f"Functionality {frid} does not exist.") - - -def get_previous_frid(plain_source_tree, frid): - previous_frid = None - for temp_frid in get_frids(plain_source_tree): - if temp_frid == frid: - return previous_frid - - previous_frid = temp_frid - - raise Exception(f"Functionality {frid} does not exist.") - - -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 - """ - previous_frids = [] - for frid in get_frids(plain_source_tree): - if frid == target_frid: - break - previous_frids.append(frid) - - return previous_frids - - -def get_specification_item_markdown(specification_item, code_variables, replace_code_variables): - markdown = specification_item["markdown"] - if "code_variables" in specification_item: - for code_variable in specification_item["code_variables"]: - if code_variable["name"] in code_variables: - if code_variables[code_variable["name"]] != code_variable["value"]: - raise Exception( - f"Code variable {code_variable['name']} has multiple values: {code_variables[code_variable['name']]} and {code_variable['value']}" - ) - else: - code_variables[code_variable["name"]] = code_variable["value"] - - if replace_code_variables: - markdown = markdown.replace(f"{{{{ {code_variable['name']} }}}}", code_variable["value"]) - - 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 = [] - - 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.") - - specifications = { - DEFINITIONS: definitions, - NON_FUNCTIONAL_REQUIREMENTS: non_functional_requirements, - TEST_REQUIREMENTS: test_requirements, - FUNCTIONAL_REQUIREMENTS: functional_requirements, - } - - if acceptance_tests: - specifications[ACCEPTANCE_TESTS] = acceptance_tests - - if code_variables: - return specifications, code_variables - else: - return specifications, None - - -@with_context -def code_variable_liquid_filter(value, *, context): - if len(context.scope) == 0: - raise Exception("Invalid use of code_variable filter!") - - if "code_variables" in context.globals: - code_variables = context.globals["code_variables"] - - variable = next(iter(context.scope.items())) - - unique_str = uuid.uuid4().hex - - code_variables[unique_str] = {variable[0]: value} - - return unique_str - else: - - return value - - -@with_context -def prohibited_chars_liquid_filter(value, prohibited_chars, *, context): - if not isinstance(value, str): - value = str(value) - - if len(context.scope) == 0: - raise Exception("Invalid use of prohibited_chars filter!") - - variable = next(iter(context.scope.items())) - variable_name = variable[0] - - for char in prohibited_chars: - if char in value: - raise InvalidLiquidVariableName( - f"'{char}' is not a valid character for variable '{variable_name}' (value: '{value}')." - ) - - return value - - -def hash_text(text): - return hashlib.sha256(text.encode()).hexdigest() - - -def get_hash_value(specifications): - return hash_text(json.dumps(specifications, indent=4)) +from plain_parser.plain_spec import * # noqa: F401,F403 +from plain_parser.plain_spec import get_frids_range def get_render_range(render_range, plain_source): render_range = render_range.split(",") range_end = render_range[1] if len(render_range) == 2 else render_range[0] - return _get_frids_range(plain_source, render_range[0], range_end) + return get_frids_range(plain_source, render_range[0], range_end) def get_render_range_from(start, plain_source): - return _get_frids_range(plain_source, start) + return get_frids_range(plain_source, start) def compute_render_range(args, plain_source_tree): @@ -415,27 +35,3 @@ def compute_render_range(args, plain_source_tree): elif args.render_from: return get_render_range_from(args.render_from, plain_source_tree) return None - - -def _get_frids_range(plain_source, start, end=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}.") - - end_idx = frids.index(end) + 1 - else: - end_idx = len(frids) - - start_idx = frids.index(start) - if start_idx >= end_idx: - raise InvalidFridArgument(f"Start functionality ID: {start} must be before end functionality ID: {end}.") - - return frids[start_idx:end_idx] diff --git a/pyproject.toml b/pyproject.toml index 5247e384..ecafff2f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,8 +15,13 @@ classifiers = [ "Topic :: Software Development :: Code Generators", ] dependencies = [ - "python-liquid2==0.4.0", + "plain-parser==0.1.4", + + # Both transitive via plain-parser; pinned so an automatic mistletoe upgrade doesn't change spec hashes. "mistletoe==1.6.0", + "python-frontmatter==1.3.0", + + "python-liquid2==0.4.0", "requests==2.34.2", "tiktoken==0.14.0", "PyYAML==6.0.3", @@ -24,8 +29,6 @@ dependencies = [ "transitions==0.9.3", "textual>=7.5.0", "rich==15.0.0", - "python-frontmatter==1.3.0", - "networkx==3.6.1", "sentry-sdk==2.73.0", ] diff --git a/requirements.txt b/requirements.txt index 5a120852..8315f1ef 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,10 @@ -python-liquid2==0.4.0 +plain-parser==0.1.4 + +# Both transitive via plain-parser; pinned so an automatic mistletoe upgrade doesn't change spec hashes. mistletoe==1.6.0 python-frontmatter==1.3.0 + +python-liquid2==0.4.0 requests==2.34.2 rich==15.0.0 tiktoken==0.14.0 @@ -8,7 +12,6 @@ PyYAML==6.0.3 gitpython==3.2.1 pytest==9.1.1 textual>=7.5.0 -networkx==3.6.1 transitions==0.9.3 sentry-sdk==2.73.0 diff --git a/tests/data/imports/circular_imports_1.plain b/tests/data/imports/circular_imports_1.plain deleted file mode 100644 index a04ccf63..00000000 --- a/tests/data/imports/circular_imports_1.plain +++ /dev/null @@ -1,8 +0,0 @@ ---- -import: - - circular_imports_2 ---- - -***definitions*** - -- :App: is a console application. diff --git a/tests/data/imports/circular_imports_2.plain b/tests/data/imports/circular_imports_2.plain deleted file mode 100644 index 39ff2eeb..00000000 --- a/tests/data/imports/circular_imports_2.plain +++ /dev/null @@ -1,8 +0,0 @@ ---- -import: - - circular_imports_1 ---- - -***implementation reqs*** - -- :Implementation: should include :UnitTests: using Unittest framework! diff --git a/tests/data/imports/circular_imports_main.plain b/tests/data/imports/circular_imports_main.plain deleted file mode 100644 index 828c0b65..00000000 --- a/tests/data/imports/circular_imports_main.plain +++ /dev/null @@ -1,12 +0,0 @@ ---- -import: - - circular_imports_1 ---- - -***implementation reqs*** - -- :MainExecutableFile: of :App: should be called "hello_world.py". - -***functional specs*** - -- Display "hello, world" \ No newline at end of file diff --git a/tests/data/imports/diamond_import_1.plain b/tests/data/imports/diamond_import_1.plain deleted file mode 100644 index e4752bd7..00000000 --- a/tests/data/imports/diamond_import_1.plain +++ /dev/null @@ -1,16 +0,0 @@ ---- -import: - - diamond_import_common ---- - -***definitions*** - -- :Import1Def: is a definition in diamond_import_1. - -***implementation reqs*** - -- :Import1Def: is used in diamond_import_1. - -***test reqs*** - -- :Import1Def: is tested in diamond_import_1. diff --git a/tests/data/imports/diamond_import_2.plain b/tests/data/imports/diamond_import_2.plain deleted file mode 100644 index 4e8ce6ca..00000000 --- a/tests/data/imports/diamond_import_2.plain +++ /dev/null @@ -1,16 +0,0 @@ ---- -import: - - diamond_import_common ---- - -***definitions*** - -- :Import2Def: is a definition in diamond_import_2. - -***implementation reqs*** - -- :Import2Def: is used in diamond_import_2. - -***test reqs*** - -- :Import2Def: is tested in diamond_import_2. diff --git a/tests/data/imports/diamond_import_common.plain b/tests/data/imports/diamond_import_common.plain deleted file mode 100644 index 27d7bcc4..00000000 --- a/tests/data/imports/diamond_import_common.plain +++ /dev/null @@ -1,11 +0,0 @@ -***definitions*** - -- :CommonImportDef: is a definition in diamond_import_common. - -***implementation reqs*** - -- :CommonImportDef: is used in diamond_import_common. - -***test reqs*** - -- :CommonImportDef: is tested in diamond_import_common. \ No newline at end of file diff --git a/tests/data/imports/diamond_imports_main.plain b/tests/data/imports/diamond_imports_main.plain deleted file mode 100644 index 9d06ea2f..00000000 --- a/tests/data/imports/diamond_imports_main.plain +++ /dev/null @@ -1,13 +0,0 @@ ---- -import: - - diamond_import_1 - - diamond_import_2 ---- - -***implementation reqs*** - -- :MainExecutableFile: of :App: should be called "hello_world.py". - -***functional specs*** - -- 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 deleted file mode 100644 index 577041dd..00000000 --- a/tests/data/imports/import_with_functionalities.plain +++ /dev/null @@ -1,7 +0,0 @@ -***implementation reqs*** - -- :SomeDef: is used in import_with_functionalities. - -***functional specs*** - -- Display "hello, world" diff --git a/tests/data/imports/imports_module_with_functionalities.plain b/tests/data/imports/imports_module_with_functionalities.plain deleted file mode 100644 index f2ff5173..00000000 --- a/tests/data/imports/imports_module_with_functionalities.plain +++ /dev/null @@ -1,12 +0,0 @@ ---- -import: - - import_with_functionalities ---- - -***implementation reqs*** - -- :MainExecutableFile: of :App: should be called "hello_world.py". - -***functional specs*** - -- Display "hello, world" diff --git a/tests/data/imports/non_existent_import.plain b/tests/data/imports/non_existent_import.plain deleted file mode 100644 index e3c902d0..00000000 --- a/tests/data/imports/non_existent_import.plain +++ /dev/null @@ -1,12 +0,0 @@ ---- -import: - - non_existent_import ---- - -***implementation reqs*** - -- :MainExecutableFile: of :App: should be called "hello_world.py". - -***functional specs*** - -- Display "hello, world" \ No newline at end of file diff --git a/tests/data/plainfile/add_new_task_modal_specification.yaml b/tests/data/plainfile/add_new_task_modal_specification.yaml deleted file mode 100644 index 26a745dd..00000000 --- a/tests/data/plainfile/add_new_task_modal_specification.yaml +++ /dev/null @@ -1 +0,0 @@ -a: b diff --git a/tests/data/plainfile/duplicate_specification_heading.plain b/tests/data/plainfile/duplicate_specification_heading.plain deleted file mode 100644 index 82d85eca..00000000 --- a/tests/data/plainfile/duplicate_specification_heading.plain +++ /dev/null @@ -1,7 +0,0 @@ -> Example of a Plain file with duplicate specification heading. - -***definitions*** -- Test definition 1 - -***definitions*** -- Test definition 2 diff --git a/tests/data/plainfile/empty_functional_specs_section.plain b/tests/data/plainfile/empty_functional_specs_section.plain deleted file mode 100644 index a4a74de5..00000000 --- a/tests/data/plainfile/empty_functional_specs_section.plain +++ /dev/null @@ -1,4 +0,0 @@ -***implementation reqs*** -- Some requirement - -***functional specs*** diff --git a/tests/data/plainfile/invalid_specification_order.plain b/tests/data/plainfile/invalid_specification_order.plain deleted file mode 100644 index a5d0f3df..00000000 --- a/tests/data/plainfile/invalid_specification_order.plain +++ /dev/null @@ -1,7 +0,0 @@ -> Example of a Plain file with invalid specification order. - -***implementation reqs*** -- Test implementation requirement - -***definitions*** -- Test definition diff --git a/tests/data/plainfile/missing_non_functional_requirements.plain b/tests/data/plainfile/missing_non_functional_requirements.plain deleted file mode 100644 index 8c4aa761..00000000 --- a/tests/data/plainfile/missing_non_functional_requirements.plain +++ /dev/null @@ -1,5 +0,0 @@ -***definitions*** -- Test definition - -***functional specs*** -- Test functionality diff --git a/tests/data/plainfile/no_functional_specs_section.plain b/tests/data/plainfile/no_functional_specs_section.plain deleted file mode 100644 index be2ef7a7..00000000 --- a/tests/data/plainfile/no_functional_specs_section.plain +++ /dev/null @@ -1,5 +0,0 @@ -***definitions*** -- Test definition - -***implementation reqs*** -- Some requirement diff --git a/tests/data/plainfile/plain_source_with_absolute_link.plain b/tests/data/plainfile/plain_source_with_absolute_link.plain deleted file mode 100644 index 627d089c..00000000 --- a/tests/data/plainfile/plain_source_with_absolute_link.plain +++ /dev/null @@ -1,9 +0,0 @@ -***definitions*** - -***implementation reqs*** - -- Implementation should be written in Python. - -***functional specs*** - -- 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 deleted file mode 100644 index 4f06694e..00000000 --- a/tests/data/plainfile/plain_source_with_missing_link.plain +++ /dev/null @@ -1,9 +0,0 @@ -***definitions*** - -***implementation reqs*** - -- Implementation should be written in Python. - -***functional specs*** - -- 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 deleted file mode 100644 index 47efdd11..00000000 --- a/tests/data/plainfile/plain_source_with_url_link.plain +++ /dev/null @@ -1,9 +0,0 @@ -***definitions*** - -***implementation reqs*** - -- Implementation should be written in Python. - -***functional specs*** - -- URL links are not allowed for now [url_link](http://www.example.com). diff --git a/tests/data/plainfile/task_list_ui_specification.yaml b/tests/data/plainfile/task_list_ui_specification.yaml deleted file mode 100644 index 26a745dd..00000000 --- a/tests/data/plainfile/task_list_ui_specification.yaml +++ /dev/null @@ -1 +0,0 @@ -a: b diff --git a/tests/data/plainfile/task_manager_with_reference_links.plain b/tests/data/plainfile/task_manager_with_reference_links.plain deleted file mode 100644 index bbddc31e..00000000 --- a/tests/data/plainfile/task_manager_with_reference_links.plain +++ /dev/null @@ -1,40 +0,0 @@ -***definitions*** - -- :App: implements a task manager application. - -- :User: is the user of :App:. - -- :Task: describes an activity that needs to be done by :User:. :Task: has the following attributes - - Name - a short description of The Task. This is a required attribute. The name must be at least 3 characters long. - - Notes - additional details about The Task - - Due Date - optional date by which The User is supposed to complete :Task:. - -- :TaskList: is a list of :Task: items. - - Initially :TaskList: should be empty. - - -***implementation reqs*** - -- Implementation should be written in TypeScript, using React as a web framework. - -- Please note that :App: will be executed using npm. - - -***test reqs*** - -- End-to-end tests should be written in TypeScript, using Cypress as the end-to-end testing framework. - - -***functional specs*** - -- 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). - -- :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 edit :Task:. - -- :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 deleted file mode 100644 index 1aca180f..00000000 --- a/tests/data/plainfile/without_non_functional_requirement.plain +++ /dev/null @@ -1,5 +0,0 @@ -***implementation reqs*** - -***functional specs*** - -- 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 f9b54950..00000000 --- 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 f492c452..00000000 --- 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 deleted file mode 100644 index 0a316715..00000000 --- a/tests/data/plainfileparser/concept_validation_defined_nondefined.plain +++ /dev/null @@ -1,18 +0,0 @@ -# "hello, world" in Plain (Python version) - -***definitions*** - -- :concept: is a concept. - -***test reqs*** - -- You can test the :concept:. - -***implementation reqs*** - -- You cant solve the :nonDefinedConcept:. -- If you do, let me know. - -***functional specs*** - -- 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 deleted file mode 100644 index b3fcb6a8..00000000 --- a/tests/data/plainfileparser/concept_validation_defined_nondefined_2.plain +++ /dev/null @@ -1,18 +0,0 @@ -# "hello, world" in Plain (Python version) - -***definitions*** - -- :concept: is a concept. - -***test reqs*** - -- You can test the :concept:. - -***implementation reqs*** - -- You can solve the :concept:. -- If you do, let me know. - -***functional specs*** - -- Implement the :nonDefinedConcept:. diff --git a/tests/data/plainfileparser/concept_validation_definition.plain b/tests/data/plainfileparser/concept_validation_definition.plain deleted file mode 100644 index 1885e67a..00000000 --- a/tests/data/plainfileparser/concept_validation_definition.plain +++ /dev/null @@ -1,16 +0,0 @@ -***definitions*** - -- :concept: is a concept. - -***test reqs*** - -- You can test the hardest problem. - -***implementation reqs*** - -- You cannot solve the hardest problem. -- If you do, let me know. - -***functional specs*** - -- Display "hello, world" diff --git a/tests/data/plainfileparser/concept_validation_noconcepts.plain b/tests/data/plainfileparser/concept_validation_noconcepts.plain deleted file mode 100644 index 8787e9c6..00000000 --- a/tests/data/plainfileparser/concept_validation_noconcepts.plain +++ /dev/null @@ -1,18 +0,0 @@ -# "hello, world" in Plain (Python version) - -***definitions*** - -- This is a definition - -***test reqs*** - -- You can test the hardest problem. - -***implementation reqs*** - -- You cannot solve the hardest problem. -- If you do, let me know. - -***functional specs*** - -- Display "hello, world" diff --git a/tests/data/plainfileparser/concept_validation_nonconcept.plain b/tests/data/plainfileparser/concept_validation_nonconcept.plain deleted file mode 100644 index c42c5e39..00000000 --- a/tests/data/plainfileparser/concept_validation_nonconcept.plain +++ /dev/null @@ -1,15 +0,0 @@ -***definitions*** - -- :concept: is a concept. - -***test reqs*** - -- You can test the :concept:. - -***implementation reqs*** - -- :concept: should connect to http://localhost:8080. - -***functional specs*** - -- Display "hello, world" diff --git a/tests/data/plainfileparser/concept_validation_nondefined.plain b/tests/data/plainfileparser/concept_validation_nondefined.plain deleted file mode 100644 index b8cc98c6..00000000 --- a/tests/data/plainfileparser/concept_validation_nondefined.plain +++ /dev/null @@ -1,18 +0,0 @@ -# "hello, world" in Plain (Python version) - -***definitions*** - -- :concept: is a concept. - -***test reqs*** - -- You cant test a :nonDefinedConcept:. - -***implementation reqs*** - -- You cannot solve the hardest problem. -- If you do, let me know. - -***functional specs*** - -- 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 deleted file mode 100644 index abed78e1..00000000 --- a/tests/data/plainfileparser/concept_validation_redefinition.plain +++ /dev/null @@ -1,19 +0,0 @@ -# "hello, world" in Plain (Python version) - -***definitions*** - -- :concept: is a concept. -- :concept: is another 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" diff --git a/tests/data/plainfileparser/concept_validation_several_concepts.plain b/tests/data/plainfileparser/concept_validation_several_concepts.plain deleted file mode 100644 index 741576bd..00000000 --- a/tests/data/plainfileparser/concept_validation_several_concepts.plain +++ /dev/null @@ -1,13 +0,0 @@ -***definitions*** - -- :concept1:, :concept2:, :concept3: are all concepts. - -***implementation reqs*** - -- :concept1: should connect to http://localhost:8080. - -***functional specs*** - -- Display :concept1:. -- Display :concept2:. -- Display :concept3:. diff --git a/tests/data/plainfileparser/concept_validation_valid.plain b/tests/data/plainfileparser/concept_validation_valid.plain deleted file mode 100644 index 08780222..00000000 --- a/tests/data/plainfileparser/concept_validation_valid.plain +++ /dev/null @@ -1,16 +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" diff --git a/tests/data/plainfileparser/cyclic_definitions.plain b/tests/data/plainfileparser/cyclic_definitions.plain deleted file mode 100644 index 154492d4..00000000 --- a/tests/data/plainfileparser/cyclic_definitions.plain +++ /dev/null @@ -1,9 +0,0 @@ -***definitions*** -- :A: relates to :B:. -- :B: relates to :A:. - -***implementation reqs*** -- The :A: concept must be implemented. - -***functional specs*** -- Implement a wrapper for the :A: concept. diff --git a/tests/data/plainfileparser/exported_concepts.plain b/tests/data/plainfileparser/exported_concepts.plain deleted file mode 100644 index a8425c91..00000000 --- a/tests/data/plainfileparser/exported_concepts.plain +++ /dev/null @@ -1,13 +0,0 @@ ---- -exported_concepts: - - :Concept: is a concept. ---- - -***definitions*** -- :Concept: is a concept. - -***implementation reqs*** -- The :Concept: concept must be implemented. - -***functional specs*** -- Implement a wrapper for the :Concept: concept. \ No newline at end of file diff --git a/tests/data/plainfileparser/exported_concepts_base.plain b/tests/data/plainfileparser/exported_concepts_base.plain deleted file mode 100644 index fcdb0f2c..00000000 --- a/tests/data/plainfileparser/exported_concepts_base.plain +++ /dev/null @@ -1,2 +0,0 @@ -***definitions*** -- :Concept: is a concept. diff --git a/tests/data/plainfileparser/exported_concepts_defaults_exported.plain b/tests/data/plainfileparser/exported_concepts_defaults_exported.plain deleted file mode 100644 index 014a0095..00000000 --- a/tests/data/plainfileparser/exported_concepts_defaults_exported.plain +++ /dev/null @@ -1,13 +0,0 @@ ---- -exported_concepts: - - :UnitTests: ---- - -***definitions*** -- :NewConcept: is a new concept. - -***implementation reqs*** -- The :NewConcept: must be implemented. - -***functional specs*** -- 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 deleted file mode 100644 index 47478391..00000000 --- a/tests/data/plainfileparser/exported_concepts_example.plain +++ /dev/null @@ -1,13 +0,0 @@ ---- -requires: - - exported_concepts ---- - -***definitions*** -- :DependantConcept: depends on the :Concept: concept. - -***implementation reqs*** -- The :DependantConcept: concept must be implemented. - -***functional specs*** -- 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 deleted file mode 100644 index 8c3e9837..00000000 --- a/tests/data/plainfileparser/exported_concepts_imported_exported.plain +++ /dev/null @@ -1,12 +0,0 @@ ---- -import: - - exported_concepts_base -exported_concepts: - - :Concept: ---- - -***implementation reqs*** -- The :Concept: concept must be implemented. - -***functional specs*** -- 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 deleted file mode 100644 index 660943c1..00000000 --- a/tests/data/plainfileparser/exported_concepts_inherited_reexported.plain +++ /dev/null @@ -1,15 +0,0 @@ ---- -requires: - - exported_concepts -exported_concepts: - - :Concept: ---- - -***definitions*** -- :DependantConcept: depends on the :Concept: concept. - -***implementation reqs*** -- The :DependantConcept: concept must be implemented. - -***functional specs*** -- 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 deleted file mode 100644 index c5528ff3..00000000 --- a/tests/data/plainfileparser/exported_concepts_malformed.plain +++ /dev/null @@ -1,13 +0,0 @@ ---- -exported_concepts: - - not a concept at all ---- - -***definitions*** -- :A: is a concept. - -***implementation reqs*** -- The :A: concept must be implemented. - -***functional specs*** -- 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 deleted file mode 100644 index 6c0ae876..00000000 --- a/tests/data/plainfileparser/exported_concepts_missing_definition.plain +++ /dev/null @@ -1,14 +0,0 @@ ---- -exported_concepts: - - :NewConcept: - - :Concept: ---- - -***definitions*** -- :NewConcept: is a new concept. - -***implementation reqs*** -- The :NewConcept: must be implemented. - -***functional specs*** -- 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 deleted file mode 100644 index 7ad273d4..00000000 --- a/tests/data/plainfileparser/exported_concepts_missing_definition_example.plain +++ /dev/null @@ -1,13 +0,0 @@ ---- -requires: - - exported_concepts_missing_definition ---- - -***definitions*** -- :AnotherConcept: is a new concept. - -***implementation reqs*** -- :AnotherConcept: must be implemented. - -***functional specs*** -- 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 deleted file mode 100644 index fc05887d..00000000 --- a/tests/data/plainfileparser/exported_concepts_multiple_per_bullet.plain +++ /dev/null @@ -1,14 +0,0 @@ ---- -exported_concepts: - - :NewConcept:, :OtherConcept: ---- - -***definitions*** -- :NewConcept: is a new concept. -- :OtherConcept: is another concept. - -***implementation reqs*** -- The :NewConcept: and :OtherConcept: concepts must be implemented. - -***functional specs*** -- 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 deleted file mode 100644 index 064ee9cc..00000000 --- a/tests/data/plainfileparser/exported_concepts_multiple_per_bullet_undefined.plain +++ /dev/null @@ -1,13 +0,0 @@ ---- -exported_concepts: - - :NewConcept:, :OtherConcept: ---- - -***definitions*** -- :NewConcept: is a new concept. - -***implementation reqs*** -- The :NewConcept: concept must be implemented. - -***functional specs*** -- 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 deleted file mode 100644 index 618ad06d..00000000 --- a/tests/data/plainfileparser/exported_concepts_nested.plain +++ /dev/null @@ -1,15 +0,0 @@ ---- -import: - - exported_concepts_base -exported_concepts: - - :NestedConcept: ---- - -***definitions*** -- :NestedConcept: depends on the :Concept: concept. - -***implementation reqs*** -- The :NestedConcept: concept must be implemented. - -***functional specs*** -- 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 deleted file mode 100644 index bb99c9d6..00000000 --- a/tests/data/plainfileparser/exported_concepts_nested_example.plain +++ /dev/null @@ -1,13 +0,0 @@ ---- -requires: - - exported_concepts_nested ---- - -***definitions*** -- :DependantConcept: depends on the :NestedConcept: concept. - -***implementation reqs*** -- The :DependantConcept: concept must be implemented. - -***functional specs*** -- 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 deleted file mode 100644 index 5e8ec496..00000000 --- a/tests/data/plainfileparser/exported_concepts_no_definitions.plain +++ /dev/null @@ -1,10 +0,0 @@ ---- -exported_concepts: - - :Concept: ---- - -***implementation reqs*** -- :Implementation: is a simple wrapper. - -***functional specs*** -- :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 deleted file mode 100644 index a9e0ba53..00000000 --- a/tests/data/plainfileparser/exported_concepts_not_declared.plain +++ /dev/null @@ -1,8 +0,0 @@ -***definitions*** -- :Concept: is a concept. - -***implementation reqs*** -- The :Concept: concept must be implemented. - -***functional specs*** -- 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 deleted file mode 100644 index c3b77d28..00000000 --- a/tests/data/plainfileparser/exported_concepts_not_declared_example.plain +++ /dev/null @@ -1,13 +0,0 @@ ---- -requires: - - exported_concepts_not_declared ---- - -***definitions*** -- :DependantConcept: depends on the :Concept: concept. - -***implementation reqs*** -- The :DependantConcept: concept must be implemented. - -***functional specs*** -- 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 deleted file mode 100644 index 781c3835..00000000 --- a/tests/data/plainfileparser/exported_concepts_transitive_example.plain +++ /dev/null @@ -1,13 +0,0 @@ ---- -requires: - - exported_concepts_transitive_l1 ---- - -***definitions*** -- :SomeOtherDependantConcept: depends on the :Concept: concept. - -***implementation reqs*** -- The :SomeOtherDependantConcept: concept must be implemented. - -***functional specs*** -- 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 deleted file mode 100644 index 45e698d4..00000000 --- a/tests/data/plainfileparser/exported_concepts_transitive_l1.plain +++ /dev/null @@ -1,13 +0,0 @@ ---- -requires: - - exported_concepts_transitive_l2 ---- - -***definitions*** -- :DependantConcept: depends on the :Concept: concept. - -***implementation reqs*** -- The :DependantConcept: concept must be implemented. - -***functional specs*** -- 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 deleted file mode 100644 index 35bfa920..00000000 --- a/tests/data/plainfileparser/exported_concepts_transitive_l2.plain +++ /dev/null @@ -1,13 +0,0 @@ ---- -exported_concepts: - - :Concept: ---- - -***definitions*** -- :Concept: is a concept. - -***implementation reqs*** -- The :Concept: concept must be implemented. - -***functional specs*** -- Implement the :Concept: concept. \ No newline at end of file diff --git a/tests/data/plainfileparser/plain_file_parser_with_comments.plain b/tests/data/plainfileparser/plain_file_parser_with_comments.plain deleted file mode 100644 index e1320b21..00000000 --- a/tests/data/plainfileparser/plain_file_parser_with_comments.plain +++ /dev/null @@ -1,8 +0,0 @@ -***definitions*** - -***implementation reqs*** ->- First implementation requirement. -- Second implementation requirement. - -***functional specs*** -- 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 deleted file mode 100644 index 58faf5b3..00000000 --- a/tests/data/plainfileparser/plain_file_with_comments_indented.plain +++ /dev/null @@ -1,10 +0,0 @@ -***definitions*** - -***implementation reqs*** -- First implementation requirement. -- Second implementation requirement. - - > This is a comment - -***functional specs*** -- 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 deleted file mode 100644 index 71f673c0..00000000 --- a/tests/data/plainfileparser/regular_plain_source.plain +++ /dev/null @@ -1,11 +0,0 @@ -***definitions*** - -***implementation reqs*** - -- First implementation requirement. - -- Second implementation requirement. - - -***functional specs*** -- Display "hello, world" diff --git a/tests/data/plainfileparser/required_concepts.plain b/tests/data/plainfileparser/required_concepts.plain deleted file mode 100644 index 3a5f7a12..00000000 --- a/tests/data/plainfileparser/required_concepts.plain +++ /dev/null @@ -1,7 +0,0 @@ ---- -required_concepts: - - :Concept: is a required concept and needs to be defined in the module that imports this one. ---- - -***definitions*** -- :DependantConcept: is a concept that depends on the :Concept: concept. \ No newline at end of file diff --git a/tests/data/plainfileparser/required_concepts_defs.plain b/tests/data/plainfileparser/required_concepts_defs.plain deleted file mode 100644 index 0b56fa8f..00000000 --- a/tests/data/plainfileparser/required_concepts_defs.plain +++ /dev/null @@ -1,2 +0,0 @@ -***definitions*** -- :Concept: is a concept. \ No newline at end of file diff --git a/tests/data/plainfileparser/required_concepts_example.plain b/tests/data/plainfileparser/required_concepts_example.plain deleted file mode 100644 index 1e3e0e6b..00000000 --- a/tests/data/plainfileparser/required_concepts_example.plain +++ /dev/null @@ -1,13 +0,0 @@ ---- -import - - required_concepts ---- - -***definitions*** -- :Concept: is a concept. - -***implementation reqs*** -- The :Concept: concept must be implemented. - -***functional specs*** -- Implement the :Concept: concept. \ No newline at end of file diff --git a/tests/data/plainfileparser/required_concepts_l1.plain b/tests/data/plainfileparser/required_concepts_l1.plain deleted file mode 100644 index 3b6b3986..00000000 --- a/tests/data/plainfileparser/required_concepts_l1.plain +++ /dev/null @@ -1,7 +0,0 @@ ---- -import: - - required_concepts_l2 ---- - -***definitions*** -- :Concept1: is a concept. \ No newline at end of file diff --git a/tests/data/plainfileparser/required_concepts_l2.plain b/tests/data/plainfileparser/required_concepts_l2.plain deleted file mode 100644 index 3ea46b41..00000000 --- a/tests/data/plainfileparser/required_concepts_l2.plain +++ /dev/null @@ -1,5 +0,0 @@ ---- -required_concepts: - - :Concept1: needs to be defined in the module that imports this one. - - :Concept2: needs to be defined in the module that imports this one. ---- \ No newline at end of file diff --git a/tests/data/plainfileparser/required_concepts_missing.plain b/tests/data/plainfileparser/required_concepts_missing.plain deleted file mode 100644 index 6ef43a74..00000000 --- a/tests/data/plainfileparser/required_concepts_missing.plain +++ /dev/null @@ -1,10 +0,0 @@ ---- -import: - - required_concepts ---- - -***implementation reqs*** -- The stuff must be implemented. - -***functional specs*** -- 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 deleted file mode 100644 index 1bbd136d..00000000 --- a/tests/data/plainfileparser/required_concepts_module.plain +++ /dev/null @@ -1,11 +0,0 @@ ---- -import: - - required_concepts - - required_concepts_defs ---- - -***implementation reqs*** -- The :Concept: concept must be implemented. - -***functional specs*** -- 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 deleted file mode 100644 index 938568a0..00000000 --- a/tests/data/plainfileparser/required_concepts_partial.plain +++ /dev/null @@ -1,13 +0,0 @@ ---- -import: - - required_concepts_l1 ---- - -***definitions*** -- :Concept2: is also a concept. - -***implementation reqs*** -- These are nested required concepts. - -***functional specs*** -- 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 deleted file mode 100644 index 5122c34d..00000000 --- a/tests/data/plainfileparser/required_concepts_partial_duplicate.plain +++ /dev/null @@ -1,14 +0,0 @@ ---- -import: - - required_concepts_l1 ---- - -***definitions*** -- :Concept1: is a concept. -- :Concept2: is also a concept. - -***implementation reqs*** -- These are nested required concepts. - -***functional specs*** -- 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 deleted file mode 100644 index c63b375c..00000000 --- a/tests/data/plainfileparser/requires_without_definitions_example.plain +++ /dev/null @@ -1,10 +0,0 @@ ---- -requires: - - exported_concepts ---- - -***implementation reqs*** -- The :Concept: concept must be reused. - -***functional specs*** -- Implement a second wrapper for the :Concept: concept. diff --git a/tests/data/plainfileparser/topological_sort.plain b/tests/data/plainfileparser/topological_sort.plain deleted file mode 100644 index 5fb59723..00000000 --- a/tests/data/plainfileparser/topological_sort.plain +++ /dev/null @@ -1,14 +0,0 @@ -***definitions*** -- :Concept3: is a concept that depends on both the :Concept1: and :Concept2: concepts. -- :Concept2: is a concept that depends on the :Concept1: concept. -- :Concept1: is a concept. - -***implementation reqs*** -- The :Concept1: concept must be implemented. -- The :Concept2: concept must be implemented. -- 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 diff --git a/tests/data/plainfileparser/topological_sort_not_referenced.plain b/tests/data/plainfileparser/topological_sort_not_referenced.plain deleted file mode 100644 index 76555224..00000000 --- a/tests/data/plainfileparser/topological_sort_not_referenced.plain +++ /dev/null @@ -1,22 +0,0 @@ -***definitions*** -- :Concept3: is a concept that depends on both the :Concept1: and :Concept2: concepts. -- :Concept2: is a concept that depends on the :Concept1: concept. -- :Concept1: is a concept. -- :NonReferencedConcept: is a concept. -- :Concept4: is a concept that depends on the :Concept3: concept. -- :Concept8: is a concept that depends on the :Concept1: concept. -- :Concept5: is a concept that depends on the :Concept1: concept. -- :Concept6: is a concept that depends on the :Concept1: and :Concept4: concepts. -- :Concept7: is a concept. -- :Concept9: is a concept that depends on the :Concept8:. - -***implementation reqs*** -- The :Concept1: concept must be implemented. -- The :Concept2: concept must be implemented. -- 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 diff --git a/tests/data/requires/circular_requires_main.plain b/tests/data/requires/circular_requires_main.plain deleted file mode 100644 index d0a04d01..00000000 --- a/tests/data/requires/circular_requires_main.plain +++ /dev/null @@ -1,18 +0,0 @@ ---- -requires: - - circular_requires_sub ---- - -***definitions*** - -- :App: is a console application. - -- :MainExecutableFile: is the main executable code file of :App:. - -***implementation reqs*** - -- :MainExecutableFile: of :App: should be called "hello_world.py". - -***functional specs*** - -- Display "hello, world" diff --git a/tests/data/requires/circular_requires_sub.plain b/tests/data/requires/circular_requires_sub.plain deleted file mode 100644 index ab921a40..00000000 --- a/tests/data/requires/circular_requires_sub.plain +++ /dev/null @@ -1,12 +0,0 @@ ---- -requires: - - circular_requires_main ---- - -***definitions*** - -- :App: is a web application. - -***functional specs*** - -- Implement the entry point for :App:. diff --git a/tests/data/requires/diamond_requires_1.plain b/tests/data/requires/diamond_requires_1.plain deleted file mode 100644 index 57be9903..00000000 --- a/tests/data/requires/diamond_requires_1.plain +++ /dev/null @@ -1,12 +0,0 @@ ---- -requires: - - diamond_requires_common ---- - -***definitions*** - -- :App: is a web application. - -***functional specs*** - -- Implement the entry point for :App:. diff --git a/tests/data/requires/diamond_requires_2.plain b/tests/data/requires/diamond_requires_2.plain deleted file mode 100644 index 8bb8c751..00000000 --- a/tests/data/requires/diamond_requires_2.plain +++ /dev/null @@ -1,12 +0,0 @@ ---- -requires: - - diamond_requires_common ---- - -***definitions*** - -- :User: is the end-user of the application. - -***functional specs*** - -- :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 deleted file mode 100644 index bd06f034..00000000 --- a/tests/data/requires/diamond_requires_common.plain +++ /dev/null @@ -1,7 +0,0 @@ -***implementation reqs*** - -- Base implementation requirement. - -***functional specs*** - -- 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 deleted file mode 100644 index c1176712..00000000 --- a/tests/data/requires/diamond_requires_main.plain +++ /dev/null @@ -1,19 +0,0 @@ ---- -requires: - - diamond_requires_1 - - diamond_requires_2 ---- - -***definitions*** - -- :App: is a console application. - -- :MainExecutableFile: is the main executable code file of :App:. - -***implementation reqs*** - -- :MainExecutableFile: of :App: should be called "hello_world.py". - -***functional specs*** - -- Display "hello, world" diff --git a/tests/data/requires/independent_requires_1.plain b/tests/data/requires/independent_requires_1.plain deleted file mode 100644 index fa9f55d5..00000000 --- a/tests/data/requires/independent_requires_1.plain +++ /dev/null @@ -1,7 +0,0 @@ -***definitions*** - -- :App: is a web application. - -***functional specs*** - -- Implement the entry point for :App:. diff --git a/tests/data/requires/independent_requires_2.plain b/tests/data/requires/independent_requires_2.plain deleted file mode 100644 index 5b2954f5..00000000 --- a/tests/data/requires/independent_requires_2.plain +++ /dev/null @@ -1,7 +0,0 @@ -***definitions*** - -- :User: is the end-user of the application. - -***functional specs*** - -- :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 deleted file mode 100644 index a9a0c301..00000000 --- a/tests/data/requires/independent_requires_main.plain +++ /dev/null @@ -1,19 +0,0 @@ ---- -requires: - - independent_requires_1 - - independent_requires_2 ---- - -***definitions*** - -- :App: is a console application. - -- :MainExecutableFile: is the main executable code file of :App:. - -***implementation reqs*** - -- :MainExecutableFile: of :App: should be called "hello_world.py". - -***functional specs*** - -- Display "hello, world" diff --git a/tests/data/requires/non_existent_require.plain b/tests/data/requires/non_existent_require.plain deleted file mode 100644 index f3f0bef9..00000000 --- a/tests/data/requires/non_existent_require.plain +++ /dev/null @@ -1,18 +0,0 @@ ---- -requires: - - require_that_does_not_exist ---- - -***definitions*** - -- :App: is a console application. - -- :MainExecutableFile: is the main executable code file of :App:. - -***implementation reqs*** - -- :MainExecutableFile: of :App: should be called "hello_world.py". - -***functional specs*** - -- 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 deleted file mode 100644 index ad4c1f39..00000000 --- a/tests/data/requires/normal_requires_1.plain +++ /dev/null @@ -1,12 +0,0 @@ ---- -requires: - - normal_requires_common ---- - -***definitions*** - -- :App: is a web application. - -***functional specs*** - -- Implement the entry point for :App:. diff --git a/tests/data/requires/normal_requires_2.plain b/tests/data/requires/normal_requires_2.plain deleted file mode 100644 index 45bd136d..00000000 --- a/tests/data/requires/normal_requires_2.plain +++ /dev/null @@ -1,12 +0,0 @@ ---- -requires: - - normal_requires_1 ---- - -***definitions*** - -- :User: is the end-user of the application. - -***functional specs*** - -- :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 deleted file mode 100644 index bd06f034..00000000 --- a/tests/data/requires/normal_requires_common.plain +++ /dev/null @@ -1,7 +0,0 @@ -***implementation reqs*** - -- Base implementation requirement. - -***functional specs*** - -- 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 deleted file mode 100644 index 1a75e2dc..00000000 --- a/tests/data/requires/normal_requires_main.plain +++ /dev/null @@ -1,19 +0,0 @@ ---- -requires: - - normal_requires_1 - - normal_requires_2 ---- - -***definitions*** - -- :App: is a console application. - -- :MainExecutableFile: is the main executable code file of :App:. - -***implementation reqs*** - -- :MainExecutableFile: of :App: should be called "hello_world.py". - -***functional specs*** - -- Display "hello, world" diff --git a/tests/data/sample_base64_image.txt b/tests/data/sample_base64_image.txt deleted file mode 100644 index aab011d9..00000000 --- a/tests/data/sample_base64_image.txt +++ /dev/null @@ -1 +0,0 @@ -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iUUUUCCiiigAooooAKWkpaBiUUUUCCiiigAozRRQAtFFFIYlFFFMQUUUUALSUUGgAooooAKKKKACjNFFABRRRQAUUUUAFFFFABRRRQAUooFLTGhKAKdigVSQnoJiinAZ6U9YXY/dp2C5BRRRWYBRRRQAUUUUAFFFFABRRRQAUUUUAFOYgsSFCg9h2ptFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUGgBaSlpKACiiigAooooAKKKKADNFFFAC0lLSUAFFFFAC0lFFABRRRQAUUUUAHSil60lABRRRQAUUUUAFFFFABRRRQAUUUtACUUUUALS0lLTQwpasWto9zIFUda6C08KOwVpJMZ7VdiHJI5sROwyBTltZWfaqc128OhWsC8pk+pqSOC2gOUiFMjnOdtdAlaMPINmO1W49OitxjJz9a15rgMuAAMVnO7GgOZnGUUUVkahRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFLRQAlFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRTgxAYDHIweKbQAUtJRQAtApKeqljgCrWoABU9vA08gUCrthpM14DhTxXRadoAtmEknOO1NImcrEmjaYsSK7dcdK3fMSJelNykSYBqpNMDwKowepJcTqVIAqhLKKHOSahdcA4zSKiRu5Aqoz81LIwGe9VWIz2pF2OXooorM0CiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigBaKKKBiUUUUCCiiigAooooAKKKKACiiigAooooAWkpaSgAooooAKKKKAFopKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKWkp6jJ6U0AqRO5+VSa2NP0iaQhynHvV3RbHJDPjbXVRLCi4UACtLaGcp9Crp8bQReXtHT0qy0rYp0rRqBgiqcswPQ1Rna4Syk96rsxJpjOd1I0gjXOeaLjUSQnCkmqNxdAcCmT3RY8HiqLMSxNRc0URzTlmpobmmYPeilcpIwaKKKgYUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFAC1ZtoDM4GO+Paoooy7YrfsLZUUH1q4omTNWzjNvEqk8gVcM4H8WKg+6uKrshOTWjZjbUsTXQ7HNVjcsxxmonOD1pm4oM0irFgylRzVSS5JzzUck/HWq+7PvUtlxRI7lm602gAk0/YR2qW7GyQynttVaGWoqkDBooooICiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAoopaACkpaSgAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAFopKKACiiigAooooAKKKKACiiigAooooAKKKKACiilxQAlFLSgc0AX9N3NIAOgroYl+UYrLsINi5Yc1qg4FbLYzkW1KqoLPVaefsvAoY/L1qpKQSKGyRrSZOSahmlzhc0shABxVbkuDUtmqih2ctUiRZpsa9zUyKBzUtlLQdsopaa1QyhjMDTM0HrSUmyjBoooqjIKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAFIwSD2pKKKACiiigAooooAKKKKACiiigAooooAWkpaSgAooooAKKKKACiiigBaKSlNAxKKKKBBRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFAoAdVi0TdJVcda09PjHDVSQNmpDhEwKm8zgAVAp5wKk2YHIqzPcbJIRUBcnrUh+dsCopOOO9S2VGJE/PFKi5NOSMu1WFjCjHeoNER44pzH0NOOKiZvShalIUvTWkzTWOaZSZQbqGpuz6UNSJMSiiiqMwooooAKKKKACiiigAooooAKKKKACiiigAooooAKXJxikooAKKKKACiiigAooooAKKKKACiilOM8EkepFACUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUALRRRSGDAA8HI9aSiimIWiiigYlFFFAgopaKAEoxS0mKACiiigAooooAKKKKACiiigAFLRRQMVRk1q23yooFUbaPfJWvBEBWiRMi1Ap4Jp8j8YFCjHFRyYDVTZKG/d+Y1XPzyD3NPmkJwBTUNZt3NIpFlBsUUhkIqIyYFRtIWqS7D2k5PFR78mmAHnNIBkGpEOpN1JRQUDPTN1OZaRVoEY9FFFUZhRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFLRQAlFFFABRRRQAtFFFIYlFFFMQUUUUAFFFFABRRRQAUUtJQAtFFFIYlFFFMQUUUUAFFFFABRRRQAUUUUABopTSUAFOUbjgdabUsOPMBNNAa1rAkcO5hk4q1GeaptcKVwrVLFKWqyGXgRUUjZPfikDECo3bmkxxIydzYqQKAM1Gh5zUoO4VDNSFiSaUrxT9nNGM0hjQuaUpxhRT1wBz1phkANJgMZMdaUqOtLuGM0jOCDigYxzjio8haVmzUeaLDsZdFFFUYhRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUALSd6WkNA2FFFFAgooooAKKKWgApKWjFAxKKdtOelL5bUBZjKKfsNO8vigLEVGKlEdKEHelcdiKkqYqBTdoouFiOjFSECkIouKwyil70UxBRRS0ihKULThQKYWEC5pwWlA4pcUASBverlseOTVEHpVy3z6U0yGXSxAqNiSadnIpvcUPY0QLTv4qZTqgZMWU96j3Y4plKX+XFAweTmoS2SKax5NJmjYCTcMe9RFsH2pGPzcVG7YXpzQA/dRTN4YUvFIZnUUUVZiFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAtFLSUirCUUtFMkSijFFABRRS4JoASinbSaNpoHZjaB1p/lmkK4oEJRRRSGAGacENPTpT+9JsdiIRmnCLHU0/mk60FWE8taPLWn4ptK4gpKWl20xhRRRTEJS0UUhiUU/FJjmlcNxpoxT8UKtMRGfpTSKm2Gk2+1AblcjminyjBplMBKUUU9FoEgop1JtagYoFKBmlEbGnbG9KLitoMHvV22YZxVcRE81LGpRhTAuikPJoR9yCkPHNNiQhOTThUY5NSDio3LFpp6c0MwAqKRvSmAkpAHFRq3FIxYnpSZCjmgLh1NI/INDSCoXc80hXBHKtips1VXrk0/f70x3K1FFFMzCiiigAooooAKKKKACilHJpKACiilAzQAlFP2UbQKVwG0YNP20BaLjsMIoxTiOaXGRRcBmKSnsuBTKYMKKKKBBRRRQAUUUUAFFFFABRRRQAUVZiiVlyanEKgfdoHYoYNKFY9BV7ywOiinCMY6CgLFHyX9Kctux61e2UFMUAU1tz1NOFuM81Z24FG0HtQMg8lfSjyR6VORzSbfamBE8fyHiqZGDWi2SprPcfOaQmCDc2KsiPA6VDDxIKtgUDQwpxTdtSkUmMVJRDimspxUxHpTD3p9CStRRiimBLGOM1IKZHUlQy0FJS0UhCUUuKXFMY3FLinbaNtMQ2nYp4jpwSgRHszRs4qXb1pQPWkBFtpQtS7RRtFA7ke0Ubafj8qMUARbfain0UCIZIi4GKjFuxPWrGKfincCsLb3qVYQBUoFKBRcdiPywB0pwAWnAUtAhtFO2+1JTuISnUjdKWmJEiNgYoZueKYM0HmkNADzUobAqEcGlL9qWtygdwDUbNnmonYc+tICXYKoyfTFMTFMp9KGdmxgVYFrgZlYIB2NRtcQxZEaZPqaYEBRscCom61JJdSP3x9Kh30NAKQRRuoJyaTBoER0UUUEhRRRQAUUUUAFFFFABRRRQAVIo4qOpFHFJjQtAHNKMCnVNyhm3JpMVIRRii4DaTFPx7UmPagQnVcVEeDVgLUMgw1NAxlFFKKokSilIpKACiiigAooooAKKKKAL0CZQHFS9qbbj92KloKCiiigQUUtFAEdOopaYCAUEU7GKDQwGEfKaz5BiQ1pEcGs6YYkNIYQnDirn4VUhH7xavAcUmNIb+FNxmn4pQtADCOKiZcg1aKgik8sUriM8qc9KTYc9DV8KN33acAvoKVyrFSKNu4qXy+OlTcdhThSAhEPtTvLzU2KTinqBGIsUu0U+jFAhm0elKB7U+kxQxiUgFPxSYpCEooooAKKKSmgCkp1FMY00h9KdikIoAbmlzSgUuKAG859qcAKKKAHUlLS7akkbRS0VQxlFOpKABSOKd2zTQMVJjK8UwIhTHJGafjBoYoi7jyfSgCJISfmdtq/rUpnjhXEChT3J61XeQycmo3fAFFxCyzFj15qsTk0p5pMVTBig07tSKuTSkYpAJSqu6jFSQ0DK9FFFBAUUUUAFFFFABRRRQAUUUUAFSL0qOpoxxSY0LTqbTqgoKKWigAopaWmIZjioW+9Vl8AVWbls00MaetOUEim1LGuVzVEjdtIRU+2k20AQ7aTbVjbRspjsViMUlWzGDxionhI6UhWIaKCMGnIhdsCgRctjmPFWMVFEnljFSUxi0tNpaLCuLS0UUhjTRmlxRimAUUtJQAves+5H7yr/SqV2MSCkBFF/rBWkBwKzEOGBFaacoKTGLtpKWikUIabzUmKTHNIBm0ZzSDOakC0YoQCUtFFAXCilopCA0CigVQxaKKKAEoopRQITFBAzS5pp60gCiiimMSiloqQCkopaBCYoxTjSGqGNoxTqSgBaXFJ+FKKRIUlLSUxhSUtFACGpohkEVERS+cIV5700A2b5BVQndyaszTrt45JqqOetNiGt0qJiWNSsRjikVfWkMjApe/Sn7eaAmaAGc0oXjnrU/lcZ7VE2M8EUXAj6Vbtxz1quq7j7VdtcBwKY1Ey6KKKDMKKKKACiiigAooooAKKKKACp06VAKsKOKTKQClopagoWlpKKqwrCUtLTqkBh6VAw5qwelQv1qkwIjU0I+WoT1qeLGyqJJdtNxT6WnsAynU2nUFCil25FLjNKKRJnv981Jbf6ymSDDmn23+toDqXKfUdOqiR3enCiloGFJTqSkWFJS0tBIUUtJSEJ3qpdjocVbxVe7HyKaGUUa0oDmMVm1pQDMfSpY0Pp44pu004A0hifhRigU4c0wGke1Lingcmlx1qRkRGKcBUhFAFAhm2jbT8UDFOwEODSqDUnFJinYLDKXPvRtpaQCZpKXFJQAlJTqSnsAUUUVIhKKWkoAMUYpcUUAIWoNLRVDCilpcUhDaKWikAlFLijFUAlFLTT0ouKwGoLhWKjHWrBNMdAymmgKKinupxxSgAPg1IOelA0VwMU8ZzmpBbljxT/sxApXGQgE1Iu1OWpXiYHApvlED5utFwGyyswwowKiCEmp/KBHFLjAp3GRhQo5pYn+emyN2p0YwuaSHYp0lLRVGIlFLigUAJS0tFAwoopaAG4pKeeRTKBCjrVgABarjrVgDikykOoopagodRRRTsIWiiikAEcVXYc1YqGQc0IZA3WrMI/d1WbrVmH7grREPclpaSloAbTgOKXFJTHcKKKWgRRmGJDSwf6wUTjEhogOJRSQdS9TqbT6ZQU7vSiimSwooopALTqbRQAlLRS4OKAegYqreD92Ktnt61WuVJjBpDRQUZNaFv8AcqoBirkJG3FSxkgOKkCg1GxoD4FIok8vilUc0qyZGO1PwOopCGUtJRVAOxiinUEZpAiKnYo2+1OKk0XAjNJmlNFMAppBFIQRTSxpWEOz7UqDNM3UpcYpDJAFI96bgHoKjDZ5pwamIKKDwaSkNi0UUUCFpKdRQIZigUtGKooKAKXFLuoQDadRRQAUUtGM0AIRTc8U4jNNxS0EJRRRTAiMLMDgU0IV4q1EwUHNQSHLUxpCg4p+/AqM9abu5pDH5pmCTTSTT1GetIB2zHaom71O3rVaRqB2IJD8xpVkwMVG3U0qA1QXIqKKKoyFpKdSUgFNIOtOopIBKWiimAnY0ynkcUygGKvWrI6VWXrVkdKTGiSiilqBiDpTqB0ooAKKKKBgRUEnU1OaglHJppAQN1q3F9wVUfrVyEfuxVE9R44ozQATShTTuIBRg0oB9KWi42Nwadg0YNOoFYz7kYlNJCSJVp1z/rKbDjzV+tCDqXh1p460UtMB1OWmjrS0x7hSU+l20hCYFJ+NPxSACi47hRRTgoYcnFITIjTJOVIqZ0xUUgypoGUQCWwKsxLtFMQBW96nDZqSgpU5IFIakjHU0hjvu800Nmkc/NSLjPNCESinjB71GeAaBn1oAlwM9acKgyQelO3UwJDtppam5pDQAuQaafrQRmmspPSkAuQe9NIAqNiVPNAl7EUxjjxScUpZaaVBHFSAEgUZ4phByaXPtTuIcDzS5NNDDPNPBFG4BmnBqZxS0bDJKKbTqQhuKdRRTsFgNFLRimIKKKKGAUUUUDFxxTCad2pMUmAzNLS4peaYiNsimE9c1MwyKiC4zQhjW9ajNOPSmmgYo5qRSBUQGKVmpgSFqruRzT88VHIcmkMiK808CmqvGaUGi4ivSilxRVGQULRRQAUtFHegYlLS0UANPSmVIelR96Q2OX71WBUEYy9WgKTBC0tFLQUKKMUtFSSIaUUUuKBiGq7jk1Zqu/3jQgZBJ1qzbf6sVVk61ct+YqsQ/ml5p1FO4BRjIpaUUDCiiigRQuv9ZTIjiQVNdj5lNQJ98fWgdjTFLSUuKYmLSikpRQCH07dSUUxDN1G6lopALS0lLupANc1Exp7daifjFIoiPBqRW4qI9acOKRRI7YFOVhjNQNJ2pu/ilYRK02WFP3DFVBUu7jrTAsKpanYKH2qGNmBxmrHLClYBBID1p/y9jULRHGRURJWhCLGeaeFqv5gzTxN83NPqBIykjim5ZetHmg96C24c0FDWYEc9ahbGeKkYbjimmMigCPFL0pwUD72ajB5wKQh4IxSmoWbBpVf3pAh1O+amUb/egCUN608VFupQ1O4EgqRTUIapFcHvRcCSkopaQgoooFUAtJRSUBcUUUUUD6BRRRQAlFFFABTHp9JQBWam09z2qM8E0DFooooENLVE3LGpG5qM96LjFAwtMH3ulStwtQh/m6UwGYpKKWmZsMUUClxQAUtFFACUUUtACHpUXepiOKhoBj4xl6tAVVi+/VpRxUsaHCnU0CnjpUjCiiikMWkp9NpgFQSfeqwQTUEnWhAVX+8au23MYqnJ1q7b/wCqFaEkuOKAKKWmMQLS4p1IelIQlFLijFMRTvOq1XT74+tWbwfdqsg+cfWkUaS/dH0p4pqcoM+lP49aYmFKBSUoNAC0tJS0DA0U403OOKCQptFFIYrdKhkNTHpULdOtTcqxAOtI/ApxFIRnrQBFmkzilK5PFLs596AE3U9ZPUVGV2mlVcnjpTsDLKFTyKsRuOlUVIBwKsx0hItIQaikiBGaAcGnH7uKBlNkPakGR3qwRULrigQgOe9SZwKr7ypFSKwJxTC48yYpPNprYIzUZIBoAs5BpjKOophbbSbxSBDHGKbkinsaaRSKBSaeCO1RdKM4oAmzTs8VEGp4IoAcDUivioA9OBzQItq1PzmqqMelTo2aYD6WkBpQaWwgoooqgCilpMCkNiUU6ildCGUtFLTASkpaSgZWkU+lRsKszKcVVPBxTYXHgUN0FODcU1zUjI2OKYBk08jIpoFAAT8lV3PzGrJBCVWcYY0wYtGKMUtUZidqO1HajtTYC0UYNGDSAKKdRQAh6GoKnwcGoKQMkhOHFWwOKqRffq2OlJlIUClxigUtSMBSjrQvWnUDACjFFLmgQlQyjJOanqGTqaAKknWrdqP3VVZatWv+qFWJk+KUUUCmIWkNLRjNACUUUuKYFW7GEFVF4YVdvB+7FUR94UhmpFjy1p/FRxcxipdtMTAU4U3aadTGOoopdtSIKay0UbqkZHgc0MwB60O3NQO4zQ2Mmd8iq7PmkdsJ15pkZ65oAnRd3WkeMqvtSbyFBB71LJIGj60AQBQTTymWAFNWp8YGaBEcsWEzTI8AEGpy29MGqjDYcCmBIFANHmBGB7VGH4pjtwaECLxcEcHilViTwKowyc47VIrFG46UBclZsNSthk61XZjmnB+1SMikXFNVzUr4K1XOQaYWJlbIwaaxpA2DTWbJ60B0H7gRTdxBqPdil3ZpiTJFf1NS5BGM1VzTgxFKxVywVFRkGgSe9KGBNIBuaeG4oIBphytAh3finBsUwHmjNCAmVqlR6qZp6timMvIcipBVZH9amVqTEyWikoosAtFFFMAoo7UUwEooopAJSU6m0AKwytU3XBq4eBUMqg9qYiuh59KfIOMVGRhqlPIzSGivTqa1FADlqvJ96rK9Kgk+lAxMCjApaQ9RVmQCigCjFAxaSlooAKKDS9qlDGOeDUNWH+4ar0xMki+/VwVUh+9VwCokVHYWloXmnUgDtSiigCgYtFFGKBhjioZ6nHSoZQKBFSWrVqf3VVZelWrP/V/jVksnooNAqhjqSiigkWkpcUtIZVu1+SqiKd2cVfuhmGqIIB4pFGnEcIKeDUMP+rWpAaBDqUDPSgU4UwFCGnbMDpR5gBpHmFSFiJxUecd6a03HFR+ZmkOw+RhVVjls0sje9RZyDzQFiVznHPFIFH+FRgnvT8nFUBIDhdtKAcc1GG7ZqRGzSAXpTzISAKQjPNN20DFV8ZqKViacR1pNuV96LhYgB5pGbIpxXaTmmEd6LgNBwalD8VGRQKBEuaUHmo37UA55pDJuCKiZaUHFPIyM0AV6Whl20lMBD1opTSUyQzS5pKKAFpRTaUUDRIJTTyQwqGgGpsMdtpKfRtpiEpaSigCdG96mjcYwaqA4qQMeKVgNENmlFQRvuqZTmgB1FJTqoQlFFFIVxKWiigoSloopIVhKKKKYypKmO1MBPSrjL2qsy45FPcERMOaYeDUrA1G3akMd/DUJG41IORik2GgBi0UlLVGYlFFLTAKWkpaGJBRRRSGMk+5UFWJPuVXoESwffq7jiqdv9+rw9M1Mi1sKOKKWioGKOKKdRTAbS0tFAABxUEo5qwKgm4oEU5atWf8Aq6qzVas/9V+NUguWKKKWrAcAMUh606ikSMxS0uKSgRXuhmGqIODWhdD91VAH5qRoaNsfkxUmKgh4jFSmQDvQIfuxS76rNLTRJgVJRYeYKtVmmHrVeSXPeoi59aLCLTSD1qIy81CWNNzTsFyQyZppNNpRTsK4oY04Stmm4p6L83NADwS1SKjdcU5FFWVIxSGRqjGlKEc1YA+WlKgg0XKKeKbjBqd0w1MZeaQ7EDpkEgVEatY4xULpimgIXHSmjNTYz1phTmlcVhvXikxinYxSsOM0wsNFSK2OKiNLzigQ+RepqI1KrA8U115oAZRRR2oAQ0UUUyQooooAXrSimil60hj160uaRTxSjkUMBtFJRQA/OaUGmA0oosGhZjY8VbibiqAarET9qNwLo5pQKjRs1IKAEopaSmIXBpMUZpaQCGilooQDaWiiiwBUcg4zUlJtyKLgU269KiIAq1OnUiqxplCUuaDSUARYopcUYpmYlJS0UwDtRSdqdQCFooopDGyfcNVqtSfcNVaBMntvv1eUGqNsMvV9RxUspCilpAMUuOakQtLSUtBQUUUUALioLjrVnFQT8EU7CRSk+6ansvu1FKOKmsfumqQ2WqdRRVkCdqO1HajbxSYC0UtFILEM/wDqzVFRzWhPzGaogYOaktFlThetRNKT3pMkimtgCgYjvxUZbikY4zUZoAQ0lFFMzYlFFFMQtKOlJ2pRSZSFqxFGSeajVTn3q5BCTjIpMtAkfNWo481JHBjk1ZSMDtRcdiHy8dqUIMVIRnoKTy2YnjFICm64JqFhjmrckZxUDLxg0my7EBBxTSOKe3Wm55pCImXmmnoc1MRUbd6BEfWkIFOpDTAYQKaKk60wimhCGnK2Bim0lMWw5sdqOopKVaAG0macRg02mSxKWkooELTiMY6cjPWm0UDFzTg2KZRmgdyTg00ikzS5zSAKKKWgBwapUbBFQU5WwRRcDRjarAPFZ8b8iriN0FAMlooooAKKKKBBSUtFMApKXBowaQxKSnUlMVxrDj3qoVOavHpRHb723fw0JCuZpGKSrdxEA/y8Cq5X2qrFXIKKKKRmJS9qMUuKBoTpS0UtA7iYopaAKLhqNkz5ZqrVqT/V1VoEye2/1laI4qhaffq+OlTIaHdhRQKWpAbS0oFKOlAwxmlAxSjiihAOxxVe47GrFQXHQUAijL92prH7pqOUfLT7JiAw7CrQi73opKXFUMWilpKBIKKWm0hEbgGqpGBVl35quzcVFzUYzY4qNnpXPU1ATmmJisxNNopKZLYZopKKZNxc0lFLjNAhacq5NIBmrMERLCpbNEiWCIkitKKHFNghHFXVUAUmWgEW4DAp6wgDmgSAUF2bgUhjyqr0Gagkc81IwbHNV5GoAhlYmoCeKlcgmompDIXHHvUR61OahagQ2o6kpjUAMpDS0HpQNjT6UjU5h1phqiRp60lLTTTJYuaUGm0opiQ4nmmGlzRSB6jTSU4ikxVENCUtJRQIWiiigYtKDTaO9IdyTg0mMGkBpRSKFpcUlKh5oAerYq5A+cGqfFSxMARQBpq3FOzxUSnipBzQFhOKdxRRRYkKKWkpoYUUUUAIVJOaMGnUUyRAD09TW2losWm89SM1m2kJe4VR6g1uXJ2xEY4rRIhs5V6gerlzCUG/vVJiW+XFFirlOiiioQC0tJS0wDFJg06ipKExRiiloERy/cNVqtTcJVWgRas/v1fWqFmPmrQAoZaFGaXFCginVNhCYpcClxS4osAlLijFLRYQAZqvcDgVcAqtcnK0hooScCls/vtQ4+U0lmcSMKtAzQxS0UUxBRRRQUBoJwKWmP0qWNEEhyagfoakY1BI3FQhkTn8qjJpSaaatEyYlFFFMgKKKXFAAKUDNAqRBk0my4ofHHmtC3hxjioIUGRWjEMCpZaRNGuAOKkzikRc1KEFIoYASelOAP0pwkUA4oLluimmgAxnHNRNDwTiplkdcjbTHmkwfk4pgU3j5OKhcYFWHdhwRVeRs4qQK7HFRv1qZuahK4pFCZpjUpHJpobmgQ2ilpKYWGmmsKcabQiWMpKWiqExKWjFFAhKSlpKYgpKWkoJYUUUUxBmkoooEHalpKKAHUUlKDzSKHUU3vTqRRJilzTQaXNBJpW7blxVhOorOt32sKvKwNMolo7UUUMkKKWigYUtLSUxCYpACKd2o5q0tCbmtpUOXLkcCrV0CeRzinafEVtc+tFwrMT71aRjfUy7mINDjvWZGgR+RW3LHxg9azryIg7sU7GkWc/QKKKxsWLRRRTAWjvTuKMUAJRS4oxSAjkGENVKuTcITVTHNIGW7Mc1oDtVCzrRUcUmUgFKM5pQKeB780CuNHIxil204CnBTTJuN2UBcVIFPYU/yWo5RXItnFQXKnZWkIDjpTZLNpVwOKLFXOfccUtmMyNW2ujxkYcmpYdLt42JANOwXM4DjJphmQfxCtmW2RY2IHauUmJDsAeM0NjWpce7iT3NIt9GepxWaaTODSuM2VnjY/epHYHpWXGx3jFXs8LUMpDHPWq0hqaU1Wbk0kMYaSlpDVmTEooopiCiiloAcKniXNQquTV2KPvUtmqLEKVfiHFQwR5A4q3HtUDJqSydEJHAq1Fa55bpVM3IUYWoJNRYcbjTRJtRrBF2GaHaLBwBXP8A29u7U77eTxmn0GaTugbIqFp1qiZtw601396krQmkdW5AqrL04pdxNMYZWgZERUbCpiKYV5pDIMU0pUhFNxzTENxTD1qTFNI5pAMI4php5pjU0S9xlFFFUSJS0UUCA0hoNJTEwpKWkoJCiiigQlFLSUxBRQaKAF70DrRSUDH0opByKUUi0LThTKUGi4XJVOCKvwNlQMVng1Zt32kUEmkDgUuaZGcinimAtLTaWgm4pFGKdil21SQ2xAMmrFrD5kwGOKI4Cx5rRtIwsgC9a2jExlI0o49sQX2qN0yfare35agYdqdiLlORCVJIzWddoDwQOla8g4PNZ9yBu+Yc+1KxSZxnFL3qDzmp6SbnA9awNyULTgo9Ksx2bOuTU6WQPendhcoYFGBWkLJM8mpFsox2p2JMk05VLdAfyrXW2jzwo/KpRGoxhR+VILmI9s7IflP5VCNNuSeEroggA4p4Ap2Hcx7WwljXLAZpLuZ7dtoHOK2sADpxWHqg/wBIP0qWNalWS9kJ61e0vdKTuOayD1rZ0TnefpREb2NVIVJ6VKI1B6Uijin1Rm2LtHoKKKWmIUClpBTsUgD8adj0pKdTAjmXMbfQ1xk4/eNnrk12sv8AqyK4y6GJ3/3jWcjWJWNMqTFMPWkgkSwLmSrZODVW3Hz1ZcgCk9ylsRyHNV2NPduahJoSHJgaSiiqMgooooAXNKBSU4DmkWtSeFQSM1owR8DAqjbrzWvAoUc1LLRMG2LionJbNPdsimdDmkA3LdzxUEqkjipiwxUTMKEMqkHPNL06GpWwSaYV5qrgAlI6mnebnHNMZeKizipGWlkzTy24A1U38cVYU5xQO+hJSMuRQKU9KTKIXXFRMOKssM1Gy0ICDFRkVMUphGO1MCLFRsOamYUxhzQiSKjFOIoxTuJoZRTiOKbTJYUZopKAYUUUlBIlFFFMgSiiimIKKDRQAUUUUAOBp3amCnA0i09ApaSigCQGpY2xioAakXqKYGpA+QKsiqFu+MVeXnGKpIljwmacIycHFSRqF61J5g4AFaxhcylNIasGepp+1U6VFuJ71LEjSsFHU1ooJGcpiee3QVr6ZFuBkaoToshwxata2hEMQX0FUySRuBgVE/epmxmozz0qRoruM1QmUM3WtNhVOQfOakux5tT4f9av1qM1LCP3q/WuY3OhjHy/hUyjioo+APpUwqrEiY9acKBSgVQBThRSigQUtFFIBDjGKxNUGLg/Stw9KxNVGJwfUVDLiZjD5q2NE/5aD6VkOea2NEP+spxHI26Wo1qSrMgFOFApRQMKcBSDrThQAuOaAKD1pQKBCMMofpXG3QP2mX/eNdmRwa428OLqb2Y1nI1gViOKi71MR1qMKSelSW0TW45NSS9abEm0ZokxSYWK56mmGnNTKpESCiiimSFFFFACipEBLCmAVZhTmky4ouWsWWrRC7etQ2q7VBqdzheahmhG7YqNpAB702V6rOxpDuSGTNRM/NMAxQetMQGQimmU560p5qEgA0xFgPnrTWUGoM4qRWyaYAM96sQ8cUxQDUigCpGT0NSUfeoNBpJpGFS4pCtILEOKaR7VKVpuKAIWTiomXFWStRSc9KBEBFJipAuaa60wZGRTXAFPxTHpohjTSdqWkNMliUUppDTJaEopDRTICiiigAooooAKKKKAClBpKKBpj+tGKQUtIoVetPU1H1pw60xFuE4xWpCzEdM1lQ8sK6rSLeKW0yU+cGtIkT2II7aaXGFq2mkXDtg4rYhRUQKw/Grca7MGt7nO9yja6JCi/vBvNWYrCCF9ypzVsPnjPFHencViJwO1J5ZNOlcJyaSK5jfjcM0gGlKjZcVYkkjTqaptcBmwoNIYjcAmqTLmQ1cZ/lNVSeaLjPNelTWy7p0FQmrFn/r1+tcyOk6FOn4VIAaYvAqQZNUSJS0U6mSLilxSCnCgYUtFLQIQisPVRi4+tbrVh6p/rx9KhlxMp/vVsaH96T8Kx361s6H96T3AoiXI26cOlIOlOHSrMBacBxSAU4UDDFOWkFKtCAWiigUxiN901x98MX84/wBquzP3a5DU+NQl9zWcjSBSNIE5p2MmnhazLHZwKilI5qw64XNU5Dk0lqxsjJpKKKsybCkoopiCiiigB6DJq9bx5YVVhXJrXtYuBmobNo7FmNdi0yVzipJDiqsjZNSMiYkmmheMmlAJNSEYFVYGQkA01lp9IRQIhPSmNUuKaRRcERGmmpCabii4EqN0qZTkCq6HJqVWwKTGTjinqRUStk1KoFBaHZGKTcKXbxS7KQxpNQ1KeOaZQMSoZOoqVjgVXlOTQJjQccUjnimg8YoNMgKif71SUxuTTERmkNONNNUQxKKKKCRKKKKZIUUUUAAoNFFABRRRQAUUUUAKKWm0tBSYtOFNFLQBPG2DXRaNdmMDmubU1dspzFKPStIkS1R3yFZV3cVLHcAHY55rAtb4EBSTVqSbI68/WtUzFo2zIo5zUL3Yz8prLjkc/KWNTDgdaoksz3DScViXEssEhZWI+laDPUM8SyRUAUhqkspCEnNdJawiKBWP3iOc1zVr5drcb5FzitoaxCygZxQwuWpvm4FV/Jpg1O2XOWqJtXtvWlYDzg1ashmdfrVU9at2H/Hwv1rlR1HQrjFPHPQU0DNPUYNaIzAdKWilxQIWiiloGIKWjFLigBMViarxOPpW72rD1b/Xj6VEi4mU/wB+tjQ/vyfhWQ/3q2NEGXk/CnE0lszbFPpoFOAqjBjqWkpaAFAp1NFOFMQlOUc0gFOFIYrcrXKaqgF85A7V1ZAwa5vVU/0xj2xUSNIGXjAoTlqcaavDe1ZGo+5faKok5NTTvuOKgqkSxKSlpKozCiiigApQOaSrdlb+dIAeB60mVFXZPZwbiCRWtGgUCmxW4iAA5qVuBUNm6ViKQ9aqswJqZuR1qu6kEmkgHAKO1O4IxUBYgcVGJm6UyCY4ppxUDTMO1RGbnrVAWSRUbECoPO5pN4osFyVjg1Fmgyc03fSsBIj4NSh6rg08HmhjRYR+etWopAeDWdnFWYWyRSLRfBBWmt9aanApWOOcVIETdajJqQmmMPSmUQs2aic81Kagfg9aaIbE3UmaRj7Uw0xDyR60wmkoJ4p2JuBNMpTSU0RISiiimQFFFFABRRRQAUUppKACiiigAooooAKXNJRQA6gcGkpe9BRIp4qzbvtcGqgNTI3IxVok6C2KSKCwqdmZf9WePQ1mWk2BV9ZQ9WjJomS8wcN8pFXBeqyYHLVTjCMcMoxUyRxxHKitESyRXY/MetK0mTwaY8gpgNNEMgmBOcd6qNC/Wr7rmqsxKCmTcpkY6mmOo9aWRjmm7qkrQxT1q7pwzcr7VSPWr2nf8fArmR1nQIKeOlNXgU4dKshhinDikpRzQIKWiigApadgUmBQCCsLVv8AXj6Vu44rD1X/AF4+lZyLiZT1saJ0l/Csd62ND+/J+FXEuWxvDpTwBSKMinAU7mAUUUUAOHSnCkpelUMKKdTakQjtgGuc1IH7RmuhkHFc9qYInHFZyNoFA8Co3O1TipGPWqsh7VBoRk55ppNBpKpGcmFFFFMkKKKAKAFXrWrYDaazo03OBW7aw4XpUyNoItoOM1FKcnFTgYShYA55rM0KBFRuOKvTWjfw1TeNsdKYEBGBUL8HgVOwOOaiI3HkUxWKjMzVGavGJSOBTHhGadybFM0mTVgxDNNMWO9MlxZDzSjNSbKaUNMOViA1LjIpgjNTxripZSTI8VYhGCKaVFWIF5FItFgD8qa3SpsACoHakUMLe9NJGDTWNI3ANNCY1utQyEDtT5HwarMcmghsaTmkooqiAoopM0CCkpaSmSJRRRTJCiiigApaSloGhDRQaKBBRRRQAUUUUAFFFFABThTaUUwFqRTyKip6nkU0wNC3fnir8D5OBWRGTu4q7FI6AYHNaIlmoJMAA1IJcDrVKNmIyasKM1ojKRIrknPapQ1RpH3NPbaBVGch5we9QyBSCCRUbznGFNQ72JOTTEJJCOueKqsnNTnnvSbRSGjn+9X9M/4+BWfnmtHS+bgewrkR2G8vSndqRAMU6qM9wxS4opaYWAUtJTh0oGOopKWgSEPSsLVf9ePpW/jisLV8CcY9KmRcdzJccitfROJH+lZDj5hWtoo/ev8AQURLlsdAtOpq06qMR1LRRQIWnUlFADqZS02gYxzwec1hal/rQK32HBrD1cbZVOamRcdzIdsCqjHJNTyng1WNQaNiGiiimZhRRRQAU8CkApaTLSLlrGGetuBcCs/T4gADWtGBjpUNmsRQMCnoKUJntUirg0tCiYqDHnFUZoge1XCjHqeKa4UEgmmBkyQcniq7REHpWpKB2quwBoGZrKwNNOc88VedM1A0YNILFVgc0znFWjH7Unlg9qZPKU9pIzSiPmrJjXFITigdiDbzzS59KXGelTRQnOcUibCImSKtxxgDpSpCOKnCgChlkTVXcVO4xUTUhkGKa/Spic1BJ6CqJZVlPNR1IwyaZjIzTJY2m06m1RmwoNJS0EiUUtLigBtJT8U00A0JRRRTJClFJQKAQppKDRQAUUUUAFFFFABRRRQAUUUUAKKUdaSlB5qkBahGSK044+mazIThga1Eb5RWkSJFlVB61OGSMDbVPNP4xWqRiWTPkcVE8pJqINRn1qrEAabTqTNMLEdOpabtpDOe71p6SP3x+lZvetTSRmVvpXGjsextoeKf1pidKfVmYUUUtAhaXFJmnUCFpaKKRQdqwdX5nX6VvVhav/x8ge1KRcNzJk+9WtouPPf6VkOK1tG/17/QURKmdAKkHSmAfLUgqjEdS0lFMQtLSUtACUU6m0hg1c9qpzNmuhbpXPasf3g+lRI0juYkx5PNQZqSX71RVJUtwooopkhRRRQA8VJEm5ulMQc1bgUAikapGtaJhBV1RgVBbDCjiri8CsmaIaDg04tTD1ppbFBQ4s5ycU15T3zQJD07USAMnHWmOwwyKV6c1C7DtSNmmMD+NFwsRsRmoyeadSEUAMJ9qN2KR85FNCknFMQrHJpNpJqRY8U8JSuBGkeKmReaeseafsAGaQxBxS5oxzTtuRQBVZutMbpUrr7VGw4pgQseajIJFSNUY60xEMgxUYqw6jBqEDmggjdcc1GasuM9uKhK8mqRDREaKcVzSEUyGmJmnhqZSZosCZLu+lNJpuaM0DuBpKd1pMUyWhKKKKBBRS0lABRRRQAUUUUAFFFFABRRRQAUopKUU1uBOhIIrRgYlOv0rNXpV62bBxmtYkSLak5x2p6+tMzlc1IOtapmUhaKdSpGz/dUn6DNWZiA84o2FulaFvpU8xBICj3rYg0+G3x8m5vWhsZi2+lSTYL/ACr61pQ6RbquHDN75rQ24HAA9sUv40rjseUDrWrpP3z9Kyh1rX0n7x+lcaOp7GytPpFpaszHUUmeKUCgAFOFJjilFFhi0UUUAGeBWHrH+vX6VuGsPWT+/T/dqWVDcyXxWtohHnPxxisl+vvWponFw30FES5bHRU6mU+qMRRTqQdqcOtADgeKWmgU4A0wCkanChqQELcAmsLVxkjituZ+KxNUGEFTI0ic6/3jTac/3jTakbCiiigQUo5NJSqOaBolTrV63XLCqkS5JrStEy44qXsbJGpEvy1Y7GowMCnZ4qDRETdaYTT2xTKQ0PDjHvTRLj3qMgmgxk9KChzFSckVG7qOlL5LU/7IWPNMRVd9xwtNEbNzitH7MiHIpNgHFFxWKQg4yRS+WB0FWG4qGR8dKEKwzaAacAvpULSEU4SH1oGWwwApGPBqBXJNSoxPfigBo4YVZcApxUJHNMaTHelcBsnAqu5FSu2QahNMCNqhNTODUTdaaJYrdKiK1IOetLtFBJEAc0wqamIxTcUBYr7aa0ZAqcjAoK1VxNFUrSbanKEU3YaLk8hDiipCnHSmEU7kONhKXFKBTwtFxqJHikxU2zimlTSuNxIzSU5qbVENBRRRQIKKKKACiiigAooooAKUUlKKAJU6irMOdw9aqqatQH5hWiEzVtLdp2CAEk1u2+ioAHlYk+gqHTgixI4Az3rbHatU9DGW5Bb6RaqBuXcfersdtAhAWJRj2ojBB9qkVuMUybARTWwB0qQjimNQBEVx0puD609uvSmmgDygda2dI7msYda2dJyM1zI63sa9OptOqzEdkY6UAmkApcGmUO7UUdqKTJFpklxFE+1mANB+6aw7wNJeNgnIOKCoxubyOrruXoaxNX5nXjtWraL5cKqfSsvWDidR2xUyKjuY79a1NEP788VlvWnoxP2j8KIlyNHU52hhTYdpY8GnaTPLMGMhJAxioNYHEI9zVrTFC2iEDk5zVGdtDSFOFNHIFOAoIJBjtS0gpaYCUhHNPpjjjNAFeUk1jaqMYrYc81l6sv7sE1Ei4nMP96m0+T7xplSU9wooooEFSxpuNRVatSA5B70mVHcmiTC9K1bNeRxVJF5xWlZjjpUM3RZJpCc0GgUixMmk25NSAZpypzSBEQjFOCjHSnHpTM0IoXpSlqQmoy3FAEhao2amFqid+etAxXeq5fJoZs96gzlsUCY7ktxUqxsafBAW5NXFjAHSgRTSMmrCLgU4L6UoBNMRG3eoWNTutQMO1IaGHk1GRUpT0oMeVzTEyuw4qJhmpW64qI0xMKWmrT6BCMMjNQHpU3GMd6GTigRXWpKb91qetACMtMKD0qcDIpGWgCDZjtTWTJFTYpNtArERQUYqXFNxRcCOgDIpxWl6LQBWcYqOpXHNRVaMprUKKKKZAUUUUAFFFFABRRRQAUUUUAPWrMBwy1VFWIzgitAOm0yXaApro0bfGK4+yl+ZTXU2kgKAe1aJmMi8j9qkVsNxVZG2nmnmZQOSKogn3cUpORyapG7x0qB7p34zxQMvPKB3qq12AeKqmRiajNAHn461uaT0asMda3NI+6a50dL2NUA4p4OKYM44p4HPNUZMUClxim5x2p9UMWiiipENOawm+a/I/wButwn5TmsHd/xMPfdS6mkTdTG0fpWLq4/0gfStmPkAisXV/wDj5Az2qWOO5lP1rT0b/j4P0rMf71amjH/ST9KaKkW9YziLHrV7TR/okdUdZBEcbDsataRIJLZQOo61Rk9jTU81IPWox1p46+1BNhwpaQU7FMBKa1LRSArOP3lZWs8RjFax5asvWU/cg96mRcTmJOGNR1JL1qOpKluFFFFAgqWE4dT71FU0a5YfWkyo7mtCQUBrQgGFzWdAMLitGL7tSzdE9FJipFXNSUKtOpaaaQxhPNRscClYgGo2bNIocW4phPFNLVGzcUDB3qB24pWaoG5NMkUvUanMgp4HBpijD5phc142VEFNa5Cnk1VEvy1TnYsetAjX86P2pfPHauZLup+8fzq9a3ZPyuTmnYm5sGaq8pyc01W3AGkZsUkNMcDihn7ZqMv1qIvzQMa3eompxakHegkipVahlpKYE1FCU5qBkLrzTcEVMRySKiJoELTqbTqYDSKMU8ikxxSCxGRSY5px60lArDSM008inN0pjmgkhc8YqGp3PFQVUTOYUUUVRAUUUUAFFFFABRRRQAUUUUAKKmjbBqEdalQ4NWgNWylwwz1roYLnCDHWuWtW+YVuQPlARVozkarXLMuaZuLc1BE524qRW9aszJNxPelFMpDTELRSUUAcGOtbekAYJrFHWtvSehrnR1SNZcUopB3pwFUZNgAMU6m9sZpw4FADqKKKAGEZ7VzsxEN+xbjmukwcVRuNNWebeaLFKVi1Dgxgj0rE1c/6UfpW7HH5aBQOlYWrc3Y+lS0VHcynPzGtLRebk/Ss1x81XtMnjt7ktI+0HvQipM29Rt/Ot9qjntTdJtZYGbfwp7U86xZgffZvoDTDrcYOI7eZvotXYyZsAcdacKxhq143Edk2f9pgKT7bqcg+WCJP95s0ybG6DTSfesXdq0nWaJPoKBbag4+e8fGOy4osM2C6gckVFLPGBy6j8ayxpbHlriZvYvS/2ZEvJB49TSKLZvbZCS0qfgaydUvoZ49sTZNTTW8cSHCLWawBJ4qGVFIzZBzUfSrNwuDxVY9akpiUUUUEijk1ahHSqo61aiPzCkzSBpQcgVow8LWfCOBWjF92oNUPUYNTimDAp27FIYpqNjSl+DUTNSKEZqiJpXPFRMaQ7jWbNRsetK5qM5NNDEYnFAHNAGTUgTFMBm3HSk2c5FSKKUrzQSQspxULJk1c4I4FRlRmmBRkiFMWPac1eZKjZeCKdxWJreXcMGpGbnmqkfytUjv70rAK8nvUTNTC4qFpPSgLku/HWkEi54PNVmLNSopDA0WEWlpGoWloGORuasAZFVsEHip0b5aQDSOtRuPSpyRgio2HFMRFgigE4xTjmkFMB1BFGKWkAzbmkI4p5pvWkDGEYqJhipW61FIMUySGU9qipznJplWjGW4UUUUyQooooAKKKKACiiigAooooAKkWo6eDVIC1C2GFbdo+VwawYzk1rWchxVomRqxkCpwaq25z1qyDWiMWSKc0hOabvCjk1G0wAqkInDY600kVVaUk03eT3ppCbORHWt3SiqxEkiqo0nn/WH8qmTTdnAlk/4CcVzqNjpckzZEiqOSPrmmm5hU8yKPxFUU0xDyzOfqxp39nQ5/1YP1pmbZO2o2qnBmX8Dmmf2rb9iW+gp8dlEijEaj8Kk8hV6AU7D0K/8Aamfu2sx/4DS/bbhh8tqfxYVZUKPpTgFHSgRT86+fokan0JzRjUGx+9Rfov8AjV0FelBC596QFJbW6YnfdMR7DFNOkRu5Z3ZmPqav7sUu+gdykukW3dM1KunQJ0jXj1FT78CjzTg5pBuCW0SjhABUiwqB0FM+0LR5+aYWJBGoHQVINgHIFVg5c08LxRcWxLlAaTKYpuw0m2mBJvHYVE7+1PCYpu0HrUsaKN4SY+nFZDcVu3ajyG7VgOeahmiIJRlTVM1fkX5D9KonrUlsbRRRTIFHWrUQ5FVRVq1O5wKTLga8Aq9HVS3XiracHNQzZE2dq5ppkHrULNxTC3IpFj2OaazetIzYHNQl896QATzTS3NI3SmYoEITmkC804LmpFTBosAiqMdKdt5p4U4oIoKGAYoxzmnYoxxQBGc0w1MRUbUySNhmmsOKkxxSEUIZXpkmamIo2ZFUSUiCTzSbeateXSeWCaSAg20o+WrBjFMMXU0BYYGxT05PNCR4NPxgUwAqBT0bFNxkUoGKkolqOnLSUANIzRt96cRxRQwsMooopiG0UUUARseahkbFStxVeU80IzZAetJS0laGLCiiigQUUUUAFFFFABRRRQAUUUUAFOFNpR1poCaM4NaVm2GHpWWhwauW7EMK0RMjdikwKl807c1Rhap81rYwY/efWkzTM0bqohsUtim7hTCeaM0AT5HpT+OwpNlKoFYG4gODSbjUgA9KQgCgEN3mjBz70pIHpTt6jqQKYhmG6c0/Yab9phU8uv501r+3UcyD8KTKsSFWA4zSBXPrUDarbL0Yn6Uw6zABwrH2xSCzLoQ9+tOWOs062g+7Gc+9RNrb5+WMUXCxtCPtR5Wetc++sXLdGAqFtRumGN9Fx2OmEKD0pMRLwWX865g3dwwwZWx9ajaaRursaVyrHVmeFOsij8aY2oWyj/Wr+dcqWJ6k/jSZ5pcwuU6dtWtFGMkn2FQnW4QeEJrn/fNITkUcw1E3X11cfLH+dQtrcjnhAKyM0BqXMVY0JL+WZCG6Gq+c81EjjvShhUsaRI4yprPcYJq//DVOYYakimQ0UUVRmFWrI/v1FVas2R/0haCovU34RgVMKii+6OamHUVmzdDSKYy4FTN0qF84pFEMjEdKiXLGpTyeTxSlQBSGJiko3UUwFxTwKatPHWkhD1GO1DU7oKaRmnYLjcYoPNDGkBpDGmo2Henmo29KBCUUtNNNAMIpVHFL3pQM0DI8VHU5HNRHrTQhtFLSUAFLRuooAbTloopAh4PNOzTKKBjqZS0UAMNIeKcaYTQTcbk01jwTSmmkcUE3EJxVVzViQ8daqtVImTGUUUVZiFFFFABRRRQAUUUUAFFFFABRRRQAUCiigCRTVyA9KojtVyGtUTI0oT0qwaqQnirO6tFsYNC596C3FM3ZppcetXcljicmlzUTPg8mmGYdjQIDrCY+5Uf9sOBwn51l+XTghrluddkXzq8zdgKjbUbhhy9Uz8tJnjFLmHYsG8nbrI351GZnbq5/Oo80ZpJsY4O3c0pPvTN1ICc0XAkzjrS59KiJyaXJouIlDUhOaioyaLgyQt0pu6mg0UXESGSk3mo6Mmgqw/zDRvIptJilcdkO3n1o3n1pmKWgB2T60AmkoFIY4fWnjNMFOB96BWLCtkVDMgxkUoPNPYZXFBZSNJTiMGm1Rmwqa2OLhPrUNSQnEqn3oBbnSxDK1OgOagiOUFWUHWs+pugYVC4zUxqNxikyyviopW2rVgrxxVacHpQMWM5608kA1AjYHWmvLigRMZOatRYKj1rFaYhuKnivGU89KEBrHpTCapfbCw6003JPenYRcYmmbxVQzk96b5pPekNFwtTN2TVYyHHWm+aRQMs5ppNRLJmlHJzQDHjrTg3NRjrTqBDiaiJwaUsCOKaaaASiiigQUUUUALSUtJQMdmlHNMzSg0hjqQ0H603PSgVxSajbpTyaiNMQhOaSimsflNBJHKetQGnu3WozVIiQ2iiiqMgooooAKKKKACiiigAooooAKKKKACiiigBRVyHOKqL94VbjOBWkSZF+I5A4qXcBzVRJNq+9NaQnvWqZiy006qaikn3VWLU0mmFiUyH1pmaZmlz70XCxX+b1NJk0uCRzSMuBXK2dI2jNBzSUriDNLSUUDCiiigoKKSigkdSUlFAxaWkpaBi0UlFIBaKKKAYlFLijFAhO9LTtvPtTtgoGMp4U0bBSgUXGFSoc1H5ZqSNcdaBkE64aoKuSpkVVPWhEyXUbSqcGikFUSjo7F90I+lXk6GsbS5cjYTWyn3amSNosU0j9KdikccVBaISKrTDIq2w4qpN1NIspliOKjbLZFPI5qRIyaZBEkO45qRrcFTgVYCYpwQGhILGYVKmgc1fkhDDIFU5IimaY7Dc0u6kprISaBoeSKYWFMYECmbmpibJsjrmneYRjmoA1KWBpDuTmY+tNM5J61CTzRTsSWFlz1p4YVUDU4PilYLlnIozUasGp2aAFzThTKXNIQ4UZpAaKAQpPek3Cmk8UzdQUTlqaWqPdRupiuOLU3NNyaQk0WEx7HiombmlJ5FRuaCSNyO1RmnmmHrVozkJRRRTICiiigAooooAKKKKACiiigAooooAKKKKAHLwasK3FVgamQ9q0iJkwbigtk0wHFGaozaF3UZpM0VQBmjNJRTAU01hTzRjiuQ2ZAwNNxU5ANMKjrQBHRSlcUYOKYDaKfsNKI6BjcZpQtPK7RxUe7mkMUrikPWgsxpKYkLSY96KSgB7YB+XpTeKMHNLgmgAoFOEbf3TSiNielAxtGcVMLdiKBbE0gI91G41MLY077PQMr7jTgx71N5OORThGKLANGMUoNPKUzbikMRuVNVZBzVs9KruM0LcGtCGkpTSVZkW7GTy51966aMgqMVyMZw6n3rqLRw8KH2pM1gy0aD0pGNITWZqMfpmqUpyDVuU8GqUxpFJlcLk1ZjQelQoKuRiixIzFOQdqey8ZoUDNACgCopYd/QVMMU/A9aYGVJCVPSosYzWrKoI5qnJGBnFMoqE4qNhmpjHTGUihDICuBSFTUp5pTigTK/Q0mcVMajZaZLG5o3c0hWkNBLdh4bBqZZMiquaUGiwcxcyO1LmoY5O1PzxSAkzSZGabmigYE0ynGkNIQ3d70Uuym0xC0UUtADc4FQs2TUsrc4qGmhMDTD1qQg4qM9apEyEooopmYUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAtPQ4pq9akClSMjrVoB1FJSVZmPzRSDNSrEzYwDTuBGKcFY1aS1JwTVmOBAKLiMpZA3FO61UqeF88GsDZElNPNS4FIVqbjIHXAzUYc1abkVEIxnpRcQzcx6CnAOexqdFUdqdgelAyDym60eSTVnGRSYpalFbyeOtAgz3qfGaeAcU9RFYQkHmpUiXuKft/OgZBoAQRoO1O2KO1OpcUDGbR6U4ADsKXFGKAEp1Np1AC0lLS0ANIzQBS4o4oAYwxTD0qRhzUbChjGtUTHipWOKhfml1GQnrTaeaZVIzkL3rf0qXdAFJ6VgVq6VLhipNMcNzbBoLVCGpd1ZM3WwOSR0qlKTmrjtxVNz81IYka96sR9ajiHy1Ki4OaYx7HikUnFMkPU05DlaQiQHNLuwaj3HNIeaoB7sCKrsBjink81GzZGKAK7CmEA1KRUR4oHsRkAUw09+aYRQFxCKaVqTFNI4oEQkUhFTOKaF6UCZEVo21MEoC80XFYg5BqZGyuO9KVFKFxzQ2CH5pBQOaeBxmgkjNAHNOAzRikMRqbT2pKYCdqa7YHWnE4qCRs0CYwnJpVXimZ5pw6VRNxGNMPJpzNTKpESYUUUUEhRRRQAUUUUAFFFFABRRRQAUUUUAFFFSxIWYelMCS3h3HNTSwbfmHSp4Y8jpVoxhoyCKaYGRtJ6VPHbFwCTinhAhINTRsMDHSrJYJAoHSpQAvAozS96EK48csM06mA808cigDndtAyrCpgBTHX86xZZNGdyg06o7duCKmxRYojpFpzUi0hki0tC0UEg1JStRQPqOHSgCjtSgUMYUUU6gBAMd6WjFIBQAE4PWkL+lNdsVCXOTSKJg1KOagVuKsR89qYElFJRQIWkooqQBulRGpGOBUDNzQMjc81ETzT2NRGmgENIaU0h6VRDEqzZyeXOM9DVanKdrA0xJ6nSo2QD60m7mq9pN5kA/KpGas2dKeg9m4qsTufFSZ4p1ugLHNIZMibUpDxTjxwKR80xELc8ZqVeBUYGW6YqSkAj9aAaQ00HFMYrGomOKczVE54oAjJqM9TTzUZ60gGsaTNLRg02IbS0UUAFFFLQSFFFFABSUUtAABzTucEUoHFGKQCAe1Bp2KQkE0AMoopGagRFK1V2NPkfJqLvVpESkGaXecYptFURdoOtFFFAgooooAKKKKACiiigAooooAKKKWgApKWigYoWrVvFuIqGJd1aMCbR0oAnjUKo4xUg6UIvApcc0wuQSw8blFQhucVf25GO1VJYSjbh0qkyQp9RrTqokeDUgIqAGpAfegDI70yn96KyZoJAcMRVmqsfDgVaxSGRuMCljpX6U1BxSGPHSlpAMCnUw6BSigClApAJTwKQDingUAGKSlxTHODTBjqKYtK1IZDJ0qLvUzDioyMVLGKtWUHBqqpwasxjNNASUUUlIQuQKaW5pCeaY7UDGySVCTTnOTUZzQA1jTCaVjTKpCbFzSUUmaZFwopaSmI0tOm/gNXic5rCjcxuGFa8cokjBqJI2hIcDg4qeNgBxVbdT0kxUmhazuIp7AYqMP6VJncKYxm32ptPpNlIQxjxUeakcY4qHvTGB6VGwp5NN20kBERmo8VOcU3FMCLFG0U7ZRtxQIbjFJinkZpMUhMYaKU0gpjCiiloJClX7wooHUUgJKKTNDHg0B0EzTCemKUnJpAeaB3AnFQTPxUsjYHFVHPNUiZMYTSUUVZgFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFLSU4UDQAZpQtAFTxx5IoHYsQRYQHFW4xxTMcVPGMChCHDgGnYzxSAU8DFMkShkDrS0VQFGRChING8Zq1Om5D61QddpIpgyTzMA+1Hm1CDzS5qkQJ9nNO+y8VV+1T/AORSGWdh1OKxNR6jEpFTYGOlVotytk1ZEqkUgYyQcUifdpz8iljTikNMAme1LjmpVTHanbM0BcjApwFKFwaftFMBnGKUCn7aUDFAEdRSHBqdgarSn5qChynik6jNMU8VKvKUhEDZ70xjxSuMMaYTQUIDhqtxucZzVHvxVmF8YFJiLWc0lM3jFNZ6Bjie1ROcUO2D1qNmJoAQnJphNBPFNyaLBYQkYphpxppqkSwpKWkpkBRRRQAVYt5ijjniq9KDg0Di7GvuyM0gfBqG3ffHg9RTmGKzN0y1HJViN+azY321YikyM5oLLu40bqiEmaQvSAdI3FQseKWR+tRMxNAIcT+dNyabnmlp2ADzTc4NLTaAEoptFAh1FFK1ADD0pppT0pKQgpQOabmjOKaEBOaAaZuFIGHNFgJCaTNMLZpRRYBwNLkAUzNNJosA2Z+Krk5pztk0yrSMZO4UUUUyQooooAKKKKACiiigAooooAKKKKACiiigAp/WmVInJoGhyrxVhGC4BqNBUiwFyGFAFuA7hVtRwMVXhTDYqwOBQSO9qcDTaVaeoBS0lLTAU881Ruk5z2q43AxUMo3xn1p3GZ1LQy7aSnuQNApMn04qUCnYHpWJsQlc9qQoR061LTgu45piI0jbbirMaACgJgipaBCbeOlLjilxyKMU7DE2jmkxTwOKNvNAhNtLt9qSlpFDTVa4HOassQKhmGVzSYFRW7VYjPFU3BV/apYpMYpDElI3VCcE1LL98+lQHg9KYxBUoqLNSKeKTFYlByOTQW6VGeKQkikMezcVEzUEmm5p2AUmkpDSUxCk02lpKZLYGkpaSmSFFFFABRRRQBYt3w+KvMNwzWYhwc1fjfIGahm0HoMIINPSTBpX5qHJBpGhdWWn7uKpK1ShzSAkZqZnNNY5pATmmBIKd2poNG4cUAISM0E01yCaaTQHQUnBFKO9R5ozSEiQEZpSRioi2KaXNADy1MLU0tSFu9MQpem7j3phPpTc807CJdxxRuNMXkU8LSGFLSUUCFJ4pjfdp3OM9qZKeMU0J7EB60UUVZiFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABUqGo6enNA0Tpya0IAMc1RiHNXIlOaBEy/6zNWAvFQxgiphTtcGIRilXrUhAIppUU0JiUUbqSmAjU3bS0tMkz7pcSCoN1XryPcuazi2DjmgTLAU0h5qRo8U4RmsjUYI8mplXApyoBTiOKBDP4qfikT5txxRTGLTtlVTeIrAVbUgqOeooC4g5pwHqKUCkpgBTvUe7AxTy3OBTSuRmpaAY/NMzkYNOY8VF0amNFa4TaSaiFXJF3Kap7cNioKRLt3jAqNrdh26VKgqVV4NIZR2UDGasvGcnjmoShFMQ3NIWpScCmEiiwxMmkzSZoqhXA0lKaSglhRRRQISiigUxBRilooGJRRRQIWrUDdqq1NCcP7UmXEtk8YqNhxTyKaag1Gjg1IDUR609TzQUSGkU4oooAeCKR24qPNIDkUAJuoDE0hGKb0oAd05oLUwmkzSE9x5NNzSA00k0xDzzTG6dabmimIWjbRS0AOBwKcTmmZpQcUgFBGKM0maBzQAo4GKimPzYqVjxVeQ5Y1SIlsNoooqjMKKKKACiiigAooooAKKKKACiiigAooooAKBRSigBBUyYAqIU9aBotW4y4rTiUAe9Zlp94VrRY29apEseuMU7+HrTQyDqaasyA/eqkguSj6UEn0pouIwBzQZ0x0NAhSpNMCMDQJgOimj7QW6KaAHbTQFORTDI5GQtIJJWP8I96QhzxFlIrImjKyEE4FamXxy1U7lCWycE1QycFO5AqM3Ea5+YVlNPI3VjTUV5GwASazLua32qMd6VryI/xVVXT22ZY81UlRon2tSEaiXCKD81Vbm63nC9Kphj70hOaZQE5at+3H7tfpXPVtWdwrRAN1FBJaFDNik3DGQaQfMQT1pAhmeeTTg2eDRIo6iofMINJjQrnBphGac/IqNTQUKOhFQSx85qYnHNLgMKljuVUk2nmpRccUx0GaaVBFIY8yg9aY7jHWoW/SmGmgEJptKQaSqJYUYoozQAhpKWkxTJFpKKKBBS0gpaBoKKKKQwooooELT1+8KYKevWky4ltclaU0kbZWnVJqRmkXrTyMCohQMnHSlxTVqRulAiE96YeKkNRGhDJAaVlJFMU4NShsjigCAqRTSKsEDFMI5pBYiINMPFSkUwgU0JoZTuKNtLimSJS0UUgF4ozTTTaYDlqSkWnUgGyN8pqtUsjcYqGriZzCiiimQFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFAC0UUCkMtQPhgK0AigZJrJjPNaYw0fNUhMnQRt3zTd0Yk2mkgRRn5uKcY49+aokkdkQVIrI8eQtRN5bLg09HVRjtTAbHL84BSkmkZWGFp+75sqppWZ3AOygBu5mjyFqON3J5HFTAuAQEpAsnpigkrukofg8U+aMletSGKQ+lBik7mgox4LJpG+b5RWnBbRxDgc1KBj0pOR0rMoUgYqldW3m8jrVtuM0gGaQzFeF05I4qGt+WIMpFY88Rjc8cUDaIKcrFehptFMkt2926yAMcithCGQEVzo6it+EYiA60AOYmq8g796t4zUEy7RxQUitv9aaTjpSkYOKbikCJFIdcd6eMiodoHOalDZXmpKsMkUHpUMgIFWQtRSJwTSAqE4pM058DNRGmMU4phNHWkqiWwpKWkoJYUUtJTEFFFFAC0ZoooGFLSUtIaCiiigYCnCkFOpFJFiI8VOBkVWj4xVhelSaIa1RjrUxFMxzQVYFqUdKiFPoFYYab1pxptADOlOQ/NQaQcGgRORTGHFKrZ5pzDikxkJGBUbVK4qI9aEJiUlFLTEFFJS0CAihVp2DTgOKYAtD8UvQVFISaSQEUhyaZSt1pK0RjJ3YUUUUCCiiigAooooAKKKKACiiigAooooAKKKKAClpKUUDQUUUUgFB5rSsgJI+TzWaOtaFlwOKtCZoxwoTyf1p/kqp4FRqpI609Q2evFCZDQuxKkCoD0qPyzml2VQ0iUbeuKC6jtTOnNNxuNCYEnmoehoaT2qPZjpShvWhiE3t6UnmH+7Ts+9M/Gi4WIqCaaTSYNYmliQtTQQKYASKNhPSmUoku+qV1H5n3atiM+lO8r1pDsYjQuO1MKkdRW95A9Kje1VxytVcLGIp2sD6VqQXg2YY1BcWJj5TmqZBBweKCbG5HKGGRzRI6hSSQKxFldejEUNIzclic+9USW5p1P3SCaasobrVPNOViDUsq5dDc0B8VCpJGaazGoKTJjKcdaiaQ+pqPdzTTzQkMUsTTaWkqiWJRRmkpksKKKKBBRRRQAUtJRQAtFJS0DCgUUo6UhoWiiikUKKkHSmL1qVelDKQLkHtVlOlVh1qxGc1LKQ8g1GRU3WmFaRRGBzTx1pmMGnDpTEMNJSkUlIYU2nYNJjFACqeamHQ1XHWpkJoEhGqI1OwqJhgUDIcUuKKWqujMTHFLtpccU8CkwG0U6igYjEdMVBIamfjJqs5zVIUnoMoooqjEKKKKACiiigAooooAKKKKACiiigAooooAKKKKAClpKdQNCUUmaKAFHWtK0IYVmVagk2nNAjYVqlFU47hWA9asrIMVQEwalBzUIfNP38UCFL80g4GaQc81JQK4Uyn03HtTC4zOPpSAg1IwBGKj5HSmIi2mlCZqXFORaxOjQakGTVlbfjJFKpAFOaTgimK5EyAHikxSk80wsM0DHECmmmlqQmgBrgEGsS7j2Smtong1m34zg0IUjOpKcabVEMKKKKQieE5BFDimwuFbmpHINJlIhNJTjTcUFNCUUUUEiUUUUyQooooAKKKKACiiigBaKKUUhoSloxzS0FJBS4NPVR3oIpFWEXg1KBiol61MD7UDGkYqaI8ioyBinR8UmUiyp4prnApVbgUjHNIroVmlweKk3ZquykMeKkUkLQArGnLUZ609aBC0lLRQAynrxSA+tJupAT4BqF6kGeKZJzQxkLUq0jULTJJVp2aavSnCkAEU0049KjYimA2Q4qux5qVzUJ61SImxKKKKoyCiiigAooooAKKKKACiiigAooooAKKKKACiiigBaDRQaQxKUUqrubFOddlMLEdSKajp46UCJVkKsKupPkCs3dT0k2d6pBY1kmJHtVlOQKyoJK0Y3yKEIsUlJRQgY+iiimIKZT6ZTEKOtSCkoFYnQKzd6buBpGNMzVBYeznGMUwNxRnioyalgOLYpSRioiKQtgUXGOY9qpXuNgPerDtVO8PyUITKBpKWkqzIKKKKQBSgkUlFADwaKZTgaVi0wxSU6jFAWG0mKdSUCsFJTqSmKwlFOxRigLDcUtLilxSuPlG4pQKXGKMUDSDFOC0lOUUikOooooGC8GpAfamDg1KvSgY2kpaSpGWFbinGo0p/8NNgMI68Uw088VGxoKGk81ItRnrQvFIkfRTadQMWmA4NPNMOc0EksZzQwqNGwalzketBRA3WkFPkFMFAmSUtFFAATUbHNKxqEsaEIRzzUZp7daZVozkJRS0lMgKKKKACiiigAooooAKKKKACiiigAooooAKKKKAFooopDHK5XpTSSxyaSimIBTs8U2igYppO9FFAieJ8GtK1bcKyEODWtZn5TTAvg04Go1NOFMQ/PFNzR2FJQIKKKKoRJSMRSbqZmsjccG9qa3GacSMVGaYhM01jQx44qMmpKQE0jEU3pTWNIYE1Tu2PSrDHHeqVw+5qaJexDmikoqjMKKKKACipIRl6dJHg8UDSuQ04UAUopMaQtJS0lIthSYpaKBBijFApcUDSExS0oFHSgdgI5pKWigLCUUUoFADgMU6mcnFOAoAWg9KXFNY8UAIn3qlHAqFTzU2TigYUlLQ1ADkOOKmJyKrBvmqcdKVhoRqibrUrVE1IoTvThwKjp+40CCiiigY6mtTqKCSMEA1OCCtQMtSxmkA1hzTFqVxkVD0oBkwOc0hOKaGpCc02IGqE1IxqMnNNCGmkNOppqiWJSUppKZDCiiigQUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAuaSiigAooooAKKKKACiiigA71p2LZWsytCxyFJqkJmmOBT88VGrHAFOB4piHUUmaM0AFLSUlACnrTc07tRismdAzdTSac2MVE7cUhDic1G3akzxSE0hiE0xm7U4mmMaYDXbiqEhyxq1K2FqmTk00RJiUUUVRAUUUUATW/wB+pXAaoYfvGrKrmkzSJAI808x1YWPJpxj44pFFIqBTcVZMXrUbJigCHFKBT9nrShetAWIwKMU/y6NlILDaMU4DigjOKBjMUU4imkUxCU8UmKUCgELS0UlSA7PFMbrT80xzTGCfeqQcVEvWpaAANilz8tNzTu1AEbdamjPFRtToz2ouBKaibqakPSomoLGinU2lp2JClBpBTqkB2aKQUtADGHNCnBpW61H3oAm6imkUooY0gG0jHig01qoQ0tmkNOHWmGmISikopkiUlOpDQQxKKKKYgooooAKKKKACiiigAooooAKKWkoAWiiigYUlLSUCCiiigAooooAKKKKACtGxHyms6tC1cDigC9Sq3zUzdR8tUJonzRnNQ7x607zB/eoEPyM+1JuxTC/+1TSxZsUySxmgtUZNBNZHQIx7VG5pWPvTDSGIWNJ2oppNArisajY805qjPNAEMx+Sqvep7hucVBVIhhRRRTEFFFFAE0C5Jq4inFQWiZrQVAB0qWXEREA7UpT8qmVPWl2j0pllVkNMaMmrZXmkKfLSGUmjxUZTBq6yU0R5oEVAvFAUAVZMe00hTjpQBW24PtTdvNTFKaUoKImGKaakYGoyKBEdPoptArDqKbRQMeW4phOaTNFAriin7qYuKcBQMdnJpwpgFPoYCMDSxnBxTW+tIpINKwE/aomqXPFRNSK1G0UUUCHUCjNFABTsmm0tACsc0zNKaYT81MCQUHvQOlBpdQGU2nU2mIKaadTTTExtFFFMgWm0ppKBMKSlopiEooooEFFFFABRRRQAUUUUALSU6koHYKKKKQCUUUUxBRRRQAUUUUAFFFAoAVetWASKhUc1IOnNIpIka4Yd6j+0tjrTZB3qKmmS0Tmds8Gj7Q/rUFFVcCYTuOhqxb3EjPgniqNT2xxJTQjYNNoJpm6sTUCeKYetOJ5phNNjFNNp2aZxikwYj0xuM044xUbsAppoGU3OXJppoJyaKoyCiiigAooooA1LJfkHrV8J0qC1jxGtW8cUM0Q0rRT9lLs4pWBjNopCKl2DFIVoKISnFIE5qXbmgrxSEQMnNMKcVYAoZBzTAqlKidRirTLjtUTAUDKhWo3WrTpzUTLSArEcUyp2jqJlOaAGUZoPWkFAhaKKKAHA0oIFNooKHA04dKYDThSY7i5ppPOKXOKaetAiwPu1G3SlQ5WkbpQBHT6ZT9tACiijFFIGITS54pppadhAelRnhs08mo6EgZMvSlPSmp0oJ4oQxGqOpGplABSGlpppksKKKDQISkpaSmSwooooASilpKZIUUUUAFFFFABS0lLQAlFFFABRS0lABRQaKACiiigAooooAKUDmgUqjJpDSHUuD6U5adU3KG4yDUTDBqao2BNNA0R0UppKogKkibbIDUdOHBFUgNfc1JRSVmaCmmnpQWxTCwxQMN1JRSUAKarzNhanqtcN0FC3JZXoooqiAooooAKcgy4HvTaltxuuEHvQBvwrhBjoKlAIpkWCoxT+nbmhlDqY1SU2kUNqSm7qFbNAxGpadTWNMLCNjPFICMUE000JgI+KrsMsalJzxTTikMYVqJ15qYkVG2KQFaQcmoHWrL9TULDvQIrsMUlSEUwjFAxtLRiigQUUUmaAuLTgabTs4BoGBNNzzS0lAyWM05qjQ4p+eKTBEZ605Tg0jUgYigCUEYpMc00NSFqYrCscCgtUZOaM0rBccWpnWikHWnYm5KtOpKKkoY1IKcRTe1UJhQaKDQDEooooEFFFFACYoPFLSGmKwlBoooJEooopiCiiigAooooAXNFJS0DCkooFAhcUlL3ooASiiigBaMUooxSKsApwFJinAUFJaDl6UtNHWnUmNCU2nU2kIPL3GplsmYjmmjjBqT7c68YzVohjXtPLwGNNEUXdjSyXrvjiovtEnr+lUmI03PWm9qKZj3rE1Cm06kp3EFFFFMBjVUmOZDVuQ4UmqJ600KQlFFFMgKKKKACp7QZnX2qCrViP3/4UAbsB4wamqvCeBUu/jrQyxxNMJ4pu6k3UDsITQWppaoy3NIol8zI60FhUJJpu6i4kTFsik34qLOaXIFIY8vUZemFs0zOaAHlqY/SlPWmMRQA1utRNUje1R9TzSGMIqM4FStjFRMMmgTG0UUUwCiiigQUUUUDEpaQUtAkKKfmmgZpcd6TL6AwzTKdTaYhKXtRijFBNhM0UUUCCgdaKUdaARIvy0tG6kpFDT9Kb2p5puMUwEpKWigQ2loxRigVgooooAKSn9qbQFhtFOxSYpktDaKWjFMVhKKMUUCCiiigAooooAKKKKAFpKWigYlKKKKQWFooooKFHWnDrTR1paRQtG6kzRQIM0+m4pQaAHHpUTCnUHmgkiopxXHakqhWNPcaTJpKWpLCm0tFK4CUtFFIRHOwEZqjVm5bnGarVoiWFFFFAgooooAKu2A+cmqVX7EYBNAGkrYp5fNQ9qM4ouWSl6buphbjmmlhSKQ8tTGbBphNNbnNICXdTM8008E0oPNFxjgaQmkzSZ5oAKWkpaAAnimMeaeTUbCgEMNR1Ic5ptLYYxhTDT3PNMJzTJGkU2n5phpFBRRRQAlFFFMkTFLRRQA5aXPakWn4pFDTTcU/FGMUAR0UGimISilooFYKKKBQMeOlKKaetKvSgBaWkpaQIjIIpKU80lMAooooEJRRRQIKKKKACkpTRQDEpKdRTFYbSUtFMmwlFLSUCCiiigAooooAKWkpaBoKKKKACnU2ikMdRSUUDuLS03NGaBDwaC1MzQTSsMdnJpQKRRxTgTQNCOOKiqbgg8VGRTRLVy/TqbS1JYtJRRQJD6a1Ju96Hb5aQFKdsvj0qKnSHLk02tEZhRRRQAUUUUAFaFpgR+9Z9aFpjYKCoq7LYOOKC1JmkpMsUmmk80E03cDSBjicU0v60UzGTUjHU2n0cUxCUtJS0ALuozTOfalHFUIdUZOTT+aMZpANC800gVLj0qNxRYpETLUZFSs2KiNDAaeM00dKeelRmgAooopAJS0lSBaYDADTttOxTQKQAKd2oxzSmmxjaKDTeaLAIaSiigQGkxS0oFArBS0U6kUJgUoAFLijFFwCihqSkIaabT2703GKoBtGaKKCQooooAKKKKAFpKWkoGJRRRTJA0GlpDQDEooooEFIadTTTEwooooEFFFFABS0lLQNBRRRSAKKM0UwuFGaKKQXEzS4oFOFDBCinKKQdadUsoU8iomGDUp6VHgUxsvNRRTaQxf4qKKSmJBUcp4NOZsVBJJkEUhXIKKKKsgKKKKACiiigAHWtOFQEFZy/fH1rSj+6KGVEkzSUtJmpLA9Kb360MeaBQNgtJTqZUgFOptOoJHZpVpuKcBVDDFAGKdRQFxtFFFAkNamdqeetNdeKBkZ5qNl4p7daRvu0DIWzmozUsnU1DQIWiiloGJSrSU+gQo60E0g7UGgYUUyn0PUBaSiigY2nU2igQN1paKKQBSrS0UALiilooGJSUUUxCU1utOooAjooPWigkKSlpKYBRRS0AFFFFIAooooAKSiimIKKKKAEpKWigkSiiimIKKKKAClpKUUDQUU7FJSHYbRTqMU7isNpaUDmlpDSExTgKKKRVhadTadSAdSU2n0ElikpKSkUOpu7Ao3cVEx4q0A2Z+eKgzTnOWNNpkNhRRRQIKKKKACiiigB8f8ArBWkvArOiGZBWgmMYpMqI6kpSaa1IsbRRRQIeMUuM0baUUDQ6iilqRjQKeopKeBTJYynUUU7AMppp+evFNNBQhNNIyKdTWpARGkPSlpCKYEbDmo+pqRjTO9ICOlpD1opgFPplPoENyaMmiigY5Wx/wDXGaa3WiigAopKWgQUUUUAOxRjNFLQyhVprUtFIQlFLTGpi6ElJmm5PrSjNAxMZNBGKM80hNAhMmiiigBKKKKBCUtJRTAWiikpALRRRQAUUUUAJRRRTAKSlpKCRKKKKZIq9aUjFIOtB6Uh9BKUHmkopiJKKaDS0ihMUo60UUDCiikpALS0h4paBi06iikAu3bzRSUUCH0tJSNSGO3c1G5NKaYzVQiM9aSiiqICiiigAooooAKKKKAJYPv5q6h5qnAPmNXFxikykLSUUUihadTadQxhTh0pmKcOlJCRJRTaN1MCQdadiowcU4NQAtMp1FMCOkp9NoGIajc8U5qaaQIibpTDUpFRmgY09KjPSpm6VCRQA2ihetFNCHL1pKSloBhRRRSAKKKSgAoozRQAtFJSZoAkptFFAxKKKKBCg8Upx603NJTEOIpDSUZpAPJFJTaKY7hRRRSEFFFFABRRRQAtFFFAxKKUUUBYSijFFAhaSlpKBiUGlpDTJGmilNJTIHL1pzLimr1qVgMUmXFXRCetJTnGCabTJe4oOKWm07OaBphRRRSGLQaMUpGKQxlOFNpwpiQ/+Gm0UtIeoUU6m0hD80hfNNzRmgd2JmmMcmlY02qRDCiiimIKKKKACiiigAooooAng61ZHFQW/IqfvSKQtLSUUrDH4zSjNJg09eQOaQ0Jmlo20tUAUUUtABTwc1HmnpSAWilprUwGls0hpMc0ppDGmo6kppHegBhGDzTGXipMZNBA7UMRXamMOKkI5701ulA2RN1pKVutJTELmgmkopALRSUUALSUpOaSmAUUUUgCiiigBwNKDTQaTNBVx1JilxRSHoJRSUZqiRTRRRSASiiigQUUZpM0ALRSUtABRRRQAUtJS0DAUuM0maAaBhSUtFAhKKXHFJigQUlHaimIaaKDRTIFHWplGQBUI5NTZwAaTKiMlGGqOpJc7+ajoQpbhQDiiimIeBxRSA8YpwpFoSiiigYlLSUtACg04HNMFOBxSYIfSUUtIZFmkJoyDSGqIA0lFFMkKKKKACiiigAooooAKKKKAJ4eFzVgHIqGIfLUyjFSyloOpabS0DuPHWnDqKZTgeKBkmabu5puaKYIXNOzTaSkA4GnhuaiGc1KOuaYIcOaRqQ0hoAb/FS0UUgGmmlafTG6fLQMaaTJNO60hXFFhEZHPFRMOKmNN29aAIGyDTKmcdaiNAxKKKKbEANFJS0AJRRRQAUtJS0CDNLSUtIoKKKKAAUUmaWgdwooooENooopiFooopAFFFFABS0UUAIaKKUDNMAopaKQxKKKKAFooooADSEZpx60h6UIQzFJTqQ0xNDaKKBTIJ4wMdKcT81NjcDg0/PNSzVbEUmCPeou1TysMYqDtTREgooopkh0NPzkUylHWgaY6ikp1IoaaKCMUlAh1LSUopFCgc0o6mm1IvSgZBmjNJRVGdwooooEFFFFABRRRQAUUUUAFA60Uq9aALMYqY1ApyakHNBSHUtJS0ihd1O3U2loELRSUtIY4HIowKM4OKUHmgApy/NTaN1AEjEU3OaTPFAoKEpy0lLTJEJ5pKUiikMYV54pFyB0p9LimIhPJpKlK5ppGKWoEDioGHJqyw4qFhzmmURUUGikSJRmiimIKUUlFAIKKKKBi5ozSUtILhRSUtABRRSUAGaXNJiimCFooopALRSUUDCilpKBC0lLRQAlFFFMGFLRxS46UhijoKWgA1Ise6gBgUnijYR1q7HbjHIqUxA9MUCRnEGo8VckgPaq7rzQMhNJmnHmm0yWNpRyaSnoQDTJW4nKmpFkHemP96mUDvZisSxzSUUUEhRRRQAUUUUAOBp1MHBp45pFxYNjsKSlNNoGKKWkpaQBS7qSloGRmkooqjIKKKKACiiigAooooAKKKKACgdaKKALEbZWpk6VVjbBqyp44oGOo5pKWkUO3UtJRSGLS0lFJgPoptOpgI1JT9tJTGBOKUU0AetKKQ0LRS0UCHU2ikpiFFOpKSgBKSloagSIWBNRMvFTmmOM5pDKhHNJUjrTCKY7DaKWkoJCg0UUAFAoooAKKKKACiiigBaWkopFBRRRQIWiiigYlFFFAgooooAWikpaAG0CilFMQuKevIpFXPFWIbZjSASKMsw4rQjhAXmlitwnarHAqihuwelNZQOlSbqjY5NOwNERGAagljDZqzimsM1IzLkXbVc1flGapspBoJaI6M0UUzMKKKKACiiigAooooAKKKKACnq2ARTKKAQ9qbS0lIoUUZxSCloAcCacOlNpcmkyiKiiiqMwooooAKKKKACiiigAooooAKKKKAAHBqzG2RVanxtg4oGi2KWmo1OpFC04HFNB4pM0mO46lptLQADg04daaeaUUwJKSjNJnmlcYtNo20UAOpaSlqSQJxSg0wnNKKsodml703PFAouIDxSMcjFO6mozzikAN1pMUp7U2gCNwMGoGX8qtMM4qBhxSJuQmilb71JVDDFGKKTmmAppKWikMSiiigQtFFFIYUUUUAFFFFACUtJRTELRRRQMKKKKAFpKWkpDCl60lOXlgKYi1bQlm6VqIgAHFVLYDAGKvKc07BYWkp1NamOKGNjFMzzQ7EEgdKZSGDGo2Oae30qPIJoHYY4JFVZVOKuMAeKrSrxSJ1KbDBptTNHk8VFg0yGhKKKKCQooooAKKKKACiiigAoopx24GAc9zmgAB4pD1pKWkNBQKKXNAwpaQdaKBjaKKKZAUUUUAFFFFABRRRQAUUUUAFFFFABSqcGkooAtqflpQelQxNxjNSDGKRY8Gnios1IDikNDVxUpNR496eKEISlpKKYxaWkopDJB0pDSjpSNQITOKUNTKcaQAMGnYxTF607NMYnSl3Gmk03NAnqSg0bRTNwNIWpgLu96SkpaQhCOKjYc07NIwzSuLqQvim09h7UjHIFUURYpaXFFAWExSClooAKKSloASiiigQUtFJigYUUUUAFFFFAgooopgLRS0lIoKKKKQgpy/epopyjJpgadufkB9quI2OaoQEbQMVZVuKoCwzcUxmpm4gcUhakUMopajpAOLcUwk0jfWkzVDEBprDdmgGjPekIruvJpmwHvVnb3phX5aQiq8ZXPpUdW8AqQRUTxEcgUyGiGiiigkKKKKACiiigAooooAKUUlFAAaWkozQA6nCmZpaRdxtFFFMgKKKKACiiigAooooAKKKKACiiigAooooAUHBqdWyOtV6kjbFBU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 \ No newline at end of file diff --git a/tests/data/simple.plain b/tests/data/simple.plain deleted file mode 100644 index 81f440cd..00000000 --- a/tests/data/simple.plain +++ /dev/null @@ -1,7 +0,0 @@ -***implementation reqs*** - -- Simple implementation requirement - -***functional specs*** - -- Simple functionality diff --git a/tests/data/templates/block_level_include.plain b/tests/data/templates/block_level_include.plain deleted file mode 100644 index b91fb985..00000000 --- a/tests/data/templates/block_level_include.plain +++ /dev/null @@ -1,12 +0,0 @@ -***implementation reqs*** - -- You cannot solve the hardest problem. -- If you do, let me know. - -***functional specs*** - -- Display "hello, world" - - ***acceptance tests*** - - 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 deleted file mode 100644 index a290ec24..00000000 --- a/tests/data/templates/code_variables.plain +++ /dev/null @@ -1,10 +0,0 @@ -***definitions*** - -- :concept: is a concept. - -***implementation reqs*** -- First implementation requirement. -- Second implementation requirement. - -***functional specs*** -- Implement {% include "implement.plain", variable_name: "nice" %} diff --git a/tests/data/templates/header.plain b/tests/data/templates/header.plain deleted file mode 100644 index c223f7a8..00000000 --- a/tests/data/templates/header.plain +++ /dev/null @@ -1,7 +0,0 @@ -***definitions*** - -- :concept: is a concept. - -***implementation reqs*** -- First implementation requirement {{variable_name_1 | code_variable}}. -- Second implementation requirement {{variable_name_2 | code_variable}}. \ No newline at end of file diff --git a/tests/data/templates/implement.plain b/tests/data/templates/implement.plain deleted file mode 100644 index b32f8d4e..00000000 --- a/tests/data/templates/implement.plain +++ /dev/null @@ -1,3 +0,0 @@ -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 diff --git a/tests/data/templates/implement_2.plain b/tests/data/templates/implement_2.plain deleted file mode 100644 index a171ed4b..00000000 --- a/tests/data/templates/implement_2.plain +++ /dev/null @@ -1,3 +0,0 @@ -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 diff --git a/tests/data/templates/template_include.plain b/tests/data/templates/template_include.plain deleted file mode 100644 index 84226d64..00000000 --- a/tests/data/templates/template_include.plain +++ /dev/null @@ -1,4 +0,0 @@ -{% include "header.plain", variable_name_1: "nice_1", variable_name_2: "nice_2" %} - -***functional specs*** -- Implement {% include "implement_2.plain", variable_name_1: "nice" %} diff --git a/tests/data/templates/test_hardest_problem.plain b/tests/data/templates/test_hardest_problem.plain deleted file mode 100644 index 258eeabb..00000000 --- a/tests/data/templates/test_hardest_problem.plain +++ /dev/null @@ -1,2 +0,0 @@ -Here's the hardest problem for you: - - How solve the hardest problem? \ No newline at end of file diff --git a/tests/test_file_utils.py b/tests/test_file_utils.py index 28b77862..f12757fb 100644 --- a/tests/test_file_utils.py +++ b/tests/test_file_utils.py @@ -5,9 +5,7 @@ import file_utils from diff_utils import get_code_diff -from file_utils import get_existing_files_content, load_linked_resources, store_response_files -from plain2code_exceptions import UnsupportedBase64Content, UnsupportedResourceType -from plain2code_utils import MAX_BASE64_BLOB_LENGTH +from file_utils import get_existing_files_content, store_response_files @pytest.fixture @@ -16,73 +14,6 @@ def template_dir(): yield d -def test_load_linked_resources_text_file(template_dir): - file_path = os.path.join(template_dir, "notes.md") - with open(file_path, "w") as f: - f.write("# hello") - - result = load_linked_resources([template_dir], [{"text": "Notes", "target": "notes.md"}], "my_thing") - - assert result == {"notes.md": "# hello"} - - -def test_load_linked_resources_binary_file_raises_unsupported_resource_type(template_dir): - file_path = os.path.join(template_dir, "icon.png") - with open(file_path, "wb") as f: - f.write(b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\xff\xfe\xfd") - - with pytest.raises(UnsupportedResourceType) as exc_info: - load_linked_resources([template_dir], [{"text": "Icon", "target": "icon.png"}], "my_thing") - - assert "icon.png" in str(exc_info.value) - assert "binary file" in str(exc_info.value) - assert "my_thing" in str(exc_info.value) - - -def test_load_linked_resources_missing_file_raises_file_not_found(template_dir): - with pytest.raises(FileNotFoundError): - load_linked_resources([template_dir], [{"text": "Missing", "target": "missing.md"}], "my_thing") - - -def test_load_linked_resources_base64_blob_raises(template_dir): - file_path = os.path.join(template_dir, "request.txt") - with open(file_path, "w") as f: - f.write("curl -d 'selfie_image=" + "A" * (MAX_BASE64_BLOB_LENGTH + 100) + "'") - - with pytest.raises(UnsupportedBase64Content) as exc_info: - load_linked_resources([template_dir], [{"text": "Request", "target": "request.txt"}], "my_thing") - - assert "request.txt" in str(exc_info.value) - assert "my_thing" in str(exc_info.value) - - -def test_load_linked_resources_small_base64_allowed(template_dir): - file_path = os.path.join(template_dir, "token.txt") - with open(file_path, "w") as f: - f.write("token=" + "A" * (MAX_BASE64_BLOB_LENGTH - 100)) - - result = load_linked_resources([template_dir], [{"text": "Token", "target": "token.txt"}], "my_thing") - - assert "token.txt" in result - - -def test_load_linked_resources_real_base64_image_raises(template_dir): - # Real base64-encoded JPEG that made Gemini 3 fail, inlined into a curl example resource. - sample_path = os.path.join(os.path.dirname(__file__), "data", "sample_base64_image.txt") - with open(sample_path) as f: - blob = f.read() - - file_path = os.path.join(template_dir, "face_match_request.txt") - with open(file_path, "w") as f: - f.write(f"curl -d 'selfie_image={blob}' https://example.com/face-match") - - with pytest.raises(UnsupportedBase64Content) as exc_info: - load_linked_resources([template_dir], [{"text": "Request", "target": "face_match_request.txt"}], "face_match") - - assert "face_match_request.txt" in str(exc_info.value) - assert str(len(blob)) in str(exc_info.value) - - def test_store_response_files_writes_unicode_as_utf8(template_dir): """A non-cp1252 character (📍 U+1F4CD) is written as UTF-8, whatever the platform's default encoding.""" content = "Location 📍 marker\n" diff --git a/tests/test_imports.py b/tests/test_imports.py deleted file mode 100644 index 90e8f0c3..00000000 --- a/tests/test_imports.py +++ /dev/null @@ -1,53 +0,0 @@ -import pytest - -import plain_file -from plain2code_exceptions import ImportedModuleWithFunctionalitiesError - - -def test_imported_module_with_functionalities(load_test_data, get_test_data_path): - plain_source_text = load_test_data("data/imports/imports_module_with_functionalities.plain") - template_dirs = [get_test_data_path("data/imports")] - with pytest.raises( - ImportedModuleWithFunctionalitiesError, - match="use 'requires' instead of 'import'", - ): - plain_file.parse_plain_source(plain_source_text, {}, template_dirs, [], []) - - -def test_non_existent_import(load_test_data, get_test_data_path): - plain_source_text = load_test_data("data/imports/non_existent_import.plain") - with pytest.raises(Exception, match="Module does not exist"): - plain_file.parse_plain_source(plain_source_text, {}, [], [], []) - - -def test_circular_imports(load_test_data, get_test_data_path): - plain_source_text = load_test_data("data/imports/circular_imports_main.plain") - template_dirs = [get_test_data_path("data/imports")] - with pytest.raises(Exception, match="Circular import detected"): - plain_file.parse_plain_source(plain_source_text, {}, template_dirs, [], []) - - -def test_diamond_imports(load_test_data, get_test_data_path): - plain_source_text = load_test_data("data/imports/diamond_imports_main.plain") - template_dirs = [get_test_data_path("data/imports")] - plain_file_parse_result = plain_file.parse_plain_source(plain_source_text, {}, template_dirs, [], []) - - assert plain_file.marshall_plain_source(plain_file_parse_result.plain_source) == { - "definitions": [ - {"markdown": "- :CommonImportDef: is a definition in diamond_import_common."}, - {"markdown": "- :Import1Def: is a definition in diamond_import_1."}, - {"markdown": "- :Import2Def: is a definition in diamond_import_2."}, - ], - "implementation reqs": [ - {"markdown": "- :CommonImportDef: is used in diamond_import_common."}, - {"markdown": "- :Import1Def: is used in diamond_import_1."}, - {"markdown": "- :Import2Def: is used in diamond_import_2."}, - {"markdown": '- :MainExecutableFile: of :App: should be called "hello_world.py".'}, - ], - "test reqs": [ - {"markdown": "- :CommonImportDef: is tested in diamond_import_common."}, - {"markdown": "- :Import1Def: is tested in diamond_import_1."}, - {"markdown": "- :Import2Def: is tested in diamond_import_2."}, - ], - "functional specs": [{"markdown": '- Display "hello, world"'}], - } diff --git a/tests/test_plainfile.py b/tests/test_plainfile.py deleted file mode 100644 index 24b2bcd1..00000000 --- a/tests/test_plainfile.py +++ /dev/null @@ -1,271 +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 - -import plain_file -import plain_spec -from plain2code_exceptions import PlainSyntaxError -from 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_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_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_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 deleted file mode 100644 index 7f4cdc05..00000000 --- a/tests/test_plainfileparser.py +++ /dev/null @@ -1,775 +0,0 @@ -import os -import re -from unittest.mock import patch -from urllib.parse import urlparse - -import pytest -from liquid2 import Environment, RenderContext, TemplateSource - -import file_utils -import plain_file -import plain_spec -from plain2code_exceptions import MissingFunctionalitiesError, PlainSyntaxError - - -def test_regular_plain_source(get_test_data_path): - _, plain_sections, _ = plain_file.plain_file_parser( - "regular_plain_source.plain", - [get_test_data_path("data/plainfileparser")], - ) - assert plain_sections == { - "definitions": [], - "implementation reqs": [ - {"markdown": "- First implementation requirement."}, - {"markdown": "- Second implementation requirement."}, - ], - "functional specs": [{"markdown": '- Display "hello, world"'}], - } - - -def test_unknown_section(): - plain_source = """ -***definitions*** - -***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" - ), - ): - plain_file.parse_plain_source(plain_source, {}, [], [], []) - - -def test_duplicate_section(): - plain_source = """ -***definitions*** - -***definitions*** -""" - with pytest.raises( - Exception, - match=re.escape("Syntax error at line 4: Duplicate specification heading (`definitions`)"), - ): - plain_file.parse_plain_source(plain_source, {}, [], [], []) - - -def test_invalid_top_level_element(): - plain_source = """ -***definitions*** -``` -code block -``` -""" - with pytest.raises( - Exception, - match=re.escape("Syntax error at line 3: Invalid source structure (`code block`)"), - ): - plain_file.parse_plain_source(plain_source, {}, [], [], []) - - -def test_syntax_error_line_number_accounts_for_frontmatter(): - plain_source = """--- -description: 'Plain file with frontmatter' ---- - -***definitions*** - -***Unknown Section:*** -""" - with pytest.raises( - Exception, - match=re.escape("Syntax error at line 7: Invalid specification heading (`Unknown Section:`)"), - ): - plain_file.parse_plain_source(plain_source, {}, [], [], []) - - -def test_syntax_error_line_number_with_windows_line_endings(): - plain_source = ( - "---\r\n" - "description: 'Plain file with frontmatter'\r\n" - "---\r\n" - "\r\n" - "***definitions***\r\n" - "\r\n" - "***Unknown Section:***\r\n" - ) - with pytest.raises( - Exception, - match=re.escape("Syntax error at line 7: Invalid specification heading (`Unknown Section:`)"), - ): - plain_file.parse_plain_source(plain_source, {}, [], [], []) - - -def test_normalize_line_endings_does_not_duplicate_newlines(): - assert plain_file.normalize_line_endings("a\r\nb\rc\nd") == "a\nb\nc\nd" - - -def test_plain_file_parser_with_comments(get_test_data_path): - _, plain_sections, _ = plain_file.plain_file_parser( - "plain_file_parser_with_comments.plain", - [get_test_data_path("data/plainfileparser")], - ) - assert plain_sections == { - "definitions": [], - "implementation reqs": [{"markdown": "- Second implementation requirement."}], - "functional specs": [{"markdown": '- Display "hello, world"'}], - } - - -def test_plain_file_parser_with_comments_indented(get_test_data_path): - _, plain_sections, _ = plain_file.plain_file_parser( - "plain_file_with_comments_indented.plain", - [get_test_data_path("data/plainfileparser")], - ) - assert plain_sections == { - "definitions": [], - "implementation reqs": [ - {"markdown": "- First implementation requirement."}, - {"markdown": "- Second implementation requirement."}, - ], - "functional specs": [{"markdown": '- Display "hello, world"'}], - } - - -def test_invalid_url_link(get_test_data_path): - with pytest.raises(Exception, match="Only relative links are allowed."): - plain_file.plain_file_parser("plain_source_with_url_link.plain", [get_test_data_path("data/plainfile")]) - - -def test_invalid_absolute_link(get_test_data_path): - with pytest.raises(Exception, match="Only relative links are allowed."): - plain_file.plain_file_parser( - "plain_source_with_absolute_link.plain", - [get_test_data_path("data/plainfile")], - ) - - -def test_missing_link_error_lists_searched_directories(get_test_data_path): - """The error tells the user which directories were searched for the linked resource.""" - plain_file_dir = get_test_data_path("data/plainfile") - - with pytest.raises(PlainSyntaxError) as exception_info: - plain_file.plain_file_parser( - "plain_source_with_missing_link.plain", - [plain_file_dir], - ) - - message = str(exception_info.value) - assert "resources/missing_resource.yaml does not exist" in message - assert "highest to lowest precedence" in message - assert plain_file_dir in message - - -def test_reference_link_parsing(get_test_data_path): - _, plain_sections, _ = plain_file.plain_file_parser( - "task_manager_with_reference_links.plain", - [get_test_data_path("data/plainfile")], - ) - asserted_resources = [ - "task_list_ui_specification.yaml", - "add_new_task_modal_specification.yaml", - ] - for functional_requirement in plain_sections[plain_spec.FUNCTIONAL_REQUIREMENTS]: - if "linked_resources" not in functional_requirement: - continue - - for resource in functional_requirement["linked_resources"]: - assert resource["target"] in asserted_resources - del asserted_resources[asserted_resources.index(resource["target"])] - - parsed_url = urlparse(resource["target"]) - assert parsed_url.scheme == "" - - assert not os.path.isabs(resource["target"]) - - assert asserted_resources == [] - - assert "`[resource]task_list_ui_specification.yaml`" in "\n".join( - [item["markdown"] for item in plain_sections[plain_spec.FUNCTIONAL_REQUIREMENTS]] - ) - assert "`[resource]add_new_task_modal_specification.yaml`" in "\n".join( - [item["markdown"] for item in plain_sections[plain_spec.FUNCTIONAL_REQUIREMENTS]] - ) - - assert plain_sections[plain_spec.FUNCTIONAL_REQUIREMENTS][1]["linked_resources"] == [ - { - "text": "task_list_ui_specification.yaml", - "target": "task_list_ui_specification.yaml", - } - ] - assert plain_sections[plain_spec.FUNCTIONAL_REQUIREMENTS][2]["linked_resources"] == [ - { - "text": "add_new_task_modal_specification.yaml", - "target": "add_new_task_modal_specification.yaml", - } - ] - - -def test_reference_link_parsing_independent_of_working_directory(get_test_data_path, monkeypatch, tmp_path): - """Linked resources resolve against the plain file's directory, not the invocation cwd.""" - monkeypatch.chdir(tmp_path) - - _, plain_sections, _ = plain_file.plain_file_parser( - "task_manager_with_reference_links.plain", - [get_test_data_path("data/plainfile")], - ) - - assert plain_sections[plain_spec.FUNCTIONAL_REQUIREMENTS][1]["linked_resources"] == [ - { - "text": "task_list_ui_specification.yaml", - "target": "task_list_ui_specification.yaml", - } - ] - assert plain_sections[plain_spec.FUNCTIONAL_REQUIREMENTS][2]["linked_resources"] == [ - { - "text": "add_new_task_modal_specification.yaml", - "target": "add_new_task_modal_specification.yaml", - } - ] - - -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.", - ): - plain_file.plain_file_parser("invalid_specification_order.plain", [get_test_data_path("data/plainfile")]) - - -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`)"), - ): - plain_file.plain_file_parser( - "duplicate_specification_heading.plain", - [get_test_data_path("data/plainfile")], - ) - - -def test_missing_non_functional_requirements(get_test_data_path): - with pytest.raises( - Exception, - match="defines functionalities but specifies no implementation reqs", - ): - plain_file.plain_file_parser( - "missing_non_functional_requirements.plain", - [get_test_data_path("data/plainfile")], - ) - - -def test_without_non_functional_requirement(get_test_data_path): - with pytest.raises( - Exception, - match="defines functionalities but specifies no implementation reqs", - ): - plain_file.plain_file_parser( - "without_non_functional_requirement.plain", - [get_test_data_path("data/plainfile")], - ) - - -def test_missing_impl_reqs_with_requires_adds_hint(): - plain_source = {plain_spec.NON_FUNCTIONAL_REQUIREMENTS: None} - with pytest.raises( - PlainSyntaxError, - match="not inherited through 'requires'", - ): - plain_file.validate_functionalities_have_implementation_reqs(plain_source, "my_app", has_requires=True) - - -def test_missing_impl_reqs_without_requires_has_no_hint(): - plain_source = {plain_spec.NON_FUNCTIONAL_REQUIREMENTS: None} - with pytest.raises(PlainSyntaxError) as exc_info: - plain_file.validate_functionalities_have_implementation_reqs(plain_source, "my_app", has_requires=False) - assert "requires" not in str(exc_info.value) - - -def test_no_functional_specs_section(get_test_data_path): - with pytest.raises( - MissingFunctionalitiesError, - match="does not have any functionality specified", - ): - plain_file.plain_file_parser( - "no_functional_specs_section.plain", - [get_test_data_path("data/plainfile")], - ) - - -def test_empty_functional_specs_section(get_test_data_path): - with pytest.raises( - PlainSyntaxError, - match=re.escape("has an empty 'functional specs' section"), - ): - plain_file.plain_file_parser( - "empty_functional_specs_section.plain", - [get_test_data_path("data/plainfile")], - ) - - -def test_indented_include_tags(): - plain_source = """# Main - -***definitions*** - -- This is a definition. - -***implementation reqs*** -- First implementation requirement. -- Second implementation requirement. - -> This is a comment -> This is a with an include tag {% include "template.plain" %} - -***functional specs*** -- Implement {% include "implement.plain" %} -{% include "template.plain" %} -- Display "hello, world" - {% include "template.plain" %} - {% include "template.plain" %} - - Implement {% include "implement.plain" %} -""" - loaded_templates = { - "template.plain": "- This is a functionality inside a template.", - "implement.plain": """something nice and useful - - the nice thing should be really nice - - the useful thing should be really useful""", - } - - expected_rendered_plain_source = """# Main - -***definitions*** - -- This is a definition. - -***implementation reqs*** -- First implementation requirement. -- Second implementation requirement. - -> This is a comment -> This is a with an include tag {% include 'template.plain' %} - -***functional specs*** -- Implement something nice and useful - - the nice thing should be really nice - - the useful thing should be really useful -- This is a functionality inside a template. -- Display "hello, world" - - This is a functionality inside a template. - - This is a functionality inside a template. - - Implement something nice and useful - - the nice thing should be really nice - - the useful thing should be really useful -""" - rendered_plain_source = plain_file.render_plain_source(plain_source, loaded_templates, {}) - assert rendered_plain_source == expected_rendered_plain_source - - def mock_get_source( - env: Environment, - template_name: str, - *, - context: RenderContext | None = None, - **kwargs: object, - ): - return TemplateSource( - loaded_templates[template_name], - template_name, - lambda: True, - ) - - with patch( - "liquid2.builtin.loaders.file_system_loader.FileSystemLoader.get_source", - side_effect=mock_get_source, - ): - plain_source_result, loaded_templates_result = file_utils.get_loaded_templates(["."], plain_source) - assert plain_source_result == expected_rendered_plain_source - assert loaded_templates_result == loaded_templates - - -def test_code_variables(load_test_data, get_test_data_path): - plain_source = load_test_data("data/templates/code_variables.plain") - loaded_templates = { - "implement.plain": load_test_data("data/templates/implement.plain"), - } - - code_variables = {} - rendered_plain_source = plain_file.render_plain_source(plain_source, loaded_templates, code_variables) - keys = list(code_variables.keys()) - - expected_rendered_plain_source = f"""***definitions*** - -- :concept: is a concept. - -***implementation reqs*** -- First implementation requirement. -- 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 -""" - - assert rendered_plain_source == expected_rendered_plain_source - - _, plain_source, _ = plain_file.plain_file_parser("code_variables.plain", [get_test_data_path("data/templates")]) - expected_plain_source = { - "definitions": [{"markdown": "- :concept: is a concept."}], - "implementation reqs": [ - {"markdown": "- First implementation requirement."}, - {"markdown": "- Second implementation requirement."}, - ], - "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", - "code_variables": [{"name": "variable_name", "value": "nice"}], - } - ], - } - - assert plain_source == expected_plain_source - - plain_source = load_test_data("data/templates/template_include.plain") - loaded_templates = { - "header.plain": load_test_data("data/templates/header.plain"), - "implement_2.plain": load_test_data("data/templates/implement_2.plain"), - } - - code_variables = {} - rendered_plain_source = plain_file.render_plain_source(plain_source, loaded_templates, code_variables) - keys = list(code_variables.keys()) - expected_rendered_plain_source = f"""***definitions*** - -- :concept: is a concept. - -***implementation reqs*** -- First implementation requirement {keys[0]}. -- 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 -""" - - assert rendered_plain_source == expected_rendered_plain_source - - _, plain_source, _ = plain_file.plain_file_parser("template_include.plain", [get_test_data_path("data/templates")]) - expected_plain_source = { - "definitions": [{"markdown": "- :concept: is a concept."}], - "implementation reqs": [ - { - "markdown": "- First implementation requirement {{ variable_name_1 }}.", - "code_variables": [{"name": "variable_name_1", "value": "nice_1"}], - }, - { - "markdown": "- Second implementation requirement {{ variable_name_1 }}.", - "code_variables": [{"name": "variable_name_1", "value": "nice_2"}], - }, - ], - "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", - "code_variables": [{"name": "variable_name_1", "value": "nice"}], - } - ], - } - - assert plain_source == expected_plain_source - - -def test_acceptance_tests_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 - 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. - """ - 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*** - -- 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 test_acceptance_tests_top_level_after_other_headings_rejected(): - plain_source = """***definitions*** - -- :concept: is a concept. - -***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." - ), - ): - plain_file.parse_plain_source(plain_source, {}, [], [], []) - - -def test_acceptance_tests_nested_under_definitions_rejected(): - plain_source = """***definitions*** - -- :concept: is a concept. - - ***acceptance tests*** - - - Test the :concept:. - -***functional specs*** - -- 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." - ), - ): - plain_file.parse_plain_source(plain_source, {}, [], [], []) - - -def test_acceptance_tests_nested_under_implementation_reqs_rejected(): - plain_source = """***implementation reqs*** - -- 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." - ), - ): - 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*** - - - Test something. -""" - with pytest.raises( - PlainSyntaxError, - match=re.escape( - "Syntax error at line 5: acceptance tests heading should be nested under specific functional spec." - ), - ): - plain_file.parse_plain_source(plain_source, {}, [], [], []) - - -def test_acceptance_tests_nested_under_functional_spec_allowed(): - plain_source = """***definitions*** - -- :concept: is a concept. - -***functional specs*** - -- Display "hello, world" - - ***acceptance tests*** - - - Test the :concept:. -""" - result = plain_file.parse_plain_source(plain_source, {}, [], [], []) - assert plain_spec.FUNCTIONAL_REQUIREMENTS in result.plain_source - - -def test_concept_validation_definitions(get_test_data_path): - plain_file.plain_file_parser( - "concept_validation_definition.plain", - [get_test_data_path("data/plainfileparser")], - ) - - with pytest.raises(PlainSyntaxError): - plain_file.plain_file_parser( - "concept_validation_noconcepts.plain", - [get_test_data_path("data/plainfileparser")], - ) - - -def test_concept_validation_usage(get_test_data_path): - plain_file.plain_file_parser( - "concept_validation_valid.plain", - [get_test_data_path("data/plainfileparser")], - ) - - with pytest.raises(PlainSyntaxError): - plain_file.plain_file_parser( - "concept_validation_nondefined.plain", - [get_test_data_path("data/plainfileparser")], - ) - - with pytest.raises(PlainSyntaxError): - plain_file.plain_file_parser( - "concept_validation_defined_nondefined.plain", - [get_test_data_path("data/plainfileparser")], - ) - - with pytest.raises(PlainSyntaxError): - plain_file.plain_file_parser( - "concept_validation_defined_nondefined_2.plain", - [get_test_data_path("data/plainfileparser")], - ) - - -def test_concept_validation_redefinition(get_test_data_path): - with pytest.raises(PlainSyntaxError): - plain_file.plain_file_parser( - "concept_validation_redefinition.plain", - [get_test_data_path("data/plainfileparser")], - ) - - -def test_concept_validation_non_concept_usage(get_test_data_path): - plain_file.plain_file_parser( - "concept_validation_nonconcept.plain", - [get_test_data_path("data/plainfileparser")], - ) - - -def test_concept_validation_several_concepts(get_test_data_path): - plain_file.plain_file_parser( - "concept_validation_several_concepts.plain", - [get_test_data_path("data/plainfileparser")], - ) - - -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="cycles in the concept graph"): - plain_file.plain_file_parser("cyclic_definitions.plain", [get_test_data_path("data/plainfileparser")]) - - -def test_required_concepts(get_test_data_path): - plain_file.plain_file_parser( - "required_concepts_example.plain", - [get_test_data_path("data/plainfileparser")], - ) - - plain_file.plain_file_parser( - "required_concepts_module.plain", - [get_test_data_path("data/plainfileparser")], - ) - - plain_file.plain_file_parser( - "required_concepts_partial.plain", - [get_test_data_path("data/plainfileparser")], - ) - - with pytest.raises(PlainSyntaxError): - plain_file.plain_file_parser( - "required_concepts_missing.plain", - [get_test_data_path("data/plainfileparser")], - ) - - with pytest.raises(PlainSyntaxError): - plain_file.plain_file_parser( - "required_concepts_partial_duplicate.plain", - [get_test_data_path("data/plainfileparser")], - ) - - -@pytest.mark.parametrize( - "fixture", - [ - "exported_concepts_example.plain", - "exported_concepts_imported_exported.plain", - "exported_concepts_multiple_per_bullet.plain", - "exported_concepts_nested_example.plain", - ], -) -def test_exported_concepts_valid(get_test_data_path, fixture): - plain_file.plain_file_parser(fixture, [get_test_data_path("data/plainfileparser")]) - - -@pytest.mark.parametrize( - ("fixture", "expected_word"), - [ - ("exported_concepts_defaults_exported.plain", "cannot export default concept"), - ("exported_concepts_inherited_reexported.plain", "cannot be re-exported"), - ("exported_concepts_malformed.plain", "exports an invalid concept"), - ("exported_concepts_missing_definition_example.plain", "'exported_concepts_missing_definition' exports"), - ("exported_concepts_missing_definition.plain", "exports undefined concept"), - ("exported_concepts_multiple_per_bullet_undefined.plain", "exports undefined concept"), - ("exported_concepts_no_definitions.plain", "exports undefined concept"), - ("exported_concepts_not_declared_example.plain", "not defined"), - ("exported_concepts_transitive_example.plain", "not defined"), - ], -) -def test_exported_concepts_invalid(get_test_data_path, fixture, expected_word): - with pytest.raises(PlainSyntaxError, match=expected_word): - plain_file.plain_file_parser(fixture, [get_test_data_path("data/plainfileparser")]) - - -def test_requires_without_definitions(get_test_data_path): - _, plain_source, _ = plain_file.plain_file_parser( - "requires_without_definitions_example.plain", - [get_test_data_path("data/plainfileparser")], - ) - assert plain_source[plain_spec.DEFINITIONS] == [{"markdown": "- :Concept: is a concept."}] - - -def test_topological_sort(get_test_data_path): - _, plain_source, _ = plain_file.plain_file_parser( - "topological_sort.plain", [get_test_data_path("data/plainfileparser")] - ) - assert plain_spec.DEFINITIONS in plain_source - assert plain_source[plain_spec.DEFINITIONS] == [ - {"markdown": "- :Concept1: is a concept."}, - {"markdown": "- :Concept2: is a concept that depends on the :Concept1: concept."}, - {"markdown": "- :Concept3: is a concept that depends on both the :Concept1: and :Concept2: concepts."}, - ] - - _, plain_source, _ = plain_file.plain_file_parser( - "topological_sort_not_referenced.plain", - [get_test_data_path("data/plainfileparser")], - ) - assert plain_spec.DEFINITIONS in plain_source - assert plain_source[plain_spec.DEFINITIONS] == [ - {"markdown": "- :Concept1: is a concept."}, - {"markdown": "- :NonReferencedConcept: is a concept."}, - {"markdown": "- :Concept7: is a concept."}, - {"markdown": "- :Concept2: is a concept that depends on the :Concept1: concept."}, - {"markdown": "- :Concept8: is a concept that depends on the :Concept1: concept."}, - {"markdown": "- :Concept5: is a concept that depends on the :Concept1: concept."}, - {"markdown": "- :Concept3: is a concept that depends on both the :Concept1: and :Concept2: concepts."}, - {"markdown": "- :Concept9: is a concept that depends on the :Concept8:."}, - {"markdown": "- :Concept4: is a concept that depends on the :Concept3: concept."}, - {"markdown": "- :Concept6: is a concept that depends on the :Concept1: and :Concept4: concepts."}, - ] diff --git a/tests/test_plainspec.py b/tests/test_plainspec.py deleted file mode 100644 index d773870b..00000000 --- a/tests/test_plainspec.py +++ /dev/null @@ -1,103 +0,0 @@ -import pytest - -import plain_file -import plain_spec - - -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) - - -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."}], - }, - ] - } - assert plain_spec.has_acceptance_tests(plain_source) is 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: []}, - ] - } - assert plain_spec.has_acceptance_tests(plain_source) is False - - -def test_has_acceptance_tests_false_when_no_functional_requirements(): - assert plain_spec.has_acceptance_tests({}) is False - - -def test_get_frids_simple(get_test_data_path): - _, plain_source, _ = plain_file.plain_file_parser("simple.plain", [get_test_data_path("data/")]) - - frids = list(plain_spec.get_frids(plain_source)) - - assert frids == ["1"] - - -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" - - -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") - - -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 - - -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") - - -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 - - -def test_get_specifications_simple(get_test_data_path): - _, plain_source, _ = plain_file.plain_file_parser("simple.plain", [get_test_data_path("data/")]) - - with pytest.raises(Exception, match="Functionality a does not exist."): - plain_spec.get_specifications_for_frid(plain_source, "a") - - frid = plain_spec.get_first_frid(plain_source) - - specifications, _ = plain_spec.get_specifications_for_frid(plain_source, frid) - - assert specifications == { - "definitions": [], - "implementation reqs": ["- Simple implementation requirement"], - "test reqs": [], - "functional specs": ["- Simple functionality"], - } diff --git a/tests/test_render_range.py b/tests/test_render_range.py new file mode 100644 index 00000000..d033154b --- /dev/null +++ b/tests/test_render_range.py @@ -0,0 +1,42 @@ +from types import SimpleNamespace + +import pytest + +import plain_spec + + +def _four_functionalities(): + return {plain_spec.FUNCTIONAL_REQUIREMENTS: [{"markdown": f"- Functionality {i}."} for i in range(1, 5)]} + + +def test_get_render_range_parses_start_and_end(): + assert plain_spec.get_render_range("2,3", _four_functionalities()) == ["2", "3"] + + +def test_get_render_range_single_value_renders_one_frid(): + assert plain_spec.get_render_range("2", _four_functionalities()) == ["2"] + + +def test_get_render_range_from_runs_to_last_frid(): + assert plain_spec.get_render_range_from("3", _four_functionalities()) == ["3", "4"] + + +def test_compute_render_range_prefers_render_range(): + args = SimpleNamespace(render_range="1,2", render_from="3") + assert plain_spec.compute_render_range(args, _four_functionalities()) == ["1", "2"] + + +def test_compute_render_range_falls_back_to_render_from(): + args = SimpleNamespace(render_range=None, render_from="3") + assert plain_spec.compute_render_range(args, _four_functionalities()) == ["3", "4"] + + +def test_compute_render_range_none_when_no_arguments(): + args = SimpleNamespace(render_range=None, render_from=None) + assert plain_spec.compute_render_range(args, _four_functionalities()) is None + + +def test_compute_render_range_invalid_frid_raises(): + args = SimpleNamespace(render_range="9", render_from=None) + with pytest.raises(plain_spec.InvalidFridArgument): + plain_spec.compute_render_range(args, _four_functionalities()) diff --git a/tests/test_requires.py b/tests/test_requires.py deleted file mode 100644 index 9540a66d..00000000 --- a/tests/test_requires.py +++ /dev/null @@ -1,28 +0,0 @@ -import pytest - -import plain_file -from plain2code_exceptions import ModuleDoesNotExistError - - -def test_non_existent_require(get_test_data_path): - with pytest.raises(ModuleDoesNotExistError, match="Module does not exist"): - plain_file.plain_file_parser("non_existent_require.plain", [get_test_data_path("data/requires")]) - - -def test_independent_requires(get_test_data_path): - with pytest.raises(Exception, match="There must be a fixed order how required modules are dependent"): - plain_file.plain_file_parser("independent_requires_main.plain", [get_test_data_path("data/requires")]) - - -def test_diamond_requires(get_test_data_path): - with pytest.raises(Exception, match="There must be a fixed order how required modules are dependent"): - plain_file.plain_file_parser("diamond_requires_main.plain", [get_test_data_path("data/requires")]) - - -def test_circular_requires(get_test_data_path): - with pytest.raises(Exception, match="Circular required module detected"): - plain_file.plain_file_parser("circular_requires_main.plain", [get_test_data_path("data/requires")]) - - -def test_normal_requires(get_test_data_path): - plain_file.plain_file_parser("normal_requires_main.plain", [get_test_data_path("data/requires")]) diff --git a/tests/test_user_error_message.py b/tests/test_user_error_message.py new file mode 100644 index 00000000..09a8ccb9 --- /dev/null +++ b/tests/test_user_error_message.py @@ -0,0 +1,36 @@ +from liquid2.exceptions import TemplateNotFoundError + +from plain2code import user_error_message + +SEARCH_ORDER_STEPS = [ + "1. The directory containing your .plain file", + "2. The directory specified by --template-dir (if provided)", + "3. The built-in 'standard_template_library' directory", +] + + +def test_template_not_found_keeps_parser_message_and_adds_search_order(): + message = user_error_message(TemplateNotFoundError("Template not found: 'nope.plain'")) + + # liquid2 repr-quotes a bare message when no token is attached; the CLI attaches one. + assert "Template not found: 'nope.plain'" in message + for step in SEARCH_ORDER_STEPS: + assert step in message + assert "missing template exists" in message + + +def test_resource_not_found_keeps_parser_message_and_adds_search_order(): + message = user_error_message(FileNotFoundError("Resource file 'x.md' referenced in module 'm' not found.")) + + assert message.startswith("Resource file 'x.md' referenced in module 'm' not found.") + for step in SEARCH_ORDER_STEPS: + assert step in message + assert "resource exists" in message + + +def test_other_errors_are_unchanged(): + assert user_error_message(ValueError("boom")) == "boom" + + +def test_empty_message_falls_back_to_repr(): + assert user_error_message(RuntimeError()) == "RuntimeError()"