diff --git a/plain2code.py b/plain2code.py index 03271b68..94380c66 100644 --- a/plain2code.py +++ b/plain2code.py @@ -25,9 +25,11 @@ from plain2code_exceptions import ( ConflictingRequirements, GitNotInstalledError, + ImportedModuleWithFunctionalitiesError, InvalidAPIKey, InvalidFridArgument, MissingAPIKey, + MissingFunctionalitiesError, MissingPreviousFunctionalitiesError, MissingResource, ModuleDoesNotExistError, @@ -65,6 +67,8 @@ MissingResource, TemplateNotFoundError, PlainSyntaxError, + ImportedModuleWithFunctionalitiesError, + MissingFunctionalitiesError, MissingPreviousFunctionalitiesError, MissingAPIKey, InvalidAPIKey, diff --git a/plain2code_exceptions.py b/plain2code_exceptions.py index 8425a573..b29bea98 100644 --- a/plain2code_exceptions.py +++ b/plain2code_exceptions.py @@ -78,6 +78,23 @@ 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/plain_file.py b/plain_file.py index 2e6f1761..997c97cd 100644 --- a/plain_file.py +++ b/plain_file.py @@ -17,7 +17,13 @@ import concept_utils import file_utils import plain_spec -from plain2code_exceptions import ModuleDoesNotExistError, PlainSyntaxError, UnsupportedBase64Content +from plain2code_exceptions import ( + ImportedModuleWithFunctionalitiesError, + MissingFunctionalitiesError, + ModuleDoesNotExistError, + PlainSyntaxError, + UnsupportedBase64Content, +) from plain2code_nodes import Plain2CodeIncludeTag, Plain2CodeLoaderMixin from plain2code_utils import find_large_base64_blob @@ -151,17 +157,41 @@ def process_code_variables(plain_source, code_variables): process_section_code_variables(requirement, code_variables) -def check_if_functional_requirements_are_specified(plain_source, non_functional_requirements): - if plain_source[plain_spec.NON_FUNCTIONAL_REQUIREMENTS] is not None and hasattr( - plain_source[plain_spec.NON_FUNCTIONAL_REQUIREMENTS], "children" - ): - non_functional_requirements.extend(plain_source[plain_spec.NON_FUNCTIONAL_REQUIREMENTS].children) +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 + - found_functional_requirements = plain_spec.FUNCTIONAL_REQUIREMENTS in plain_source - if found_functional_requirements and len(non_functional_requirements) == 0: - raise PlainSyntaxError("Plain syntax error: functionality with no implementation reqs specified.") +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. - return found_functional_requirements + ``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]: @@ -385,8 +415,12 @@ def process_imports( module_name, code_variables, template_dirs, imported_modules, modules_trace ) - if check_if_functional_requirements_are_specified(plain_file_parse_result.plain_source, []): - raise PlainSyntaxError("Plain syntax error: Imported module must not contain functionalities.") + 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: @@ -726,11 +760,26 @@ def plain_file_parser( # noqa: C901 f"Plain syntax error: Not all required concepts were defined. {missing_required_concepts_msg}." ) - if not check_if_functional_requirements_are_specified(plain_file_parse_result.plain_source, []): + 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}' was required but does not contain functional requirements." + 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={}, diff --git a/tests/data/imports/import_with_functionalities.plain b/tests/data/imports/import_with_functionalities.plain new file mode 100644 index 00000000..577041dd --- /dev/null +++ b/tests/data/imports/import_with_functionalities.plain @@ -0,0 +1,7 @@ +***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 new file mode 100644 index 00000000..f2ff5173 --- /dev/null +++ b/tests/data/imports/imports_module_with_functionalities.plain @@ -0,0 +1,12 @@ +--- +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/plainfile/empty_functional_specs_section.plain b/tests/data/plainfile/empty_functional_specs_section.plain new file mode 100644 index 00000000..a4a74de5 --- /dev/null +++ b/tests/data/plainfile/empty_functional_specs_section.plain @@ -0,0 +1,4 @@ +***implementation reqs*** +- Some requirement + +***functional specs*** diff --git a/tests/data/plainfile/no_functional_specs_section.plain b/tests/data/plainfile/no_functional_specs_section.plain new file mode 100644 index 00000000..be2ef7a7 --- /dev/null +++ b/tests/data/plainfile/no_functional_specs_section.plain @@ -0,0 +1,5 @@ +***definitions*** +- Test definition + +***implementation reqs*** +- Some requirement diff --git a/tests/test_imports.py b/tests/test_imports.py index 7ad80ac1..90e8f0c3 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -1,6 +1,17 @@ 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): diff --git a/tests/test_plainfileparser.py b/tests/test_plainfileparser.py index c42e20d1..9ebdee6b 100644 --- a/tests/test_plainfileparser.py +++ b/tests/test_plainfileparser.py @@ -9,7 +9,7 @@ import file_utils import plain_file import plain_spec -from plain2code_exceptions import PlainSyntaxError +from plain2code_exceptions import MissingFunctionalitiesError, PlainSyntaxError def test_regular_plain_source(get_test_data_path): @@ -176,7 +176,7 @@ def test_duplicate_specification_heading(get_test_data_path): def test_missing_non_functional_requirements(get_test_data_path): with pytest.raises( Exception, - match="Plain syntax error: functionality with no implementation reqs specified.", + match="defines functionalities but specifies no implementation reqs", ): plain_file.plain_file_parser( "missing_non_functional_requirements.plain", @@ -187,7 +187,7 @@ def test_missing_non_functional_requirements(get_test_data_path): def test_without_non_functional_requirement(get_test_data_path): with pytest.raises( Exception, - match="Plain syntax error: functionality with no implementation reqs specified.", + match="defines functionalities but specifies no implementation reqs", ): plain_file.plain_file_parser( "without_non_functional_requirement.plain", @@ -195,6 +195,44 @@ def test_without_non_functional_requirement(get_test_data_path): ) +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