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
4 changes: 4 additions & 0 deletions plain2code.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,11 @@
from plain2code_exceptions import (
ConflictingRequirements,
GitNotInstalledError,
ImportedModuleWithFunctionalitiesError,
InvalidAPIKey,
InvalidFridArgument,
MissingAPIKey,
MissingFunctionalitiesError,
MissingPreviousFunctionalitiesError,
MissingResource,
ModuleDoesNotExistError,
Expand Down Expand Up @@ -65,6 +67,8 @@
MissingResource,
TemplateNotFoundError,
PlainSyntaxError,
ImportedModuleWithFunctionalitiesError,
MissingFunctionalitiesError,
MissingPreviousFunctionalitiesError,
MissingAPIKey,
InvalidAPIKey,
Expand Down
17 changes: 17 additions & 0 deletions plain2code_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
77 changes: 63 additions & 14 deletions plain_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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={},
Expand Down
7 changes: 7 additions & 0 deletions tests/data/imports/import_with_functionalities.plain
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
***implementation reqs***

- :SomeDef: is used in import_with_functionalities.

***functional specs***

- Display "hello, world"
12 changes: 12 additions & 0 deletions tests/data/imports/imports_module_with_functionalities.plain
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
import:
- import_with_functionalities
---

***implementation reqs***

- :MainExecutableFile: of :App: should be called "hello_world.py".

***functional specs***

- Display "hello, world"
4 changes: 4 additions & 0 deletions tests/data/plainfile/empty_functional_specs_section.plain
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
***implementation reqs***
- Some requirement

***functional specs***
5 changes: 5 additions & 0 deletions tests/data/plainfile/no_functional_specs_section.plain
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
***definitions***
- Test definition

***implementation reqs***
- Some requirement
11 changes: 11 additions & 0 deletions tests/test_imports.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down
44 changes: 41 additions & 3 deletions tests/test_plainfileparser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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",
Expand All @@ -187,14 +187,52 @@ 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",
[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

Expand Down
Loading