Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions file_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}

Expand Down
26 changes: 17 additions & 9 deletions plain_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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}).")
Expand All @@ -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):
Expand Down Expand Up @@ -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)

Expand Down
9 changes: 9 additions & 0 deletions tests/data/plainfile/plain_source_with_missing_link.plain
Original file line number Diff line number Diff line change
@@ -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).
4 changes: 2 additions & 2 deletions tests/data/plainfile/task_manager_with_reference_links.plain
Original file line number Diff line number Diff line change
Expand Up @@ -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:.

Expand Down
47 changes: 43 additions & 4 deletions tests/test_plainfileparser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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",
}
]

Expand Down
Loading