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
93 changes: 63 additions & 30 deletions plain2code.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,15 +158,43 @@ def _check_connection(codeplainAPI: codeplain_api.CodeplainAPI):
)


def render(args, run_state: RunState, event_bus: EventBus, default_log_level: str = "INFO"): # noqa: C901
template_dirs = file_utils.get_template_directories(args.filename, args.template_dir, DEFAULT_TEMPLATE_DIRS)
def warn_if_acceptance_tests_without_conformance_script(plain_module, args) -> None:
"""Warn when any loaded module (including required modules) defines acceptance tests
but no conformance tests script is configured.

Acceptance tests are treated as conformance tests, so a conformance tests script is required
to actually run them. Without it, the acceptance tests cannot be executed.
"""
if args.conformance_tests_script:
return

module_names_with_acceptance_tests = [
module.module_name
for module in plain_module.all_required_modules + [plain_module]
if plain_spec.has_acceptance_tests(module.plain_source)
]
if not module_names_with_acceptance_tests:
return

module_names = ", ".join(module_names_with_acceptance_tests)
console.warning(
f"Acceptance tests were found ({module_names}) but no conformance tests script is configured. "
"Acceptance tests are treated as conformance tests and require a conformance tests script "
"(--conformance-tests-script or 'conformance_tests_script' in config) to be executed."
)


def render( # noqa: C901
plain_module: plain_modules.PlainModule,
args,
run_state: RunState,
event_bus: EventBus,
default_log_level: str = "INFO",
):
# Compute render range from either --render-range or --render-from
render_range = None
if args.render_range or args.render_from:
# Parse the plain file to get the plain_source for FRID extraction
_, plain_source, _ = plain_file.plain_file_parser(args.filename, template_dirs)
render_range = plain_spec.compute_render_range(args, plain_source)
render_range = plain_spec.compute_render_range(args, plain_module.plain_source)

