diff --git a/file_utils.py b/file_utils.py index c83551da..b67ed9c8 100644 --- a/file_utils.py +++ b/file_utils.py @@ -180,6 +180,20 @@ def open_from(dirs, file_name): 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 = {} diff --git a/plain_file.py b/plain_file.py index 3d8630f6..4583f5a7 100644 --- a/plain_file.py +++ b/plain_file.py @@ -90,7 +90,7 @@ def remove_quotes(token): token.children = tuple(new_children) -def check_section_for_linked_resources(section): +def check_section_for_linked_resources(section, template_dirs): linked_resources = [] for link in traverse(section, klass=Link): parsed_url = urlparse(link.node.target) @@ -99,11 +99,19 @@ def check_section_for_linked_resources(section): f"Plain syntax error: Only relative links are allowed (text: {link.node.children[0].content}, target: {link.node.target})." ) - if not os.path.exists(link.node.target): - raise PlainSyntaxError(f"Plain syntax error: Link {link.node.target} does not exist.") + 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(link.node.target): - raise PlainSyntaxError(f"Plain syntax error: Link {link.node.target} must be a file.") + 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}).") @@ -122,15 +130,15 @@ def check_section_for_linked_resources(section): section.linked_resources = linked_resources -def check_for_linked_resources(plain_source): +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) + 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) + check_section_for_linked_resources(acceptance_test, template_dirs) def process_section_code_variables(section, code_variables): @@ -845,7 +853,7 @@ def plain_file_parser( # noqa: C901 process_acceptance_tests(plain_file_parse_result.plain_source) - check_for_linked_resources(plain_file_parse_result.plain_source) + check_for_linked_resources(plain_file_parse_result.plain_source, template_dirs) marshalled_plain_source = marshall_plain_source(plain_file_parse_result.plain_source) diff --git a/tests/data/plainfile/plain_source_with_missing_link.plain b/tests/data/plainfile/plain_source_with_missing_link.plain new file mode 100644 index 00000000..4f06694e --- /dev/null +++ b/tests/data/plainfile/plain_source_with_missing_link.plain @@ -0,0 +1,9 @@ +***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/task_manager_with_reference_links.plain b/tests/data/plainfile/task_manager_with_reference_links.plain index c48407e8..bbddc31e 100644 --- a/tests/data/plainfile/task_manager_with_reference_links.plain +++ b/tests/data/plainfile/task_manager_with_reference_links.plain @@ -29,9 +29,9 @@ - Implement the entry point for :App:. -- Show :TaskList:. The details of the user interface are provided in the file [task_list_ui_specification.yaml](tests/data/plainfile/task_list_ui_specification.yaml). +- 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](tests/data/plainfile/add_new_task_modal_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:. diff --git a/tests/test_plainfileparser.py b/tests/test_plainfileparser.py index 6677e5c2..7f4cdc05 100644 --- a/tests/test_plainfileparser.py +++ b/tests/test_plainfileparser.py @@ -146,14 +146,30 @@ def test_invalid_absolute_link(get_test_data_path): ) +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 = [ - "tests/data/plainfile/task_list_ui_specification.yaml", - "tests/data/plainfile/add_new_task_modal_specification.yaml", + "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: @@ -180,13 +196,36 @@ def test_reference_link_parsing(get_test_data_path): assert plain_sections[plain_spec.FUNCTIONAL_REQUIREMENTS][1]["linked_resources"] == [ { "text": "task_list_ui_specification.yaml", - "target": "tests/data/plainfile/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": "tests/data/plainfile/add_new_task_modal_specification.yaml", + "target": "add_new_task_modal_specification.yaml", } ]