Skip to content
Open
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
22 changes: 15 additions & 7 deletions src/plain_parser/plain_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -448,10 +448,12 @@ def process_imports(

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)
new_concepts, errors = concept_utils.extract_concepts_from_definition(item)
if errors:
raise PlainSyntaxError(
f"Plain syntax error: Module '{module_name}' requires an invalid concept (`{item}`). "
f"Expected a concept token, e.g. :ConceptName:."
)
required_concepts.extend(new_concepts)

required_concepts.extend(plain_file_parse_result.required_concepts)
Expand Down Expand Up @@ -480,11 +482,17 @@ def read_plain_source_metadata(plain_source_text):
except Exception as e:
raise PlainSyntaxError(f"Plain syntax error: Invalid frontmatter: {e}")

for directive in [IMPORT_DIRECTIVE, REQUIRES_DIRECTIVE, EXPORTED_CONCEPTS_DIRECTIVE, REQUIRED_CONCEPTS_DIRECTIVE]:
if directive in plain_source_obj.metadata and not isinstance(plain_source_obj.metadata[directive], list):
plain_source_obj.metadata[directive] = [plain_source_obj.metadata[directive]]

for directive in [IMPORT_DIRECTIVE, REQUIRES_DIRECTIVE]:
for module_name in plain_source_obj.metadata.get(directive, []):
if not isinstance(module_name, str):
raise PlainSyntaxError(f"Plain syntax error: Invalid {directive} metadata. Expected a module name.")

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):
Expand Down
3 changes: 3 additions & 0 deletions tests/data/imports/scalar_import/lib.plain
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
***implementation reqs***

- The main executable file should be called "hello_world.py".
11 changes: 11 additions & 0 deletions tests/data/imports/scalar_import/main.plain
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
import: lib
---

***implementation reqs***

- The main executable file should be called "hello_world.py".

***functional specs***

- Display "hello, world"
3 changes: 3 additions & 0 deletions tests/data/imports/scalar_import_single_char/l.plain
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
***implementation reqs***

- The main executable file should be called "hello_world.py".
11 changes: 11 additions & 0 deletions tests/data/imports/scalar_import_single_char/main.plain
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
import: l
---

***implementation reqs***

- The main executable file should be called "hello_world.py".

***functional specs***

- Display "hello, world"
11 changes: 11 additions & 0 deletions tests/data/requires/scalar_requires_main.plain
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
requires: chain_base
---

***implementation reqs***

- Scalar requires implementation requirement.

***functional specs***

- A scalar requires functionality.
56 changes: 54 additions & 2 deletions tests/test_imports.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import pytest

from plain_parser import plain_file
from plain_parser.exceptions import ImportedModuleWithFunctionalitiesError
from plain_parser import concept_utils, plain_file
from plain_parser.exceptions import ImportedModuleWithFunctionalitiesError, PlainSyntaxError


def test_imported_module_with_functionalities(load_test_data, get_test_data_path):
Expand Down Expand Up @@ -51,3 +51,55 @@ def test_diamond_imports(load_test_data, get_test_data_path):
],
"functional specs": [{"markdown": '- Display "hello, world"'}],
}


@pytest.mark.parametrize("fixture", ["scalar_import", "scalar_import_single_char"])
def test_scalar_import_is_a_list_of_one(load_test_data, get_test_data_path, fixture):
plain_source_text = load_test_data(f"data/imports/{fixture}/main.plain")
template_dirs = [get_test_data_path(f"data/imports/{fixture}")]
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)["implementation reqs"] == [
{"markdown": '- The main executable file should be called "hello_world.py".'},
{"markdown": '- The main executable file should be called "hello_world.py".'},
]


@pytest.mark.parametrize("directive", ["import", "requires"])
def test_non_string_module_entry_is_a_syntax_error(directive):
plain_source_text = f"---\n{directive}: [1]\n---\n\n***functional specs***\n\n- Display text\n"
with pytest.raises(PlainSyntaxError, match=f"Invalid {directive} metadata"):
plain_file.parse_plain_source(plain_source_text, {}, [], [], [])


@pytest.mark.parametrize("directive", ["exported_concepts", "required_concepts"])
def test_scalar_concept_directive_is_a_list_of_one(directive):
plain_source_obj = plain_file.read_plain_source_metadata(f'---\n{directive}: ":A:"\n---\n')

assert plain_source_obj.metadata[directive] == ["- :A:"]


@pytest.mark.parametrize("directive", ["exported_concepts", "required_concepts"])
def test_non_string_concept_entry_is_a_syntax_error(directive):
with pytest.raises(PlainSyntaxError, match=f"Invalid {directive} metadata"):
plain_file.read_plain_source_metadata(f"---\n{directive}: [1]\n---\n")


@pytest.mark.parametrize("value", ["foo", "[foo]"])
def test_imported_required_concept_without_concept_token_is_a_syntax_error(tmp_path, value):
(tmp_path / "lib.plain").write_text(f"---\nrequired_concepts: {value}\n---\n\n***implementation reqs***\n\n- x\n")
plain_source_text = (
"---\nimport: [lib]\n---\n\n***implementation reqs***\n\n- y\n\n***functional specs***\n\n- Display text\n"
)
with pytest.raises(PlainSyntaxError, match="requires an invalid concept"):
plain_file.parse_plain_source(plain_source_text, {}, [str(tmp_path)], [], [])


@pytest.mark.parametrize("directive", ["exported_concepts", "required_concepts"])
@pytest.mark.parametrize("entry, concepts", [(":A:", [":A:"]), (":A:, :B:", [":A:", ":B:"])])
def test_unquoted_concept_in_block_list(directive, entry, concepts):
# YAML reads an unquoted `- :A:` as a mapping; real modules rely on it still naming :A:.
plain_source_obj = plain_file.read_plain_source_metadata(f"---\n{directive}:\n - {entry}\n---\n")

[item] = plain_source_obj.metadata[directive]
assert concept_utils.extract_concepts_from_definition(item) == (concepts, [])
6 changes: 6 additions & 0 deletions tests/test_requires.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ def test_parse_module_chain_orders_required_modules_first(get_test_data_path):
assert "functional specs" in plain_source_tree


def test_scalar_requires_is_a_list_of_one(get_test_data_path):
chain = plain_file.parse_module_chain("scalar_requires_main.plain", [get_test_data_path("data/requires")])

assert [module_name for module_name, _ in chain] == ["chain_base", "scalar_requires_main"]


def test_parse_module_chain_single_module(get_test_data_path):
chain = plain_file.parse_module_chain("chain_base.plain", [get_test_data_path("data/requires")])

Expand Down
Loading