codeplainAPI = codeplain_api.CodeplainAPI(args.api_key, console)
assert args.api is not None and args.api != "", "API URL is required"
Expand All @@ -178,12 +206,7 @@ def render(args, run_state: RunState, event_bus: EventBus, default_log_level: st
enter_pause_event = threading.Event()
signal.signal(signal.SIGTERM, lambda _signum, _frame: stop_event.set())

plain_module = plain_modules.PlainModule(
args.filename,
args.build_folder,
args.conformance_tests_folder,
template_dirs,
)
warn_if_acceptance_tests_without_conformance_script(plain_module, args)

render_choice = None
if render_range is None:
Expand Down Expand Up @@ -308,28 +331,38 @@ def main(): # noqa: C901
console.error(f"Error fetching status: {str(e)}")
return

# Handle early-exit flags before heavy initialization
if args.dry_run or args.full_plain:
template_dirs = file_utils.get_template_directories(args.filename, args.template_dir, DEFAULT_TEMPLATE_DIRS)
template_dirs = file_utils.get_template_directories(args.filename, args.template_dir, DEFAULT_TEMPLATE_DIRS)

# Handle full plain early-exit (raw text dump; does not require a parsed module).
if args.full_plain:
try:
if args.full_plain:
module_name = Path(args.filename).stem
plain_source = plain_file.read_module_plain_source(module_name, template_dirs)
[full_plain_source, _] = file_utils.get_loaded_templates(template_dirs, plain_source)
console.info("Full plain text:\n")
console.info(full_plain_source)
return

if args.dry_run:
console.info("Printing dry run output...\n")
_, plain_source_tree, _ = plain_file.plain_file_parser(args.filename, template_dirs)
render_range = plain_spec.compute_render_range(args, plain_source_tree)
print_dry_run_output(plain_source_tree, render_range)
return
module_name = Path(args.filename).stem
plain_source = plain_file.read_module_plain_source(module_name, template_dirs)
[full_plain_source, _] = file_utils.get_loaded_templates(template_dirs, plain_source)
console.info("Full plain text:\n")
console.info(full_plain_source)
except Exception as e:
console.error(f"Error: {str(e)}")
return
return

# Parse the plain file (and its required modules) once; reused by dry-run and rendering.
try:
plain_module = plain_modules.PlainModule(
args.filename,
args.build_folder,
args.conformance_tests_folder,
template_dirs,
)
except Exception as e:
console.error(f"Error: {str(e)}")
return

if args.dry_run:
console.info("Printing dry run output...\n")
render_range = plain_spec.compute_render_range(args, plain_module.plain_source)
print_dry_run_output(plain_module.plain_source, render_range)
warn_if_acceptance_tests_without_conformance_script(plain_module, args)
return

event_bus = EventBus()

Expand Down Expand Up @@ -357,7 +390,7 @@ def main(): # noqa: C901
raise MissingAPIKey(
"Your API key is required. Please set the CODEPLAIN_API_KEY environment variable or provide it with the --api-key argument.\n"
)
render(args, run_state, event_bus, default_log_level)
render(plain_module, args, run_state, event_bus, default_log_level)
except BaseException as e:
if isinstance(e, KeyboardInterrupt):
error_message = "Keyboard interrupt"
Expand Down
41 changes: 41 additions & 0 deletions plain_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,26 @@ def _is_acceptance_test_heading(token) -> tuple[bool, str | None]:
return False, None


def _find_acceptance_test_heading(token):
"""
Recursively search a token tree for an `acceptance tests` heading.

Returns the heading token if one is found anywhere in the subtree, otherwise None.
Used to detect acceptance tests nested under sections other than functional specs.
"""
is_acceptance_test_heading, _ = _is_acceptance_test_heading(token)
if is_acceptance_test_heading:
return token

if getattr(token, "children", None):
for child in token.children:
found = _find_acceptance_test_heading(child)
if found is not None:
return found

return None


def _process_single_acceptance_test_requirement(functional_requirement: mistletoe.block_token.ListItem):
"""
Process a single functionality to extract acceptance tests.
Expand Down Expand Up @@ -473,6 +493,11 @@ def parse_plain_source( # noqa: C901

specification_heading = token.children[0].children[0].children[0].content

if specification_heading == plain_spec.ACCEPTANCE_TEST_HEADING:
raise PlainSyntaxError(
f"Plain syntax error: Syntax error at line {token.line_number}: {plain_spec.ACCEPTANCE_TEST_HEADING} heading should be nested under specific functional spec."
)

if specification_heading not in plain_spec.ALLOWED_SPECIFICATION_HEADINGS:
raise PlainSyntaxError(
f"Plain syntax error: Syntax error at line {token.line_number}: Invalid specification heading (`{specification_heading}`). Allowed headings: {', '.join(plain_spec.ALLOWED_SPECIFICATION_HEADINGS)}"
Expand Down Expand Up @@ -511,6 +536,22 @@ def parse_plain_source( # noqa: C901
f"Plain syntax error: Syntax error at line {token.line_number}: Invalid source structure (`{token_text}`)"
)

# Acceptance tests are only allowed nested under functional specs. Reject them when
# they are nested under any other section (e.g. definitions, implementation reqs, test reqs).
for non_functional_heading in (
plain_spec.DEFINITIONS,
plain_spec.NON_FUNCTIONAL_REQUIREMENTS,
plain_spec.TEST_REQUIREMENTS,
):
section = plain_source.get(non_functional_heading)
if section is None:
continue
nested_acceptance_test_heading = _find_acceptance_test_heading(section)
if nested_acceptance_test_heading is not None:
raise PlainSyntaxError(
f"Plain syntax error: Syntax error at line {nested_acceptance_test_heading.line_number}: {plain_spec.ACCEPTANCE_TEST_HEADING} heading should be nested under specific functional spec."
)

if plain_source[plain_spec.DEFINITIONS] is not None:
with PlainRenderer() as renderer:
for token in plain_source[plain_spec.DEFINITIONS].children:
Expand Down
9 changes: 9 additions & 0 deletions plain_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@
ALLOWED_IMPORT_SPECIFICATION_HEADINGS = [DEFINITIONS, NON_FUNCTIONAL_REQUIREMENTS, TEST_REQUIREMENTS]


def has_acceptance_tests(plain_source_tree) -> bool:
"""Return True if any functional requirement in the plain source defines acceptance tests."""
functional_requirements = plain_source_tree.get(FUNCTIONAL_REQUIREMENTS) or []
for functional_requirement in functional_requirements:
if functional_requirement.get(ACCEPTANCE_TESTS):
return True
return False


def collect_specification_linked_resources(specification, specification_heading, linked_resources_list):
linked_resources = []
if "linked_resources" in specification:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
requires:
- required_with_acceptance_tests
---

***definitions***

- :MainApp: is a console application.

***implementation reqs***

- The :MainApp: should print hello.

***functional specs***

- Display "hello, world"
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
***definitions***

- :App: is a console application.

***implementation reqs***

- The :App: should be simple.

***functional specs***

- Implement the entry point for :App:.

***acceptance tests***

- The entry point should run without errors.
94 changes: 94 additions & 0 deletions tests/test_plain2code.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import tempfile
from argparse import Namespace
from types import SimpleNamespace
from unittest.mock import patch

import plain2code
import plain_spec
from plain_modules import PlainModule


def _make_module(module_name, has_acceptance_tests, required_modules=None):
"""Build a minimal stand-in for a PlainModule.

The functional requirements only need an `acceptance_tests` key when the module
is expected to contain acceptance tests, because that is all
`plain_spec.has_acceptance_tests` inspects.
"""
functional_requirement = {"markdown": f"- {module_name} functionality."}
if has_acceptance_tests:
functional_requirement[plain_spec.ACCEPTANCE_TESTS] = [{"markdown": "- Test it."}]

return SimpleNamespace(
module_name=module_name,
plain_source={plain_spec.FUNCTIONAL_REQUIREMENTS: [functional_requirement]},
all_required_modules=required_modules or [],
)


def test_warns_when_acceptance_tests_present_and_no_conformance_script():
plain_module = _make_module("top", has_acceptance_tests=True)
args = Namespace(conformance_tests_script=None)

with patch("plain2code.console") as mock_console:
plain2code.warn_if_acceptance_tests_without_conformance_script(plain_module, args)

mock_console.warning.assert_called_once()
warning_message = mock_console.warning.call_args.args[0]
assert "top" in warning_message
assert "conformance tests script" in warning_message


def test_no_warning_when_conformance_script_configured():
plain_module = _make_module("top", has_acceptance_tests=True)
args = Namespace(conformance_tests_script="run_conformance_tests.sh")

with patch("plain2code.console") as mock_console:
plain2code.warn_if_acceptance_tests_without_conformance_script(plain_module, args)

mock_console.warning.assert_not_called()


def test_no_warning_when_no_acceptance_tests():
plain_module = _make_module("top", has_acceptance_tests=False)
args = Namespace(conformance_tests_script=None)

with patch("plain2code.console") as mock_console:
plain2code.warn_if_acceptance_tests_without_conformance_script(plain_module, args)

mock_console.warning.assert_not_called()


def test_warns_when_acceptance_tests_only_in_required_module():
required_module = _make_module("dependency", has_acceptance_tests=True)
plain_module = _make_module("top", has_acceptance_tests=False, required_modules=[required_module])
args = Namespace(conformance_tests_script=None)

with patch("plain2code.console") as mock_console:
plain2code.warn_if_acceptance_tests_without_conformance_script(plain_module, args)

mock_console.warning.assert_called_once()
warning_message = mock_console.warning.call_args.args[0]
assert "dependency" in warning_message


def test_warning_covers_required_modules_for_real_plain_module(get_test_data_path):
"""Integration test: a main module without acceptance tests that requires a
module with acceptance tests should still trigger the warning, naming the
required module. This mirrors the dry-run path which builds a real PlainModule."""
fixtures_dir = get_test_data_path("data/acceptance_tests_warning")
with tempfile.TemporaryDirectory() as build, tempfile.TemporaryDirectory() as conformance:
plain_module = PlainModule(
"main_requiring_acceptance_tests.plain",
build,
conformance,
[fixtures_dir],
)

args = Namespace(conformance_tests_script=None)
with patch("plain2code.console") as mock_console:
plain2code.warn_if_acceptance_tests_without_conformance_script(plain_module, args)

mock_console.warning.assert_called_once()
warning_message = mock_console.warning.call_args.args[0]
assert "required_with_acceptance_tests" in warning_message
Loading
Loading