├── resources ├── plain_example.png └── codeplain_overview.png ├── requirements.txt ├── examples ├── example_hello_world_react │ ├── config.yaml │ ├── hello_world_react.plain │ ├── harness_tests │ │ └── hello_world_display │ │ │ ├── cypress │ │ │ ├── e2e │ │ │ │ └── hello_world.cy.ts │ │ │ └── support │ │ │ │ └── e2e.ts │ │ │ ├── tsconfig.json │ │ │ ├── cypress.config.ts │ │ │ └── package.json │ └── run.sh ├── example_hello_world_golang │ ├── config.yaml │ ├── hello_world_golang.plain │ ├── harness_tests │ │ └── hello_world_test.go │ └── run.sh ├── example_hello_world_python │ ├── config.yaml │ ├── hello_world_python.plain │ ├── harness_tests │ │ └── hello_world_display │ │ │ └── test_hello_world.py │ └── run.sh └── run.sh ├── .gitignore ├── standard_template_library ├── typescript-react-app-template.plain ├── golang-console-app-template.plain └── python-console-app-template.plain ├── docs ├── generate_cli.py ├── starting_a_plain_project_from_scratch.md ├── plain2code_cli.md └── plain_language_specification.md ├── render_machine ├── actions │ ├── base_action.py │ ├── commit_implementation_code_changes.py │ ├── exit_with_error.py │ ├── create_dist.py │ ├── prepare_testing_environment.py │ ├── run_unit_tests.py │ ├── summarize_conformance_tests.py │ ├── run_conformance_tests.py │ ├── refactor_code.py │ ├── prepare_repositories.py │ ├── commit_conformance_tests_changes.py │ ├── fix_unit_tests.py │ ├── analyze_specification_ambiguity.py │ ├── render_functional_requirement.py │ ├── fix_conformance_test.py │ └── render_conformance_tests.py ├── implementation_code_helpers.py ├── render_types.py ├── triggers.py ├── states.py ├── code_renderer.py ├── conformance_test_helpers.py ├── render_utils.py └── render_context.py ├── plain2code_exceptions.py ├── hash_key.py ├── LICENSE ├── system_config.yaml ├── test_scripts ├── run_unittests_golang.sh ├── run_unittests_python.sh ├── run_conformance_tests_python.sh ├── run_unittests_react.sh ├── run_conformance_tests_golang.sh └── run_conformance_tests_cypress.sh ├── system_config.py ├── plain2code_utils.py ├── plain2code_read_config.py ├── plain2code_state.py ├── README.md ├── plain2code_console.py ├── plain2code_nodes.py ├── plain2code.py ├── git_utils.py ├── plain2code_arguments.py ├── file_utils.py └── plain_spec.py /resources/plain_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Codeplain-ai/plain2code_client/HEAD/resources/plain_example.png -------------------------------------------------------------------------------- /resources/codeplain_overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Codeplain-ai/plain2code_client/HEAD/resources/codeplain_overview.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | python-liquid2==0.3.0 2 | mistletoe==1.3.0 3 | requests==2.32.3 4 | rich==13.9.4 5 | tiktoken==0.9.0 6 | PyYAML==6.0.2 7 | gitpython==3.1.42 8 | transitions==0.9.3 -------------------------------------------------------------------------------- /examples/example_hello_world_react/config.yaml: -------------------------------------------------------------------------------- 1 | conformance-tests-script: ../../test_scripts/run_conformance_tests_cypress.sh 2 | verbose: true # verbose flag, defaults to false if not set 3 | -------------------------------------------------------------------------------- /examples/example_hello_world_golang/config.yaml: -------------------------------------------------------------------------------- 1 | verbose: true 2 | unittests-script: ../../test_scripts/run_unittests_golang.sh 3 | conformance-tests-script: ../../test_scripts/run_conformance_tests_golang.sh -------------------------------------------------------------------------------- /examples/example_hello_world_react/hello_world_react.plain: -------------------------------------------------------------------------------- 1 | {% include "typescript-react-app-template.plain" %} 2 | 3 | # "hello, world" in Plain (React version) 4 | 5 | ***Functional Requirements:*** 6 | 7 | - Display "hello, world" 8 | -------------------------------------------------------------------------------- /examples/example_hello_world_python/config.yaml: -------------------------------------------------------------------------------- 1 | unittests-script: ../../test_scripts/run_unittests_python.sh 2 | conformance-tests-script: ../../test_scripts/run_conformance_tests_python.sh 3 | verbose: true # verbose flag, defaults to false if not set -------------------------------------------------------------------------------- /examples/example_hello_world_react/harness_tests/hello_world_display/cypress/e2e/hello_world.cy.ts: -------------------------------------------------------------------------------- 1 | describe('Hello World App', () => { 2 | it('displays "hello, world"', () => { 3 | cy.visit('/'); 4 | cy.contains('div', 'hello, world').should('be.visible'); 5 | }); 6 | }); -------------------------------------------------------------------------------- /examples/example_hello_world_golang/hello_world_golang.plain: -------------------------------------------------------------------------------- 1 | {% include "golang-console-app-template.plain", main_executable_file_name: "hello_world.go" %} 2 | 3 | # "hello, world" in Plain (Go lang version) 4 | 5 | ***Functional Requirements:*** 6 | 7 | - Display "hello, world" 8 | -------------------------------------------------------------------------------- /examples/example_hello_world_react/harness_tests/hello_world_display/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["es5", "dom"], 5 | "types": ["cypress", "node"], 6 | "strict": true, 7 | "noEmit": true 8 | }, 9 | "include": ["**/*.ts"] 10 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .DS_Store 3 | 4 | # Plain 5 | examples/**/build*/ 6 | examples/**/conformance_tests/ 7 | examples/**/conformance_tests.backup/ 8 | examples/**/node_build/ 9 | examples/**/node_conformance_tests/ 10 | examples/**/package-lock.json 11 | examples/**/node_modules/ 12 | examples/**/node_harness_tests/ 13 | 14 | examples/**/go_build/ 15 | examples/**/python_build/ 16 | 17 | .venv 18 | -------------------------------------------------------------------------------- /examples/example_hello_world_react/harness_tests/hello_world_display/cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "cypress"; 2 | 3 | export default defineConfig({ 4 | e2e: { 5 | setupNodeEvents(on, config) { 6 | // implement node event listeners here 7 | }, 8 | baseUrl: "http://localhost:3000", 9 | supportFile: false, 10 | specPattern: "cypress/e2e/**/*.cy.ts", 11 | }, 12 | }); -------------------------------------------------------------------------------- /examples/example_hello_world_react/harness_tests/hello_world_display/cypress/support/e2e.ts: -------------------------------------------------------------------------------- 1 | // This file is processed and loaded automatically before your test files. 2 | // This is a great place to put global configuration and behavior that modifies Cypress. 3 | 4 | // Import commands.js using ES2015 syntax: 5 | // import './commands' 6 | 7 | // Alternatively you can use CommonJS syntax: 8 | // require('./commands') -------------------------------------------------------------------------------- /examples/example_hello_world_python/hello_world_python.plain: -------------------------------------------------------------------------------- 1 | {% include "python-console-app-template.plain", main_executable_file_name: "hello_world.py" %} 2 | 3 | # "hello, world" in Plain (Python version) 4 | 5 | ***Functional Requirements:*** 6 | 7 | - Display "hello, world" 8 | 9 | ***Acceptance Tests:*** 10 | 11 | - :App: should exit with status code 0 indicating successful execution. 12 | 13 | - :App: should complete execution in under 1 second. 14 | -------------------------------------------------------------------------------- /standard_template_library/typescript-react-app-template.plain: -------------------------------------------------------------------------------- 1 | ***Definitions:*** 2 | 3 | - :App: is a web application. 4 | 5 | 6 | ***Non-Functional Requirements:*** 7 | 8 | - :App: should be implemented in TypeScript, using React as a web framework. 9 | 10 | - :App: will run on Node.js as the runtime environment. 11 | 12 | 13 | ***Test Requirements:*** 14 | 15 | - :ConformanceTests: of :App: should be written in TypeScript, using Cypress as the framework for :ConformanceTests:. 16 | 17 | 18 | # The React boilerplate 19 | 20 | ***Functional Requirements:*** 21 | 22 | - Implement the entry point for :App:. -------------------------------------------------------------------------------- /examples/example_hello_world_python/harness_tests/hello_world_display/test_hello_world.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import subprocess 4 | import unittest 5 | 6 | 7 | class TestHelloWorld(unittest.TestCase): 8 | def test_hello_world_output(self): 9 | # Run the hello_world.py script and capture its output 10 | result = subprocess.run(["python3", "hello_world.py"], capture_output=True, text=True) 11 | 12 | # Check if the output matches the expected string 13 | self.assertEqual(result.stdout.strip(), "hello, world") 14 | 15 | 16 | if __name__ == "__main__": 17 | unittest.main() 18 | -------------------------------------------------------------------------------- /docs/generate_cli.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | 4 | # Add the parent directory to the path so we can import plain2code_arguments 5 | sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 6 | 7 | from plain2code_arguments import create_parser 8 | 9 | # Get the parser and generate help text 10 | parser = create_parser() 11 | help_text = parser.format_help() 12 | 13 | # Create markdown 14 | md = "# Plain2Code CLI Reference\n\n```text\n" + help_text + "\n```" 15 | 16 | # Run generate_cli.py in the docs folder 17 | 18 | with open("plain2code_cli.md", "w", encoding="utf-8") as f: 19 | f.write(md) 20 | -------------------------------------------------------------------------------- /render_machine/actions/base_action.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from typing import Any 3 | 4 | from render_machine.render_context import RenderContext 5 | 6 | 7 | class BaseAction: 8 | def __init__(self): 9 | pass 10 | 11 | @abstractmethod 12 | def execute(self, _render_context: RenderContext, _previous_action_payload: Any | None): 13 | """ 14 | Execute the action with the given render context and optional previous action payload. 15 | 16 | Returns: 17 | tuple: (outcome, payload) where outcome is a string and payload can be any object or None 18 | """ 19 | pass 20 | -------------------------------------------------------------------------------- /examples/example_hello_world_golang/harness_tests/hello_world_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | func TestHelloWorld(t *testing.T) { 12 | cwd, err := os.Getwd() 13 | if err != nil { 14 | fmt.Printf("Error getting current working directory: %v\n", err) 15 | } else { 16 | fmt.Printf("Command will be executed in the current working directory: %s\n", cwd) 17 | } 18 | 19 | cmd := exec.Command("go", "run", "hello_world.go") 20 | output, err := cmd.CombinedOutput() 21 | if err != nil { 22 | t.Fatalf("Failed to run hello_world.go: %v", err) 23 | } 24 | 25 | expected := "hello, world" 26 | actual := strings.TrimSpace(string(output)) 27 | if actual != expected { 28 | t.Errorf("Expected output '%s', but got '%s'", expected, actual) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /examples/example_hello_world_python/run.sh: -------------------------------------------------------------------------------- 1 | CONFIG_FILE="config.yaml" 2 | VERBOSE=0 3 | 4 | # Check if verbose is set in config.yaml and set VERBOSE accordingly 5 | if grep -q "v: true" "$CONFIG_FILE" 2>/dev/null; then 6 | VERBOSE=1 7 | fi 8 | 9 | if [ $VERBOSE -eq 1 ]; then 10 | echo "Running the hello world example for Python in verbose mode." 11 | fi 12 | 13 | # Execute the command 14 | python ../../plain2code.py hello_world_python.plain 15 | 16 | # Check if the plain2code command failed 17 | if [ $? -ne 0 ]; then 18 | echo "Error: The plain2code command failed." 19 | exit 1 20 | fi 21 | 22 | cd build 23 | 24 | python ../harness_tests/hello_world_display/test_hello_world.py 25 | 26 | # Check if the test harness has failed for the hello world example 27 | if [ $? -ne 0 ]; then 28 | echo "Error: The test harness has failed for the hello world example." 29 | exit 1 30 | fi 31 | 32 | cd .. 33 | -------------------------------------------------------------------------------- /examples/run.sh: -------------------------------------------------------------------------------- 1 | echo "Running all example test harnesses..." 2 | 3 | ( 4 | printf "\nHELLO, WORLD (Python example)...\n\n" 5 | 6 | cd example_hello_world_python 7 | sh run.sh 8 | if [ $? -ne 0 ]; then 9 | echo "Hello World Python example failed." 10 | exit 1 11 | fi 12 | cd .. 13 | ) 14 | 15 | ( 16 | printf "\nHELLO, WORLD (Go lang example)...\n\n" 17 | 18 | cd example_hello_world_golang 19 | sh run.sh 20 | if [ $? -ne 0 ]; then 21 | echo "Hello World Golang example failed." 22 | exit 1 23 | fi 24 | cd .. 25 | ) 26 | 27 | ( 28 | printf "\nHELLO, WORLD (React example)...\n\n" 29 | 30 | cd example_hello_world_react 31 | sh run.sh 32 | if [ $? -ne 0 ]; then 33 | echo "Hello World React example failed." 34 | exit 1 35 | fi 36 | cd .. 37 | ) 38 | 39 | echo "All example test harnesses completed successfully!" -------------------------------------------------------------------------------- /examples/example_hello_world_react/run.sh: -------------------------------------------------------------------------------- 1 | VERBOSE=0 2 | CONFIG_FILE="config.yaml" 3 | 4 | # Check if verbose is set in config.yaml and set VERBOSE accordingly 5 | if grep -q "verbose: true" "$CONFIG_FILE" 2>/dev/null; then 6 | VERBOSE=1 7 | fi 8 | 9 | 10 | if [ $VERBOSE -eq 1 ]; then 11 | echo "Running the hello world example for React in verbose mode." 12 | fi 13 | 14 | # Execute the command 15 | python ../../plain2code.py hello_world_react.plain 16 | 17 | # Check if the plain2code command failed 18 | if [ $? -ne 0 ]; then 19 | echo "Error: The plain2code command failed." 20 | exit 1 21 | fi 22 | 23 | ../../test_scripts/run_conformance_tests_cypress.sh build harness_tests/hello_world_display ${VERBOSE:+-v} 24 | 25 | # Check if the test harness has failed for the hello world example 26 | if [ $? -ne 0 ]; then 27 | echo "Error: The test harness has failed for the hello world example." 28 | exit 1 29 | fi 30 | -------------------------------------------------------------------------------- /render_machine/actions/commit_implementation_code_changes.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import git_utils 4 | from render_machine.actions.base_action import BaseAction 5 | from render_machine.render_context import RenderContext 6 | 7 | 8 | class CommitImplementationCodeChanges(BaseAction): 9 | SUCCESSFUL_OUTCOME = "implementation_code_changes_committed" 10 | 11 | def __init__(self, base_commit_message: str): 12 | self.base_commit_message = base_commit_message 13 | 14 | def execute(self, render_context: RenderContext, _previous_action_payload: Any | None): 15 | git_utils.add_all_files_and_commit( 16 | render_context.args.build_folder, 17 | self.base_commit_message.format(render_context.frid_context.frid), 18 | render_context.frid_context.frid, 19 | render_context.run_state.render_id, 20 | ) 21 | 22 | return self.SUCCESSFUL_OUTCOME, None 23 | -------------------------------------------------------------------------------- /render_machine/implementation_code_helpers.py: -------------------------------------------------------------------------------- 1 | import file_utils 2 | import git_utils 3 | import plain_spec 4 | from render_machine.render_context import RenderContext 5 | 6 | 7 | class ImplementationCodeHelpers: 8 | @staticmethod 9 | def fetch_existing_files(render_context: RenderContext): 10 | existing_files = file_utils.list_all_text_files(render_context.args.build_folder) 11 | existing_files_content = file_utils.get_existing_files_content(render_context.args.build_folder, existing_files) 12 | return existing_files, existing_files_content 13 | 14 | @staticmethod 15 | def get_code_diff(render_context: RenderContext): 16 | previous_frid_code_diff = git_utils.diff( 17 | render_context.args.build_folder, 18 | plain_spec.get_previous_frid(render_context.plain_source_tree, render_context.frid_context.frid), 19 | ) 20 | return previous_frid_code_diff 21 | -------------------------------------------------------------------------------- /plain2code_exceptions.py: -------------------------------------------------------------------------------- 1 | class FunctionalRequirementTooComplex(Exception): 2 | def __init__(self, message, proposed_breakdown=None): 3 | self.message = message 4 | self.proposed_breakdown = proposed_breakdown 5 | super().__init__(self.message) 6 | 7 | 8 | class ConflictingRequirements(Exception): 9 | pass 10 | 11 | 12 | class CreditBalanceTooLow(Exception): 13 | pass 14 | 15 | 16 | class LLMInternalError(Exception): 17 | pass 18 | 19 | 20 | class MissingResource(Exception): 21 | pass 22 | 23 | 24 | class PlainSyntaxError(Exception): 25 | pass 26 | 27 | 28 | class OnlyRelativeLinksAllowed(Exception): 29 | pass 30 | 31 | 32 | class LinkMustHaveTextSpecified(Exception): 33 | pass 34 | 35 | 36 | class NoRenderFound(Exception): 37 | pass 38 | 39 | 40 | class MultipleRendersFound(Exception): 41 | pass 42 | 43 | 44 | class UnexpectedState(Exception): 45 | pass 46 | -------------------------------------------------------------------------------- /hash_key.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import sys 3 | 4 | 5 | def hash_api_key(api_key): 6 | """Hash the provided API key using SHA-256 and return the hash as a hex string.""" 7 | try: 8 | hash_object = hashlib.sha256(api_key.encode()) 9 | hex_dig = hash_object.hexdigest() 10 | return hex_dig 11 | except Exception as e: 12 | error_message = f"An error occurred while hashing the API key: {str(e)}" 13 | raise Exception(error_message) 14 | 15 | 16 | if __name__ == "__main__": 17 | if len(sys.argv) != 2: 18 | print("Error: Exactly one argument must be provided for the API key.") 19 | print(f"Usage: python {sys.argv[0]} ") 20 | sys.exit(1) 21 | 22 | api_key = sys.argv[1] 23 | 24 | try: 25 | hashed_key = hash_api_key(api_key) 26 | print(f"Hashed API Key: {hashed_key}") 27 | except Exception as e: 28 | print(f"Error: {str(e)}") 29 | sys.exit(1) 30 | -------------------------------------------------------------------------------- /render_machine/actions/exit_with_error.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from plain2code_console import console 4 | from render_machine.actions.base_action import BaseAction 5 | from render_machine.render_context import RenderContext 6 | 7 | 8 | class ExitWithError(BaseAction): 9 | SUCCESSFUL_OUTCOME = "error_handled" 10 | 11 | def execute(self, render_context: RenderContext, previous_action_payload: Any | None): 12 | console.error(previous_action_payload) 13 | 14 | if render_context.frid_context is not None: 15 | console.info( 16 | f"To continue rendering from the last successfully rendered functional requirement, provide the [red][b]--render-from {render_context.frid_context.frid}[/b][/red] flag." 17 | ) 18 | 19 | if render_context.run_state.render_id is not None: 20 | console.info(f"Render ID: {render_context.run_state.render_id}") 21 | 22 | return self.SUCCESSFUL_OUTCOME, None 23 | -------------------------------------------------------------------------------- /examples/example_hello_world_golang/run.sh: -------------------------------------------------------------------------------- 1 | # Store command line arguments 2 | VERBOSE=0 3 | CONFIG_FILE="config.yaml" 4 | 5 | # Check if verbose is set in config.yaml and set VERBOSE accordingly 6 | if grep -q "verbose: true" "$CONFIG_FILE" 2>/dev/null; then 7 | VERBOSE=1 8 | fi 9 | 10 | if [ $VERBOSE -eq 1 ]; then 11 | echo "Running Go lang hello world example in verbose mode." 12 | fi 13 | 14 | # Execute the command 15 | python ../../plain2code.py hello_world_golang.plain 16 | 17 | # Check if the plain2code command failed 18 | if [ $? -ne 0 ]; then 19 | echo "Error: The plain2code command failed." 20 | exit 1 21 | fi 22 | 23 | cd build 24 | 25 | # We need to compile the tests so that we can execute them in the current folder 26 | # (https://stackoverflow.com/questions/23847003/golang-tests-and-working-directory/29541248#29541248) 27 | go test -c ../harness_tests/hello_world_test.go 28 | 29 | # Check if test compilation has failed for the hello world example 30 | if [ $? -ne 0 ]; then 31 | echo "Error: The test harness has failed for the hello world example." 32 | exit 1 33 | fi 34 | 35 | ./main.test -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Codeplain.ai 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /render_machine/actions/create_dist.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import file_utils 4 | from plain2code_console import console 5 | from render_machine.actions.base_action import BaseAction 6 | from render_machine.render_context import RenderContext 7 | 8 | 9 | class CreateDist(BaseAction): 10 | SUCCESSFUL_OUTCOME = "dist_created" 11 | 12 | def execute(self, render_context: RenderContext, _previous_action_payload: Any | None): 13 | # Copy build and conformance tests folders to output folders if specified 14 | if render_context.args.copy_build: 15 | file_utils.copy_folder_to_output( 16 | render_context.args.build_folder, 17 | render_context.args.build_dest, 18 | ) 19 | if render_context.args.copy_conformance_tests: 20 | file_utils.copy_folder_to_output( 21 | render_context.args.conformance_tests_folder, 22 | render_context.args.conformance_tests_dest, 23 | ) 24 | console.info(f"Render {render_context.run_state.render_id} completed successfully.") 25 | 26 | return self.SUCCESSFUL_OUTCOME, None 27 | -------------------------------------------------------------------------------- /examples/example_hello_world_react/harness_tests/hello_world_display/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hello-world-app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@types/node": "^16.18.0", 7 | "@types/react": "^18.0.0", 8 | "@types/react-dom": "^18.0.0", 9 | "react": "^18.2.0", 10 | "react-dom": "^18.2.0", 11 | "react-scripts": "5.0.1", 12 | "typescript": "^4.9.5" 13 | }, 14 | "scripts": { 15 | "start": "react-scripts start", 16 | "build": "react-scripts build", 17 | "test": "react-scripts test", 18 | "eject": "react-scripts eject", 19 | "cypress:open": "cypress open", 20 | "cypress:run": "cypress run" 21 | }, 22 | "eslintConfig": { 23 | "extends": [ 24 | "react-app", 25 | "react-app/jest" 26 | ] 27 | }, 28 | "browserslist": { 29 | "production": [ 30 | ">0.2%", 31 | "not dead", 32 | "not op_mini all" 33 | ], 34 | "development": [ 35 | "last 1 chrome version", 36 | "last 1 firefox version", 37 | "last 1 safari version" 38 | ] 39 | }, 40 | "devDependencies": { 41 | "@types/cypress": "^1.1.3", 42 | "cypress": "^12.17.4" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /system_config.yaml: -------------------------------------------------------------------------------- 1 | system_requirements: 2 | timeout: 3 | command: timeout 4 | error_message: | 5 | Error: Required system command 'timeout' is not available. 6 | This command is needed to enforce time limits on test execution. 7 | 8 | To install the timeout command: 9 | - On Linux: Install coreutils package 10 | - Debian/Ubuntu: sudo apt-get install coreutils 11 | - CentOS/RHEL: sudo yum install coreutils 12 | - Fedora: sudo dnf install coreutils 13 | - On macOS: 14 | - Using Homebrew: brew install coreutils 15 | - Using MacPorts: port install coreutils 16 | - On Windows: Use Windows Subsystem for Linux (WSL), Cygwin, or Git Bash 17 | 18 | error_messages: 19 | template_not_found: 20 | message: | 21 | The required template could not be found. Templates are searched in the following order (highest to lowest precedence): 22 | 23 | 1. The directory containing your .plain file 24 | 2. The directory specified by --template-dir (if provided) 25 | 3. The built-in 'standard_template_library' directory 26 | 27 | Please ensure that the missing template exists in one of these locations, or specify the correct --template-dir if using custom templates. 28 | -------------------------------------------------------------------------------- /render_machine/render_types.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import Optional 3 | 4 | 5 | @dataclass 6 | class FridContext: 7 | frid: str 8 | specifications: dict 9 | functional_requirement_text: str 10 | linked_resources: dict 11 | functional_requirement_render_attempts: int = 0 12 | changed_files: set[str] = field(default_factory=set) 13 | refactoring_iteration: int = 0 14 | 15 | 16 | @dataclass 17 | class UnitTestsRunningContext: 18 | fix_attempts: int 19 | changed_files: set[str] = field(default_factory=set) 20 | 21 | 22 | @dataclass 23 | class ConformanceTestsRunningContext: 24 | current_testing_frid: Optional[str] 25 | fix_attempts: int 26 | conformance_tests_json: dict 27 | conformance_tests_render_attempts: int 28 | current_testing_frid_specifications: Optional[dict[str, list]] 29 | conformance_test_phase_index: int # 0 => conformance tests, 1 or more => acceptance tests 30 | regenerating_conformance_tests: bool = False 31 | 32 | # will be propagated only when: 33 | # - current_testing_frid == frid noqa: E800 34 | # - conformance_test_phase_index == 0 (conformance tests phase) 35 | current_testing_frid_high_level_implementation_plan: Optional[str] = None 36 | should_prepare_testing_environment: bool = False 37 | -------------------------------------------------------------------------------- /test_scripts/run_unittests_golang.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | UNRECOVERABLE_ERROR_EXIT_CODE=69 4 | 5 | # Check if subfolder name is provided 6 | if [ -z "$1" ]; then 7 | echo "Error: No subfolder name provided." 8 | echo "Usage: $0 " 9 | exit $UNRECOVERABLE_ERROR_EXIT_CODE 10 | fi 11 | 12 | GO_BUILD_SUBFOLDER=go_$1 13 | 14 | if [ "${VERBOSE:-}" -eq 1 ] 2>/dev/null; then 15 | printf "Preparing Go build subfolder: $GO_BUILD_SUBFOLDER\n" 16 | fi 17 | 18 | # Check if the go build subfolder exists 19 | if [ -d "$GO_BUILD_SUBFOLDER" ]; then 20 | # Find and delete all files and folders 21 | find "$GO_BUILD_SUBFOLDER" -mindepth 1 -exec rm -rf {} + 22 | 23 | if [ "${VERBOSE:-}" -eq 1 ] 2>/dev/null; then 24 | printf "Cleanup completed.\n" 25 | fi 26 | else 27 | if [ "${VERBOSE:-}" -eq 1 ] 2>/dev/null; then 28 | printf "Subfolder does not exist. Creating it...\n" 29 | fi 30 | 31 | mkdir $GO_BUILD_SUBFOLDER 32 | fi 33 | 34 | cp -R $1/* $GO_BUILD_SUBFOLDER 35 | 36 | # Move to the subfolder 37 | cd "$GO_BUILD_SUBFOLDER" 2>/dev/null 38 | 39 | if [ $? -ne 0 ]; then 40 | printf "Error: Go build folder '$GO_BUILD_SUBFOLDER' does not exist.\n" 41 | exit $UNRECOVERABLE_ERROR_EXIT_CODE 42 | fi 43 | 44 | echo "Running go get..." 45 | go get 46 | 47 | # Execute all Golang unittests in the subfolder 48 | echo "Running Golang unittests in $1..." 49 | go test 50 | -------------------------------------------------------------------------------- /render_machine/actions/prepare_testing_environment.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import render_machine.render_utils as render_utils 4 | from plain2code_console import console 5 | from render_machine.actions.base_action import BaseAction 6 | from render_machine.render_context import RenderContext 7 | 8 | 9 | class PrepareTestingEnvironment(BaseAction): 10 | SUCCESSFUL_OUTCOME = "testing_environment_prepared" 11 | FAILED_OUTCOME = "testing_environment_preparation_failed" 12 | 13 | def execute(self, render_context: RenderContext, _previous_action_payload: Any | None): 14 | if render_context.args.verbose: 15 | console.info( 16 | f"[b]Running testing environment preparation script {render_context.args.prepare_environment_script} for build folder {render_context.args.build_folder}.[/b]" 17 | ) 18 | exit_code, _ = render_utils.execute_script( 19 | render_context.args.prepare_environment_script, 20 | [render_context.args.build_folder], 21 | render_context.args.verbose, 22 | "Testing Environment Preparation", 23 | ) 24 | 25 | render_context.conformance_tests_running_context.should_prepare_testing_environment = False 26 | 27 | if exit_code == 0: 28 | return self.SUCCESSFUL_OUTCOME, None 29 | else: 30 | return self.FAILED_OUTCOME, None 31 | -------------------------------------------------------------------------------- /render_machine/triggers.py: -------------------------------------------------------------------------------- 1 | """ 2 | State machine trigger constants for the render machine. 3 | 4 | These triggers are used to transition between states in the hierarchical state machine 5 | that controls the code rendering process. 6 | """ 7 | 8 | # Trigger constants for state machine transitions 9 | START_RENDER = "start_render" 10 | RENDER_FUNCTIONAL_REQUIREMENT = "render_functional_requirement" # todo: rename 11 | PROCEED_FRID_PROCESSING = "proceed_frid_processing" 12 | MARK_UNIT_TESTS_FAILED = "mark_unit_tests_failed" 13 | MARK_UNIT_TESTS_PASSED = "mark_unit_tests_passed" 14 | MARK_UNIT_TESTS_READY = "mark_unit_tests_ready" 15 | PREPARE_FINAL_OUTPUT = "prepare_final_output" 16 | FINISH_RENDER = "finish_render" 17 | HANDLE_ERROR = "handle_error" 18 | REFACTOR_CODE = "refactor_code" 19 | RESTART_FRID_PROCESSING = "restart_frid_processing" 20 | START_NEW_REFACTORING_ITERATION = "start_new_refactoring_iteration" 21 | MARK_CONFORMANCE_TESTS_READY = "mark_conformance_tests_ready" 22 | MARK_TESTING_ENVIRONMENT_PREPARED = "mark_testing_environment_prepared" 23 | MARK_CONFORMANCE_TESTS_FAILED = "mark_conformance_tests_failed" 24 | MOVE_TO_NEXT_CONFORMANCE_TEST = "move_to_next_conformance_test" 25 | MARK_ALL_CONFORMANCE_TESTS_PASSED = "mark_all_conformance_tests_passed" 26 | MARK_REGENERATION_OF_CONFORMANCE_TESTS = "mark_regeneration_of_conformance_tests" 27 | MARK_NEXT_CONFORMANCE_TESTS_POSTPROCESSING_STEP = "mark_next_conformance_tests_postprocessing_step" 28 | -------------------------------------------------------------------------------- /standard_template_library/golang-console-app-template.plain: -------------------------------------------------------------------------------- 1 | ***Definitions:*** 2 | 3 | - :App: is a console application. 4 | 5 | 6 | ***Non-Functional Requirements:*** 7 | 8 | - :Implementation: should be in Go lang. 9 | 10 | - :Implementation: should include unit tests using Go's built-in testing package. 11 | 12 | - The main executable code file of :App: should be called "{{ main_executable_file_name | code_variable }}". The main executable code file does not have any semantic meaning and it should be completely disregarded when generating the code (except for the file name). 13 | 14 | 15 | ***Test Requirements:*** 16 | 17 | - :ConformanceTests: of :App: should be implemented in Go lang. 18 | 19 | - The main executable code file of :ConformanceTests: should be called "conformance_tests.go". 20 | 21 | - :ConformanceTests: will be executed using the command "go run /conformance_tests.go" from a folder that contains the file "{{ main_executable_file_name | code_variable }}". 22 | 23 | - "{{ main_executable_file_name | code_variable }}" is the main executable code file of :App:. 24 | 25 | - Before :ConformanceTests: are executed, "go get" will be executed in the folder containing the file "{{ main_executable_file_name | code_variable }}". 26 | 27 | - Before :ConformanceTests: are executed, "go get" will be executed in . 28 | 29 | - :App: can be executed using the command "go run". 30 | 31 | 32 | # The Go lang console application boilerplate 33 | 34 | ***Functional Requirements:*** 35 | 36 | - Implement the entry point for :App:. -------------------------------------------------------------------------------- /standard_template_library/python-console-app-template.plain: -------------------------------------------------------------------------------- 1 | ***Definitions:*** 2 | 3 | - :App: is a console application. 4 | 5 | 6 | ***Non-Functional Requirements:*** 7 | 8 | - :Implementation: should be in Python. 9 | 10 | - :Implementation: should include :UnitTests: using Unittest framework! If :UnitTests: are put in the subfolder, make sure to include __init__.py to make them discoverable. 11 | 12 | - The main executable code file of :App: should be called "{{ main_executable_file_name | code_variable }}". The main executable code file does not have any semantic meaning and it should be completely disregarded when generating the code (except for the file name). 13 | 14 | 15 | ***Test Requirements:*** 16 | 17 | - :ConformanceTests: of :App: should be implemented in Python using Unittest framework. :ConformanceTests: will be run using "python -m unittest discover" command. Therefore, if :ConformanceTests: are put in the subfolder, make sure to include __init__.py to make them discoverable. 18 | 19 | - :ConformanceTests: must be implemented and executed - do not use unittest.skip() or “any other test skipping functionality. 20 | 21 | - The main executable code file of :App: is "{{ main_executable_file_name | code_variable }}". 22 | 23 | - The current working directory contains the file "{{ main_executable_file_name | code_variable }}". 24 | 25 | - :App: can be executed using the command "python {{ main_executable_file_name | code_variable }}". 26 | 27 | 28 | # The Python console application boilerplate 29 | 30 | ***Functional Requirements:*** 31 | 32 | - Implement the entry point for :App:. -------------------------------------------------------------------------------- /render_machine/actions/run_unit_tests.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import render_machine.render_utils as render_utils 4 | from plain2code_console import console 5 | from render_machine.actions.base_action import BaseAction 6 | from render_machine.render_context import RenderContext 7 | 8 | UNRECOVERABLE_ERROR_EXIT_CODES = [69] 9 | 10 | 11 | class RunUnitTests(BaseAction): 12 | SUCCESSFUL_OUTCOME = "unit_tests_succeeded" 13 | FAILED_OUTCOME = "unit_tests_failed" 14 | UNRECOVERABLE_ERROR_OUTCOME = "unrecoverable_error_occurred" 15 | 16 | def execute(self, render_context: RenderContext, _previous_action_payload: Any | None): 17 | if render_context.args.verbose: 18 | console.info( 19 | f"[b]Running unit tests script {render_context.args.unittests_script}.[/b] (attempt: {render_context.unit_tests_running_context.fix_attempts + 1})" 20 | ) 21 | exit_code, unittests_issue = render_utils.execute_script( 22 | render_context.args.unittests_script, 23 | [render_context.args.build_folder], 24 | render_context.args.verbose, 25 | "Unit Tests", 26 | ) 27 | 28 | if exit_code == 0: 29 | return self.SUCCESSFUL_OUTCOME, None 30 | 31 | elif exit_code in UNRECOVERABLE_ERROR_EXIT_CODES: 32 | console.error(unittests_issue) 33 | return ( 34 | self.UNRECOVERABLE_ERROR_OUTCOME, 35 | "Unit tests script failed due to problems in the environment setup. Please check the your environment or update the script for running unittests.", 36 | ) 37 | else: 38 | return self.FAILED_OUTCOME, {"previous_unittests_issue": unittests_issue} 39 | -------------------------------------------------------------------------------- /render_machine/actions/summarize_conformance_tests.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from plain2code_console import console 4 | from render_machine.actions.base_action import BaseAction 5 | from render_machine.conformance_test_helpers import ConformanceTestHelpers 6 | from render_machine.render_context import RenderContext 7 | 8 | 9 | class SummarizeConformanceTests(BaseAction): 10 | SUCCESSFUL_OUTCOME = "conformance_tests_summarized" 11 | 12 | def execute(self, render_context: RenderContext, _previous_action_payload: Any | None): 13 | console.info(f"Summarizing conformance tests for functional requirement {render_context.frid_context.frid}.") 14 | 15 | _, existing_conformance_test_files_content = ConformanceTestHelpers.fetch_existing_conformance_test_files( 16 | render_context.conformance_tests_running_context # type: ignore 17 | ) 18 | 19 | with console.status( 20 | f"[{console.INFO_STYLE}]Summarizing finished conformance tests for functional requirement {render_context.frid_context.frid}...\n" 21 | ): 22 | summary = render_context.codeplain_api.summarize_finished_conformance_tests( 23 | frid=render_context.frid_context.frid, 24 | plain_source_tree=render_context.plain_source_tree, 25 | linked_resources=render_context.frid_context.linked_resources, 26 | conformance_test_files_content=existing_conformance_test_files_content, 27 | run_state=render_context.run_state, 28 | ) 29 | 30 | ConformanceTestHelpers.set_current_conformance_tests_summary( 31 | render_context.conformance_tests_running_context, summary # type: ignore 32 | ) 33 | 34 | return self.SUCCESSFUL_OUTCOME, None 35 | -------------------------------------------------------------------------------- /system_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import sys 4 | 5 | import yaml 6 | 7 | from plain2code_console import console 8 | 9 | 10 | class SystemConfig: 11 | """Manages system-level configuration including requirements and error messages.""" 12 | 13 | def __init__(self): 14 | self.config = self._load_config() 15 | if "system_requirements" not in self.config: 16 | raise KeyError("Missing 'system_requirements' section in system_config.yaml") 17 | if "error_messages" not in self.config: 18 | raise KeyError("Missing 'error_messages' section in system_config.yaml") 19 | 20 | self.requirements = self.config["system_requirements"] 21 | self.error_messages = self.config["error_messages"] 22 | 23 | def _load_config(self): 24 | """Load system configuration from YAML file.""" 25 | config_path = os.path.join(os.path.dirname(__file__), "system_config.yaml") 26 | try: 27 | with open(config_path, "r") as f: 28 | yaml_data = yaml.safe_load(f) 29 | return yaml_data 30 | except Exception as e: 31 | console.error(f"Failed to load system configuration: {e}") 32 | sys.exit(69) 33 | 34 | def verify_requirements(self): 35 | """Verify all system requirements are met.""" 36 | for req_data in self.requirements.values(): 37 | if not shutil.which(req_data["command"]): 38 | console.error(req_data["error_message"]) 39 | sys.exit(69) 40 | 41 | def get_error_message(self, message_key, **kwargs): 42 | """Get a formatted error message by its key.""" 43 | if message_key not in self.error_messages: 44 | raise KeyError(f"Unknown error message key: {message_key}") 45 | return self.error_messages[message_key]["message"].format(**kwargs) 46 | 47 | 48 | # Create a singleton instance 49 | system_config = SystemConfig() 50 | -------------------------------------------------------------------------------- /test_scripts/run_unittests_python.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | UNRECOVERABLE_ERROR_EXIT_CODE=69 4 | 5 | # Check if subfolder name is provided 6 | if [ -z "$1" ]; then 7 | echo "Error: No subfolder name provided." 8 | echo "Usage: $0 " 9 | exit $UNRECOVERABLE_ERROR_EXIT_CODE 10 | fi 11 | 12 | PYTHON_BUILD_SUBFOLDER=python_$1 13 | 14 | if [ "${VERBOSE:-}" -eq 1 ] 2>/dev/null; then 15 | printf "Preparing Python build subfolder: $PYTHON_BUILD_SUBFOLDER\n" 16 | fi 17 | 18 | # Check if the Python build subfolder exists 19 | if [ -d "$PYTHON_BUILD_SUBFOLDER" ]; then 20 | # Find and delete all files and folders 21 | find "$PYTHON_BUILD_SUBFOLDER" -mindepth 1 -exec rm -rf {} + 22 | 23 | if [ "${VERBOSE:-}" -eq 1 ] 2>/dev/null; then 24 | printf "Cleanup completed.\n" 25 | fi 26 | else 27 | if [ "${VERBOSE:-}" -eq 1 ] 2>/dev/null; then 28 | printf "Subfolder does not exist. Creating it...\n" 29 | fi 30 | 31 | mkdir $PYTHON_BUILD_SUBFOLDER 32 | fi 33 | 34 | cp -R $1/* $PYTHON_BUILD_SUBFOLDER 35 | 36 | # Move to the subfolder 37 | cd "$PYTHON_BUILD_SUBFOLDER" 2>/dev/null 38 | 39 | if [ $? -ne 0 ]; then 40 | printf "Error: Python build folder '$PYTHON_BUILD_SUBFOLDER' does not exist.\n" 41 | exit $UNRECOVERABLE_ERROR_EXIT_CODE 42 | fi 43 | 44 | # Execute all Python unittests in the subfolder 45 | echo "Running Python unittests in $PYTHON_BUILD_SUBFOLDER..." 46 | 47 | output=$(timeout 60s python -m unittest discover -b 2>&1) 48 | exit_code=$? 49 | 50 | # Check if the command timed out 51 | if [ $exit_code -eq 124 ]; then 52 | printf "\nError: Unittests timed out after 60 seconds.\n" 53 | exit $exit_code 54 | fi 55 | 56 | # Echo the original output 57 | echo "$output" 58 | 59 | # Return the exit code of the unittest command 60 | exit $exit_code 61 | 62 | # Note: The 'discover' option automatically identifies and runs all unittests in the current directory and subdirectories 63 | # Ensure that your Python files are named according to the unittest discovery pattern (test*.py by default) 64 | -------------------------------------------------------------------------------- /render_machine/actions/run_conformance_tests.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import render_machine.render_utils as render_utils 4 | from plain2code_console import console 5 | from render_machine.actions.base_action import BaseAction 6 | from render_machine.conformance_test_helpers import ConformanceTestHelpers 7 | from render_machine.render_context import RenderContext 8 | 9 | UNRECOVERABLE_ERROR_EXIT_CODES = [69] 10 | 11 | 12 | class RunConformanceTests(BaseAction): 13 | 14 | SUCCESSFUL_OUTCOME = "conformance_tests_passed" 15 | FAILED_OUTCOME = "conformance_tests_failed" 16 | UNRECOVERABLE_ERROR_OUTCOME = "unrecoverable_error_occurred" 17 | 18 | def execute(self, render_context: RenderContext, _previous_action_payload: Any | None): 19 | conformance_tests_folder_name = ConformanceTestHelpers.get_current_conformance_test_folder_name( 20 | render_context.conformance_tests_running_context # type: ignore 21 | ) 22 | 23 | if render_context.args.verbose: 24 | console.info( 25 | f"\n[b]Running conformance tests script {render_context.args.conformance_tests_script} for {conformance_tests_folder_name} (functional requirement {render_context.conformance_tests_running_context.current_testing_frid}).[/b]" 26 | ) 27 | exit_code, conformance_tests_issue = render_utils.execute_script( 28 | render_context.args.conformance_tests_script, 29 | [render_context.args.build_folder, conformance_tests_folder_name], 30 | render_context.args.verbose, 31 | "Conformance Tests", 32 | ) 33 | 34 | if exit_code == 0: 35 | return self.SUCCESSFUL_OUTCOME, None 36 | 37 | if exit_code in UNRECOVERABLE_ERROR_EXIT_CODES: 38 | console.error(conformance_tests_issue) 39 | return ( 40 | self.UNRECOVERABLE_ERROR_OUTCOME, 41 | {"previous_conformance_tests_issue": conformance_tests_issue}, 42 | ) 43 | 44 | return self.FAILED_OUTCOME, {"previous_conformance_tests_issue": conformance_tests_issue} 45 | -------------------------------------------------------------------------------- /test_scripts/run_conformance_tests_python.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | UNRECOVERABLE_ERROR_EXIT_CODE=69 4 | 5 | # Check if build folder name is provided 6 | if [ -z "$1" ]; then 7 | printf "Error: No build folder name provided.\n" 8 | printf "Usage: $0 \n" 9 | exit $UNRECOVERABLE_ERROR_EXIT_CODE 10 | fi 11 | 12 | # Check if conformance tests folder name is provided 13 | if [ -z "$2" ]; then 14 | printf "Error: No conformance tests folder name provided.\n" 15 | printf "Usage: $0 \n" 16 | exit $UNRECOVERABLE_ERROR_EXIT_CODE 17 | fi 18 | 19 | current_dir=$(pwd) 20 | 21 | PYTHON_BUILD_SUBFOLDER=python_$1 22 | 23 | if [ "${VERBOSE:-}" -eq 1 ] 2>/dev/null; then 24 | printf "Preparing Python build subfolder: $PYTHON_BUILD_SUBFOLDER\n" 25 | fi 26 | 27 | # Check if the Python build subfolder exists 28 | if [ -d "$PYTHON_BUILD_SUBFOLDER" ]; then 29 | # Find and delete all files and folders 30 | find "$PYTHON_BUILD_SUBFOLDER" -mindepth 1 -exec rm -rf {} + 31 | 32 | if [ "${VERBOSE:-}" -eq 1 ] 2>/dev/null; then 33 | printf "Cleanup completed.\n" 34 | fi 35 | else 36 | if [ "${VERBOSE:-}" -eq 1 ] 2>/dev/null; then 37 | printf "Subfolder does not exist. Creating it...\n" 38 | fi 39 | 40 | mkdir $PYTHON_BUILD_SUBFOLDER 41 | fi 42 | 43 | cp -R $1/* $PYTHON_BUILD_SUBFOLDER 44 | 45 | # Move to the subfolder 46 | cd "$PYTHON_BUILD_SUBFOLDER" 2>/dev/null 47 | 48 | if [ $? -ne 0 ]; then 49 | printf "Error: Python build folder '$PYTHON_BUILD_SUBFOLDER' does not exist.\n" 50 | exit $UNRECOVERABLE_ERROR_EXIT_CODE 51 | fi 52 | 53 | # Execute all Python conformance tests in the build folder 54 | printf "Running Python conformance tests...\n\n" 55 | 56 | output=$(python -m unittest discover -b -s "$current_dir/$2" 2>&1) 57 | exit_code=$? 58 | 59 | # Echo the original output 60 | echo "$output" 61 | 62 | # Check if no tests were discovered 63 | if echo "$output" | grep -q "Ran 0 tests in"; then 64 | printf "\nError: No unittests discovered.\n" 65 | exit 1 66 | fi 67 | 68 | # Echo the original exit code of the unittest command 69 | exit $exit_code -------------------------------------------------------------------------------- /render_machine/states.py: -------------------------------------------------------------------------------- 1 | """ 2 | State name constants for the hierarchical state machine. 3 | 4 | This module defines the state name constants used by the state machine configuration. 5 | Note: State names must be in camelCase due to hierarchical graph machine requirements. 6 | """ 7 | 8 | from enum import Enum 9 | 10 | 11 | class States(Enum): 12 | """State name constants for the hierarchical state machine. 13 | 14 | Note: State names must be in camelCase due to hierarchical graph machine requirements. 15 | TODO: Consider standardizing on present vs past tense for state names. 16 | """ 17 | 18 | # Root level states 19 | RENDER_INITIALISED = "renderInitialised" 20 | IMPLEMENTING_FRID = "implementingFrid" 21 | RENDER_COMPLETED = "renderCompleted" 22 | RENDER_FAILED = "renderFailed" 23 | 24 | # FRID implementation states 25 | READY_FOR_FRID_IMPLEMENTATION = "readyForFridImplementation" 26 | FRID_FULLY_IMPLEMENTED = "fridFullyImplemented" 27 | 28 | # Unit test processing states 29 | PROCESSING_UNIT_TESTS = "processingUnitTests" 30 | UNIT_TESTS_READY = "unittestsReady" 31 | UNIT_TESTS_FAILED = "unittestsFailed" 32 | UNIT_TESTS_PASSED = "unittestsPassed" 33 | 34 | # Code refactoring states 35 | REFACTORING_CODE = "refactoringCode" 36 | READY_FOR_REFACTORING = "readyForRefactoring" 37 | 38 | # Conformance test processing states 39 | PROCESSING_CONFORMANCE_TESTS = "processingConformanceTests" 40 | CONFORMANCE_TESTING_INITIALISED = "conformanceTestingInitialised" 41 | CONFORMANCE_TEST_GENERATED = "conformanceTestGenerated" 42 | CONFORMANCE_TEST_ENV_PREPARED = "conformanceTestEnvironmentPrepared" 43 | CONFORMANCE_TEST_FAILED = "conformanceTestFailed" 44 | 45 | # Postprocessing conformance tests states 46 | POSTPROCESSING_CONFORMANCE_TESTS = "postprocessingConformanceTests" 47 | CONFORMANCE_TESTS_READY_FOR_SUMMARY = "conformanceTestsReadyForSummary" 48 | CONFORMANCE_TESTS_READY_FOR_COMMIT = "conformanceTestsReadyForCommit" 49 | CONFORMANCE_TESTS_READY_FOR_AMBIGUITY_ANALYSIS = "conformanceTestsReadyForAmbiguityAnalysis" 50 | 51 | def __str__(self): 52 | return self.value 53 | -------------------------------------------------------------------------------- /test_scripts/run_unittests_react.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | UNRECOVERABLE_ERROR_EXIT_CODE=69 4 | 5 | # ANSI escape code pattern to remove color codes and formatting from output 6 | ANSI_ESCAPE_PATTERN="s/\x1b\[[0-9;]*[mK]//g" 7 | 8 | # Ensures that if any command in the pipeline fails (like npm run build), the entire pipeline 9 | # will return a non-zero status, allowing the if condition to properly catch failures. 10 | set -o pipefail 11 | 12 | # Check if subfolder name is provided 13 | if [ -z "$1" ]; then 14 | echo "Error: No subfolder name provided." 15 | echo "Usage: $0 " 16 | exit $UNRECOVERABLE_ERROR_EXIT_CODE 17 | fi 18 | 19 | # Define the path to the subfolder 20 | NODE_SUBFOLDER=node_$1 21 | 22 | if [ "${VERBOSE:-}" -eq 1 ] 2>/dev/null; then 23 | printf "Preparing Node subfolder: $NODE_SUBFOLDER\n" 24 | fi 25 | 26 | # Check if the node subfolder exists 27 | if [ -d "$NODE_SUBFOLDER" ]; then 28 | # Find and delete all files and folders except "node_modules", "build", and "package-lock.json" 29 | find "$NODE_SUBFOLDER" -mindepth 1 ! -path "$NODE_SUBFOLDER/node_modules*" ! -path "$NODE_SUBFOLDER/build*" ! -name "package-lock.json" -exec rm -rf {} + 30 | 31 | if [ "${VERBOSE:-}" -eq 1 ] 2>/dev/null; then 32 | printf "Cleanup completed, keeping 'node_modules' and 'package-lock.json'.\n" 33 | fi 34 | else 35 | if [ "${VERBOSE:-}" -eq 1 ] 2>/dev/null; then 36 | printf "Subfolder does not exist. Creating it...\n" 37 | fi 38 | 39 | mkdir $NODE_SUBFOLDER 40 | fi 41 | 42 | cp -R $1/* $NODE_SUBFOLDER 43 | 44 | # Move to the subfolder 45 | cd "$NODE_SUBFOLDER" 2>/dev/null 46 | 47 | if [ $? -ne 0 ]; then 48 | echo "Error: Subfolder '$1' does not exist." 49 | exit $UNRECOVERABLE_ERROR_EXIT_CODE 50 | fi 51 | 52 | # Install libraries 53 | npm install 54 | 55 | # Execute all React unittests in the subfolder 56 | echo "Running React unittests in $1..." 57 | npm test -- --runInBand --silent --detectOpenHandles 2>&1 | sed -E "$ANSI_ESCAPE_PATTERN" 58 | TEST_EXIT_CODE=$? 59 | 60 | # Check if tests failed 61 | if [ $TEST_EXIT_CODE -ne 0 ]; then 62 | echo "Error: Tests failed with exit code $TEST_EXIT_CODE" 63 | exit $TEST_EXIT_CODE 64 | fi 65 | 66 | exit $TEST_EXIT_CODE -------------------------------------------------------------------------------- /render_machine/code_renderer.py: -------------------------------------------------------------------------------- 1 | from transitions.extensions.diagrams import HierarchicalGraphMachine 2 | 3 | from plain2code_state import RunState 4 | from render_machine.render_context import RenderContext 5 | from render_machine.state_machine_config import StateMachineConfig, States 6 | 7 | 8 | class CodeRenderer: 9 | """Main code renderer class that orchestrates the code generation workflow using a hierarchical state machine.""" 10 | 11 | def __init__(self, codeplain_api, plain_source_tree: dict, args: dict, run_state: RunState): 12 | self.render_context = RenderContext(codeplain_api, plain_source_tree, args, run_state) 13 | self.state_machine_config = StateMachineConfig() 14 | 15 | # Initialize the state machine 16 | states = self.state_machine_config.get_states(self.render_context) 17 | transitions = self.state_machine_config.get_transitions() 18 | 19 | self.machine = HierarchicalGraphMachine( 20 | model=self.render_context, 21 | states=states, 22 | transitions=transitions, 23 | initial=States.RENDER_INITIALISED.value, 24 | ) 25 | self.render_context.set_machine(self.machine) 26 | 27 | # Get action mappings 28 | self.action_map = self.state_machine_config.get_action_map() 29 | self.action_result_triggers_map = self.state_machine_config.get_action_result_triggers_map() 30 | 31 | def run(self): 32 | """Execute the main rendering workflow.""" 33 | previous_action_payload = None 34 | while True: 35 | outcome, previous_action_payload = self.action_map[self.render_context.state].execute( 36 | self.render_context, previous_action_payload 37 | ) 38 | 39 | if self.render_context.state in [ 40 | States.RENDER_FAILED.value, 41 | States.RENDER_COMPLETED.value, 42 | ]: 43 | break 44 | 45 | next_trigger = self.action_result_triggers_map[outcome] 46 | self.machine.dispatch(next_trigger) 47 | 48 | def generate_render_machine_graph(self): 49 | """Generate a visual diagram of the state machine.""" 50 | self.render_context.get_graph().draw("render_machine_diagram.png", prog="dot") 51 | -------------------------------------------------------------------------------- /render_machine/actions/refactor_code.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import file_utils 4 | from plain2code_console import console 5 | from render_machine.actions.base_action import BaseAction 6 | from render_machine.implementation_code_helpers import ImplementationCodeHelpers 7 | from render_machine.render_context import RenderContext 8 | 9 | 10 | class RefactorCode(BaseAction): 11 | SUCCESSFUL_OUTCOME = "refactoring_successful" 12 | NO_FILES_REFACTORED_OUTCOME = "no_files_refactored" 13 | 14 | def execute(self, render_context: RenderContext, _previous_action_payload: Any | None): 15 | existing_files, existing_files_content = ImplementationCodeHelpers.fetch_existing_files(render_context) 16 | 17 | if render_context.args.verbose: 18 | console.info(f"\nRefactoring iteration {render_context.frid_context.refactoring_iteration}.") 19 | 20 | if render_context.args.verbose: 21 | console.print_files( 22 | "Files sent as input for refactoring:", 23 | render_context.args.build_folder, 24 | existing_files_content, 25 | style=console.INPUT_STYLE, 26 | ) 27 | with console.status( 28 | f"[{console.INFO_STYLE}]Refactoring the generated code for functional requirement {render_context.frid_context.frid}...\n" 29 | ): 30 | response_files = render_context.codeplain_api.refactor_source_files_if_needed( 31 | frid=render_context.frid_context.frid, 32 | files_to_check=render_context.frid_context.changed_files, 33 | existing_files_content=existing_files_content, 34 | run_state=render_context.run_state, 35 | ) 36 | 37 | if len(response_files) == 0: 38 | if render_context.args.verbose: 39 | console.info("No files refactored.") 40 | return self.NO_FILES_REFACTORED_OUTCOME, None 41 | 42 | file_utils.store_response_files(render_context.args.build_folder, response_files, existing_files) 43 | 44 | if render_context.args.verbose: 45 | console.print_files( 46 | "Files refactored:", render_context.args.build_folder, response_files, style=console.OUTPUT_STYLE 47 | ) 48 | return self.SUCCESSFUL_OUTCOME, None 49 | -------------------------------------------------------------------------------- /render_machine/actions/prepare_repositories.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import file_utils 4 | import git_utils 5 | import plain_spec 6 | from plain2code_console import console 7 | from render_machine.actions.base_action import BaseAction 8 | from render_machine.render_context import RenderContext 9 | 10 | 11 | class PrepareRepositories(BaseAction): 12 | SUCCESSFUL_OUTCOME = "repositories_prepared" 13 | 14 | def execute(self, render_context: RenderContext, _previous_action_payload: Any | None): 15 | 16 | if render_context.args.render_range is not None and render_context.args.render_range[ 17 | 0 18 | ] != plain_spec.get_first_frid(render_context.plain_source_tree): 19 | frid = render_context.args.render_range[0] 20 | 21 | render_context.starting_frid = frid 22 | 23 | previous_frid = plain_spec.get_previous_frid(render_context.plain_source_tree, frid) 24 | 25 | if render_context.args.verbose: 26 | console.info(f"Reverting code to version implemented for {previous_frid}.") 27 | 28 | git_utils.revert_to_commit_with_frid(render_context.args.build_folder, previous_frid) 29 | # conformance tests are still not fully implemented 30 | if render_context.args.render_conformance_tests: 31 | git_utils.revert_to_commit_with_frid(render_context.args.conformance_tests_folder, previous_frid) 32 | else: 33 | if render_context.args.verbose: 34 | console.info("Initializing git repositories for the render folders.") 35 | 36 | git_utils.init_git_repo(render_context.args.build_folder) 37 | 38 | if render_context.args.base_folder: 39 | file_utils.copy_folder_content(render_context.args.base_folder, render_context.args.build_folder) 40 | git_utils.add_all_files_and_commit( 41 | render_context.args.build_folder, 42 | git_utils.BASE_FOLDER_COMMIT_MESSAGE, 43 | None, 44 | render_context.run_state.render_id, 45 | ) 46 | 47 | if render_context.args.render_conformance_tests: 48 | git_utils.init_git_repo(render_context.args.conformance_tests_folder) 49 | 50 | return self.SUCCESSFUL_OUTCOME, None 51 | -------------------------------------------------------------------------------- /render_machine/actions/commit_conformance_tests_changes.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import git_utils 4 | import plain_spec 5 | from render_machine.actions.base_action import BaseAction 6 | from render_machine.render_context import RenderContext 7 | 8 | 9 | class CommitConformanceTestsChanges(BaseAction): 10 | SUCCESSFUL_OUTCOME_IMPLEMENTATION_NOT_UPDATED = "conformance_tests_changes_committed_implementation_not_updated" 11 | SUCCESSFUL_OUTCOME_IMPLEMENTATION_UPDATED = "conformance_tests_changes_committed_implementation_updated" 12 | 13 | def __init__(self, implementation_code_commit_message: str, conformance_tests_commit_message: str): 14 | self.implementation_code_commit_message = implementation_code_commit_message 15 | self.conformance_tests_commit_message = conformance_tests_commit_message 16 | 17 | def execute(self, render_context: RenderContext, _previous_action_payload: Any | None): 18 | implementation_updated = False 19 | if git_utils.is_dirty(render_context.args.build_folder): 20 | git_utils.add_all_files_and_commit( 21 | render_context.args.build_folder, 22 | self.implementation_code_commit_message, 23 | render_context.frid_context.frid, 24 | render_context.run_state.render_id, 25 | ) 26 | implementation_updated = True 27 | functional_requirement_text = render_context.frid_context.specifications[plain_spec.FUNCTIONAL_REQUIREMENTS][-1] 28 | templated_functional_requirement_finished_commit_msg = self.conformance_tests_commit_message.format( 29 | render_context.frid_context.frid 30 | ) 31 | formatted_conformance_commit_msg = ( 32 | f"{functional_requirement_text}\n\n{templated_functional_requirement_finished_commit_msg}" 33 | ) 34 | render_context.conformance_tests_utils.dump_conformance_tests_json( 35 | render_context.conformance_tests_running_context.conformance_tests_json 36 | ) 37 | git_utils.add_all_files_and_commit( 38 | render_context.args.conformance_tests_folder, 39 | formatted_conformance_commit_msg, 40 | None, 41 | render_context.run_state.render_id, 42 | ) 43 | if implementation_updated: 44 | return self.SUCCESSFUL_OUTCOME_IMPLEMENTATION_UPDATED, None 45 | else: 46 | return self.SUCCESSFUL_OUTCOME_IMPLEMENTATION_NOT_UPDATED, None 47 | -------------------------------------------------------------------------------- /test_scripts/run_conformance_tests_golang.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | UNRECOVERABLE_ERROR_EXIT_CODE=69 4 | 5 | # Check if build folder name is provided 6 | if [ -z "$1" ]; then 7 | printf "Error: No build folder name provided.\n" 8 | printf "Usage: $0 \n" 9 | exit $UNRECOVERABLE_ERROR_EXIT_CODE 10 | fi 11 | 12 | # Check if conformance tests folder name is provided 13 | if [ -z "$2" ]; then 14 | printf "Error: No conformance tests folder name provided.\n" 15 | printf "Usage: $0 \n" 16 | exit $UNRECOVERABLE_ERROR_EXIT_CODE 17 | fi 18 | 19 | current_dir=$(pwd) 20 | 21 | GO_BUILD_SUBFOLDER=go_$1 22 | 23 | if [ "${VERBOSE:-}" -eq 1 ] 2>/dev/null; then 24 | printf "Preparing Go build subfolder: $GO_BUILD_SUBFOLDER\n" 25 | fi 26 | 27 | # Check if the go build subfolder exists 28 | if [ -d "$GO_BUILD_SUBFOLDER" ]; then 29 | # Find and delete all files and folders 30 | find "$GO_BUILD_SUBFOLDER" -mindepth 1 -exec rm -rf {} + 31 | 32 | if [ "${VERBOSE:-}" -eq 1 ] 2>/dev/null; then 33 | printf "Cleanup completed.\n" 34 | fi 35 | else 36 | if [ "${VERBOSE:-}" -eq 1 ] 2>/dev/null; then 37 | printf "Subfolder does not exist. Creating it...\n" 38 | fi 39 | 40 | mkdir $GO_BUILD_SUBFOLDER 41 | fi 42 | 43 | cp -R $1/* $GO_BUILD_SUBFOLDER 44 | 45 | # Move to the subfolder 46 | cd "$GO_BUILD_SUBFOLDER" 2>/dev/null 47 | 48 | if [ $? -ne 0 ]; then 49 | printf "Error: Go build folder '$GO_BUILD_SUBFOLDER' does not exist.\n" 50 | exit $UNRECOVERABLE_ERROR_EXIT_CODE 51 | fi 52 | 53 | echo "Runinng go get in the build folder..." 54 | go get 55 | 56 | cd "$current_dir/$2" 2>/dev/null 57 | 58 | if [ $? -ne 0 ]; then 59 | printf "Error: Conformance tests folder '$current_dir/$2' does not exist.\n" 60 | exit $UNRECOVERABLE_ERROR_EXIT_CODE 61 | fi 62 | 63 | echo "Checking for go.mod in conformance test directory..." 64 | if [ -f "go.mod" ]; then 65 | echo "Running go get in conformance test directory..." 66 | go get 67 | else 68 | echo "No go.mod found in conformance test directory, skipping go get" 69 | fi 70 | 71 | # Move back to build directory 72 | cd "$current_dir/$GO_BUILD_SUBFOLDER" 2>/dev/null 73 | 74 | # Execute Go lang conformance tests 75 | printf "Running Golang conformance tests...\n\n" 76 | 77 | output=$(go run $current_dir/$2/conformance_tests.go 2>&1) 78 | exit_code=$? 79 | 80 | # If there was an error, print the output and exit with the error code 81 | if [ $exit_code -ne 0 ]; then 82 | echo "$output" 83 | exit $exit_code 84 | fi 85 | 86 | # Echo the original exit code of the unittest command 87 | exit $exit_code -------------------------------------------------------------------------------- /plain2code_utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Optional 3 | 4 | import plain_spec 5 | from plain2code_console import console 6 | 7 | AMBIGUITY_CAUSES = { 8 | "reference_resource_ambiguity": "Ambiguity is in the reference resources", 9 | "definition_ambiguity": "Ambiguity is in the definitions", 10 | "non_functional_requirement_ambiguity": "Ambiguity is in the non-functional requirements", 11 | "functional_requirement_ambiguity": "Ambiguity is in the functional requirements", 12 | "other": "Ambiguity in the other parts of the specification", 13 | } 14 | 15 | 16 | class RetryOnlyFilter(logging.Filter): 17 | def filter(self, record): 18 | # Allow all logs with level > DEBUG (i.e., INFO and above) 19 | if record.levelno > logging.DEBUG: 20 | return True 21 | # For DEBUG logs, only allow if message matches retry-related patterns 22 | msg = record.getMessage().lower() 23 | return ( 24 | "retrying due to" in msg 25 | or "raising timeout error" in msg 26 | or "raising connection error" in msg 27 | or "encountered exception" in msg 28 | or "retrying request" in msg 29 | or "retry left" in msg 30 | or "1 retry left" in msg 31 | or "retries left" in msg 32 | ) 33 | 34 | 35 | def print_dry_run_output(plain_source_tree: dict, render_range: Optional[list[str]]): 36 | frid = plain_spec.get_first_frid(plain_source_tree) 37 | 38 | while frid is not None: 39 | is_inside_range = render_range is None or frid in render_range 40 | 41 | if is_inside_range: 42 | specifications, _ = plain_spec.get_specifications_for_frid(plain_source_tree, frid) 43 | functional_requirement_text = specifications[plain_spec.FUNCTIONAL_REQUIREMENTS][-1] 44 | console.info("\n-------------------------------------") 45 | console.info(f"Rendering functional requirement {frid}") 46 | console.info(f"[b]{functional_requirement_text}[/b]") 47 | console.info("-------------------------------------\n") 48 | if plain_spec.ACCEPTANCE_TESTS in specifications: 49 | for i, acceptance_test in enumerate(specifications[plain_spec.ACCEPTANCE_TESTS], 1): 50 | console.info(f"\nGenerating acceptance test #{i}:\n\n{acceptance_test}") 51 | else: 52 | console.info("\n-------------------------------------") 53 | console.info(f"Skipping rendering iteration: {frid}") 54 | console.info("-------------------------------------\n") 55 | 56 | frid = plain_spec.get_next_frid(plain_source_tree, frid) 57 | -------------------------------------------------------------------------------- /render_machine/actions/fix_unit_tests.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import file_utils 4 | import render_machine.render_utils as render_utils 5 | from plain2code_console import console 6 | from plain2code_exceptions import UnexpectedState 7 | from render_machine.actions.base_action import BaseAction 8 | from render_machine.implementation_code_helpers import ImplementationCodeHelpers 9 | from render_machine.render_context import RenderContext 10 | 11 | MAX_ISSUE_LENGTH = 10000 12 | 13 | 14 | class FixUnitTests(BaseAction): 15 | SUCCESSFUL_OUTCOME = "unit_tests_fix_generated" 16 | 17 | def execute(self, render_context: RenderContext, previous_action_payload: Any | None): 18 | if not previous_action_payload.get("previous_unittests_issue"): 19 | raise UnexpectedState("Previous action payload does not contain previous unit tests issue.") 20 | previous_unittests_issue = previous_action_payload["previous_unittests_issue"] 21 | 22 | if previous_unittests_issue and len(previous_unittests_issue) > MAX_ISSUE_LENGTH: 23 | console.warning( 24 | f"Unit tests issue text is too long and will be smartly truncated to {MAX_ISSUE_LENGTH} characters." 25 | ) 26 | 27 | existing_files, existing_files_content = ImplementationCodeHelpers.fetch_existing_files(render_context) 28 | 29 | if render_context.args.verbose: 30 | render_utils.print_inputs( 31 | render_context, existing_files_content, "Files sent as input to unit tests fixing:" 32 | ) 33 | 34 | with console.status( 35 | f"[{console.INFO_STYLE}]Fixing unit tests issue for functional requirement {render_context.frid_context.frid}...\n" 36 | ): 37 | response_files = render_context.codeplain_api.fix_unittests_issue( 38 | render_context.frid_context.frid, 39 | render_context.plain_source_tree, 40 | render_context.frid_context.linked_resources, 41 | existing_files_content, 42 | previous_unittests_issue, 43 | render_context.run_state, 44 | ) 45 | 46 | _, changed_files = file_utils.update_build_folder_with_rendered_files( 47 | render_context.args.build_folder, existing_files, response_files 48 | ) 49 | 50 | render_context.unit_tests_running_context.changed_files.update(changed_files) 51 | 52 | if render_context.args.verbose: 53 | console.print_files( 54 | "Files fixed:", render_context.args.build_folder, response_files, style=console.OUTPUT_STYLE 55 | ) 56 | 57 | return self.SUCCESSFUL_OUTCOME, None 58 | -------------------------------------------------------------------------------- /plain2code_read_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from argparse import ArgumentParser, Namespace 3 | from typing import Any, Dict 4 | 5 | import yaml 6 | 7 | from plain2code_console import console 8 | 9 | 10 | def load_config(config_file: str) -> Dict[str, Any]: 11 | """Load configuration from YAML file.""" 12 | try: 13 | with open(config_file, "r") as f: 14 | return yaml.safe_load(f) 15 | except Exception as e: 16 | console.error(f"Error loading config file: {e}. Please check the config file path and the config file content.") 17 | raise e 18 | 19 | 20 | def validate_config(config: Dict[str, Any], parser: ArgumentParser) -> None: 21 | """Validate the configuration against the parser.""" 22 | actions = [action.dest for action in parser._actions] 23 | for action in parser._actions: 24 | if hasattr(action, "option_strings"): 25 | actions.extend(opt.lstrip("-") for opt in action.option_strings) 26 | 27 | for key in config.keys(): 28 | if key not in actions: 29 | raise KeyError(f"Invalid configuration key: {key}") 30 | return config 31 | 32 | 33 | def get_args_from_config(config_file: str, parser: ArgumentParser) -> Namespace: 34 | """ 35 | Read configuration from YAML file and return args compatible with plain2code_arguments.py. 36 | 37 | Args: 38 | config_file: Path to the YAML config file 39 | Returns: 40 | Namespace object with arguments as defined in plain2code_arguments.py 41 | 42 | Raises: 43 | FileNotFoundError: If config file doesn't exist 44 | KeyError: If argument not found in parser arguments 45 | """ 46 | 47 | args = Namespace() 48 | 49 | if config_file == "config.yaml": 50 | if not os.path.exists(config_file): 51 | console.info(f"Default config file {config_file} not found. No config file is read.") 52 | return args 53 | 54 | # Load config 55 | config = load_config(config_file) 56 | config = validate_config(config, parser) 57 | 58 | for action in parser._actions: 59 | # Create a list of possible config keys for this argument 60 | possible_keys = [action.dest] 61 | if hasattr(action, "option_strings"): 62 | # Add all option strings without leading dashes 63 | possible_keys.extend(opt.lstrip("-") for opt in action.option_strings) 64 | 65 | # Handling multi-named arguments like --verbose and -v 66 | config_value = None 67 | for key in possible_keys: 68 | if key in config: 69 | config_value = config[key] 70 | break 71 | 72 | if config_value is not None: 73 | setattr(args, action.dest, config_value) 74 | return args 75 | -------------------------------------------------------------------------------- /render_machine/actions/analyze_specification_ambiguity.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import file_utils 4 | import git_utils 5 | import plain_spec 6 | from plain2code_console import console 7 | from plain2code_utils import AMBIGUITY_CAUSES 8 | from render_machine.actions.base_action import BaseAction 9 | from render_machine.render_context import RenderContext 10 | 11 | 12 | class AnalyzeSpecificationAmbiguity(BaseAction): 13 | SUCCESSFUL_OUTCOME = "conformance_tests_postanalyzed" 14 | 15 | def execute(self, render_context: RenderContext, _previous_action_payload: Any | None): 16 | fixed_implementation_code_diff = git_utils.get_fixed_implementation_code_diff( 17 | render_context.args.build_folder, render_context.frid_context.frid 18 | ) 19 | if fixed_implementation_code_diff is None: 20 | raise Exception( 21 | "Fixes to the implementation code found during conformance testing are not committed to git." 22 | ) 23 | previous_frid = plain_spec.get_previous_frid(render_context.plain_source_tree, render_context.frid_context.frid) 24 | git_utils.checkout_commit_with_frid(render_context.args.build_folder, previous_frid) 25 | existing_files = file_utils.list_all_text_files(render_context.args.build_folder) 26 | existing_files_content = file_utils.get_existing_files_content(render_context.args.build_folder, existing_files) 27 | git_utils.checkout_previous_branch(render_context.args.build_folder) 28 | implementation_code_diff = git_utils.get_implementation_code_diff( 29 | render_context.args.build_folder, render_context.frid_context.frid, previous_frid 30 | ) 31 | rendering_analysis = render_context.codeplain_api.analyze_rendering( 32 | render_context.frid_context.frid, 33 | render_context.plain_source_tree, 34 | render_context.frid_context.linked_resources, 35 | existing_files_content, 36 | implementation_code_diff, 37 | fixed_implementation_code_diff, 38 | render_context.run_state, 39 | ) 40 | if rendering_analysis: 41 | # TODO: Before this output is exposed to the user, we should check the 'guidance' field using LLM in the same way as we do conflicting requirements. 42 | console.info( 43 | f"Specification ambiguity detected! {AMBIGUITY_CAUSES[rendering_analysis['cause']]} of the functional requirement {render_context.frid_context.frid}." 44 | ) 45 | console.info(rendering_analysis["guidance"]) 46 | else: 47 | console.warning( 48 | f"No specification ambiguity detected for functional requirement {render_context.frid_context.frid}." 49 | ) 50 | return self.SUCCESSFUL_OUTCOME, None 51 | -------------------------------------------------------------------------------- /plain2code_state.py: -------------------------------------------------------------------------------- 1 | """Contains all state and context information we need for the rendering process.""" 2 | 3 | import json 4 | import os 5 | import uuid 6 | from typing import Optional 7 | 8 | from plain2code_console import console 9 | 10 | CONFORMANCE_TESTS_DEFINITION_FILE_NAME = "conformance_tests.json" 11 | 12 | 13 | class ConformanceTestsUtils: 14 | """Manages the state of conformance tests.""" 15 | 16 | def __init__( 17 | self, 18 | conformance_tests_folder: str, 19 | conformance_tests_definition_file_name: str, 20 | verbose: bool, 21 | ): 22 | self.conformance_tests_folder = conformance_tests_folder 23 | self.full_conformance_tests_definition_file_name = os.path.join( 24 | self.conformance_tests_folder, conformance_tests_definition_file_name 25 | ) 26 | self.verbose = verbose 27 | 28 | def get_conformance_tests_json(self): 29 | try: 30 | with open(self.full_conformance_tests_definition_file_name, "r") as f: 31 | return json.load(f) 32 | except FileNotFoundError: 33 | return {} 34 | 35 | def dump_conformance_tests_json(self, conformance_tests_json: dict): 36 | """Dump the conformance tests definition to the file.""" 37 | if os.path.exists(self.conformance_tests_folder): 38 | if self.verbose: 39 | console.info( 40 | f"Storing conformance tests definition to {self.full_conformance_tests_definition_file_name}" 41 | ) 42 | with open(self.full_conformance_tests_definition_file_name, "w") as f: 43 | json.dump(conformance_tests_json, f, indent=4) 44 | 45 | 46 | class RunState: 47 | """Contains information about the identifiable state of the rendering process.""" 48 | 49 | def __init__(self, spec_filename: str, replay_with: Optional[str] = None): 50 | self.replay: bool = replay_with is not None 51 | if replay_with: 52 | self.render_id: str = replay_with 53 | else: 54 | self.render_id: str = str(uuid.uuid4()) 55 | self.spec_filename: str = spec_filename 56 | self.call_count: int = 0 57 | self.unittest_batch_id: int = 0 58 | self.frid_render_anaysis: dict[str, str] = {} 59 | 60 | def increment_call_count(self): 61 | self.call_count += 1 62 | 63 | def increment_unittest_batch_id(self): 64 | self.unittest_batch_id += 1 65 | 66 | def add_rendering_analysis_for_frid(self, frid, rendering_analysis) -> None: 67 | self.frid_render_anaysis[frid] = rendering_analysis 68 | 69 | def to_dict(self): 70 | return { 71 | "render_id": self.render_id, 72 | "call_count": self.call_count, 73 | "replay": self.replay, 74 | "spec_filename": self.spec_filename, 75 | } 76 | -------------------------------------------------------------------------------- /render_machine/conformance_test_helpers.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Optional 3 | 4 | import file_utils 5 | import plain_spec 6 | from render_machine.render_types import ConformanceTestsRunningContext 7 | 8 | 9 | class ConformanceTestHelpers: 10 | @staticmethod 11 | def fetch_existing_conformance_test_folder_names(conformance_tests_folder: str): 12 | if os.path.isdir(conformance_tests_folder): 13 | existing_folder_names = file_utils.list_folders_in_directory(conformance_tests_folder) 14 | else: 15 | # This happens if we're rendering the first FRID (without previously created conformance tests) 16 | existing_folder_names = [] 17 | return existing_folder_names 18 | 19 | @staticmethod 20 | def fetch_existing_conformance_test_files(conformance_tests_running_context: ConformanceTestsRunningContext): 21 | conformance_test_folder_name = ConformanceTestHelpers.get_current_conformance_test_folder_name( 22 | conformance_tests_running_context 23 | ) 24 | existing_conformance_test_files = file_utils.list_all_text_files(conformance_test_folder_name) 25 | existing_conformance_test_files_content = file_utils.get_existing_files_content( 26 | conformance_test_folder_name, existing_conformance_test_files 27 | ) 28 | return existing_conformance_test_files, existing_conformance_test_files_content 29 | 30 | @staticmethod 31 | def current_conformance_tests_exist(conformance_tests_running_context: ConformanceTestsRunningContext) -> bool: 32 | return ( 33 | conformance_tests_running_context.conformance_tests_json.get( 34 | conformance_tests_running_context.current_testing_frid 35 | ) 36 | is not None 37 | ) 38 | 39 | @staticmethod 40 | def get_current_conformance_test_folder_name( 41 | conformance_tests_running_context: ConformanceTestsRunningContext, 42 | ) -> str: 43 | return conformance_tests_running_context.conformance_tests_json[ 44 | conformance_tests_running_context.current_testing_frid 45 | ]["folder_name"] 46 | 47 | @staticmethod 48 | def get_current_acceptance_tests( 49 | conformance_tests_running_context: ConformanceTestsRunningContext, 50 | ) -> Optional[list[str]]: 51 | if ( 52 | plain_spec.ACCEPTANCE_TESTS 53 | in conformance_tests_running_context.conformance_tests_json[ 54 | conformance_tests_running_context.current_testing_frid 55 | ] 56 | ): 57 | return conformance_tests_running_context.conformance_tests_json[ 58 | conformance_tests_running_context.current_testing_frid 59 | ][plain_spec.ACCEPTANCE_TESTS] 60 | return None 61 | 62 | @staticmethod 63 | def set_current_conformance_tests_summary( 64 | conformance_tests_running_context: ConformanceTestsRunningContext, summary: list[dict] 65 | ): 66 | conformance_tests_running_context.conformance_tests_json[ 67 | conformance_tests_running_context.current_testing_frid 68 | ]["test_summary"] = summary 69 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Codeplain plain2code renderer 2 | 3 | Render ***plain source to software code using the Codeplain API. 4 | 5 | ## Codeplain.ai - Code Generation as a Service 6 | 7 | Codeplain is a platform that generates software code using large language models based on requirements you specify in ***plain specification language. 8 | 9 | Schematic overview of the Codeplain's code generation service 10 | 11 | 12 | 13 | ### Abstracting Away Code Generation Complexity with ***plain 14 | 15 | 16 | ***plain is a novel specification language that helps abstracting away complexity of using large language models for code generation. 17 | 18 | An example application in ***plain 19 | 20 | 21 | 22 | 23 | ## Getting started 24 | 25 | ### Prerequisites 26 | 27 | 28 | #### System requirements 29 | 30 | To run the plain2code client, you need Python 3.11 or a later version. 31 | 32 | **Windows users:** Please install WSL (Windows Subsystem for Linux) as this is currently the supported environment for running plain code on Windows. 33 | 34 | #### Authorization - Codeplain API Key 35 | 36 | We are using Codeplain API Key to authorize requests to the Codeplain API. To get your Codeplain API Key, please contact Codeplain.ai support at support@codeplain.ai. 37 | 38 | In order to generate code, you need to export the following environment variable: 39 | 40 | ```bash 41 | export CODEPLAIN_API_KEY="your_actual_api_key_here" 42 | ``` 43 | 44 | ### Installation Steps 45 | 46 | 1. Clone this repository 47 | 2. Set your Codeplain API key as an environment variable: 48 | ``` 49 | export CODEPLAIN_API_KEY=your_api_key_here 50 | ``` 51 | 3. (Recommended) Create and activate a virtual environment: 52 | ```bash 53 | python -m venv .venv 54 | source .venv/bin/activate 55 | ``` 56 | 4. Install required libraries 57 | ``` 58 | pip install -r requirements.txt 59 | ``` 60 | 61 | ### Quick Start 62 | 63 | After completing the installation steps above, you can immediately test the system with a simple "Hello World" example: 64 | 65 | - Change to the example folder and run the example: 66 | ``` 67 | cd examples/example_hello_world_python 68 | python ../../plain2code.py hello_world_python.plain 69 | ``` 70 | 71 | *Note: Rendering will take a few minutes to complete.* 72 | 73 | - The system will generate a Python application in the `build` directory. You can run it with: 74 | ``` 75 | cd build 76 | python hello_world.py 77 | ``` 78 | 79 | ## Additional Resources 80 | 81 | ### Examples and Sample Projects 82 | 83 | - See the [examples](examples) folder for sample projects in Golang, Python, and React. 84 | - For example application how to implement task manager in ***plain see [example-task-manager](https://github.com/Codeplain-ai/example-task-manager) repository. 85 | - For example application how to implement SaaS connectors in ***plain see [example-saas-connectors](https://github.com/Codeplain-ai/example-saas-connectors) repository. 86 | 87 | ### Documentation 88 | 89 | - For more details on the ***plain format, see the [***plain language specification](docs/plain_language_specification.md). 90 | - For step-by-step instructions for creating your first ***plain project see the [Kickstart your plain project](docs/starting_a_plain_project_from_scratch.md). 91 | - For complete CLI documentation and usage examples, see [plain2code CLI documentation](docs/plain2code_cli.md). 92 | 93 | 94 | -------------------------------------------------------------------------------- /render_machine/actions/render_functional_requirement.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import file_utils 4 | import render_machine.render_utils as render_utils 5 | from plain2code_console import console 6 | from plain2code_exceptions import FunctionalRequirementTooComplex 7 | from render_machine.actions.base_action import BaseAction 8 | from render_machine.implementation_code_helpers import ImplementationCodeHelpers 9 | from render_machine.render_context import RenderContext 10 | 11 | MAX_CODE_GENERATION_RETRIES = 2 12 | 13 | 14 | class RenderFunctionalRequirement(BaseAction): 15 | SUCCESSFUL_OUTCOME = "code_and_unit_tests_generated" 16 | FUNCTIONAL_REQUIREMENT_TOO_COMPLEX_OUTCOME = "functional_requirement_too_complex" 17 | 18 | def execute(self, render_context: RenderContext, _previous_action_payload: Any | None): 19 | render_utils.revert_uncommitted_changes(render_context) 20 | existing_files, existing_files_content = ImplementationCodeHelpers.fetch_existing_files(render_context) 21 | 22 | if render_context.args.verbose: 23 | msg = f"Rendering functional requirement {render_context.frid_context.frid}" 24 | if render_context.frid_context.functional_requirement_render_attempts > 1: 25 | msg += f", attempt number {render_context.frid_context.functional_requirement_render_attempts}/{MAX_CODE_GENERATION_RETRIES}." 26 | msg += f"\n[b]{render_context.frid_context.functional_requirement_text}[/b]" 27 | console.info("\n-------------------------------------") 28 | console.info(msg) 29 | console.info("-------------------------------------\n") 30 | 31 | try: 32 | if render_context.args.verbose: 33 | render_utils.print_inputs( 34 | render_context, existing_files_content, "Files sent as input to code generation:" 35 | ) 36 | 37 | with console.status( 38 | f"[{console.INFO_STYLE}]Generating functional requirement {render_context.frid_context.frid}...\n" 39 | ): 40 | response_files = render_context.codeplain_api.render_functional_requirement( 41 | render_context.frid_context.frid, 42 | render_context.plain_source_tree, 43 | render_context.frid_context.linked_resources, 44 | existing_files_content, 45 | render_context.run_state, 46 | ) 47 | except FunctionalRequirementTooComplex as e: 48 | error_message = f"The functional requirement:\n[b]{render_context.frid_context.functional_requirement_text}[/b]\n is too complex to be implemented. Please break down the functional requirement into smaller parts ({str(e)})." 49 | if e.proposed_breakdown: 50 | error_message += "\nProposed breakdown:" 51 | for _, part in e.proposed_breakdown.items(): 52 | error_message += f"\n - {part}" 53 | 54 | return self.FUNCTIONAL_REQUIREMENT_TOO_COMPLEX_OUTCOME, error_message 55 | 56 | _, changed_files = file_utils.update_build_folder_with_rendered_files( 57 | render_context.args.build_folder, existing_files, response_files 58 | ) 59 | render_context.frid_context.changed_files.update(changed_files) 60 | 61 | if render_context.args.verbose: 62 | console.print_files( 63 | "Files generated or updated:", 64 | render_context.args.build_folder, 65 | response_files, 66 | style=console.OUTPUT_STYLE, 67 | ) 68 | 69 | return self.SUCCESSFUL_OUTCOME, None 70 | -------------------------------------------------------------------------------- /docs/starting_a_plain_project_from_scratch.md: -------------------------------------------------------------------------------- 1 | # How to Start a New Plain Project from Scratch 2 | 3 | This guide will walk you through creating your first Plain project from scratch. 4 | It assumes you have already: 5 | 6 | ✅ Met all [prerequisites](../README.md#prerequisites), 7 | ✅ Completed the [installation steps](../README.md/#installation-steps), 8 | ✅ Successfully rendered your [first example](../README.md#quick-start). 9 | 10 | If you haven't done so yet, please refer to [README](../README.md). 11 | 12 | After following this guide, you'll be equipped to turn your ideas into working code with Plain. 13 | 14 | ## Project Structure Overview 15 | 16 | Every Plain project follows this basic structure: 17 | 18 | ``` 19 | my-new-project/ 20 | ├── my_app.plain # Your application specification 21 | ├── config.yaml # CLI configuration 22 | ├── run_unittests_[language].sh # Unit test script 23 | ├── run_conformance_tests_[language].sh # Conformance test script 24 | ├── build/ # Generated 25 | └── conformance_tests/ # Generated 26 | ``` 27 | 28 | In this guide we will cover how to create each of these step by step. 29 | 30 | ## 1. Define Your .plain File 31 | 32 | Create a `.plain` file. The following example shows how to specify the array sorting problem. For more details, see [Plain language specifications](plain_language_specification.md). 33 | 34 | **Example: `array_sorting.plain`** 35 | ```plain 36 | {% include "python-console-app-template.plain", main_executable_file_name: "array_sorting.py" %} 37 | 38 | ***Definitions:*** 39 | - The Array is an array of integers received as input. 40 | 41 | ***Functional Requirements:*** 42 | - The App should be extended to receive The Array 43 | - Sort The Array. 44 | - Display The Array. 45 | 46 | ***Acceptance Tests:*** 47 | - When given input "5 2 8 1 9", The App should output "1 2 5 8 9" 48 | - When given input "1 2 3 4 5", The App should output "1 2 3 4 5" 49 | 50 | ``` 51 | 52 | - When including templates, use `--full-plain` flag to preview the complete specification including all template content before rendering. You can find predefined templates in [standard template library](../standard_template_library/). (This flag can be configured in your config file.) 53 | 54 | ## 2. Add Test Scripts 55 | 56 | Include the appropriate test scripts to your project: 57 | 58 | ```bash 59 | cp /path/to/plain2code_client/test_scripts/run_unittests_python.sh ./ 60 | cp /path/to/plain2code_client/test_scripts/run_conformance_tests_python.sh ./ 61 | ``` 62 | - You may need to modify these scripts based on your specific project requirements. 63 | 64 | ## 3. Configure Parameters 65 | 66 | Create a `config.yaml` (default name, which you can change with `--config-name` argument in the file) file to configure the plain2code CLI parameters. 67 | 68 | Example of a basic `config.yaml` file: 69 | 70 | ```yaml 71 | 72 | unittests-script: ./run_unittests_python.sh 73 | conformance-tests-script: ./run_conformance_tests_python.sh 74 | verbose: true 75 | 76 | ``` 77 | - Specify the test scripts so that Plain knows how to run unit and conformance tests. 78 | - Indicate whether to display detailed output during code generation like shown in output control. 79 | - For additional options and advanced configuration, see the [plain2code CLI documentation](plain2code_cli.md). 80 | 81 | ## 4. Generate & Run Your Project 82 | 83 | ```bash 84 | python ../plain2code_client/plain2code.py my_app.plain 85 | ``` 86 | - Generated code will appear in build/ and conformance_tests/. 87 | 88 | 89 | ## 5. Notes 90 | - `build/` and `conformance_tests/` folders are generated automatically 91 | - These folders are excluded from git via `.gitignore` 92 | - `dist/` and `dist_conformance_tests/` are created if you set `copy-build: true` and `copy-conformance-tests: true` in your config.yaml 93 | - Always review generated code before using in production 94 | - The `.plain` file is your source of truth - keep it well-documented and version-controlled 95 | -------------------------------------------------------------------------------- /plain2code_console.py: -------------------------------------------------------------------------------- 1 | import tiktoken 2 | from rich.console import Console 3 | from rich.style import Style 4 | from rich.tree import Tree 5 | 6 | 7 | class Plain2CodeConsole(Console): 8 | INFO_STYLE = Style() 9 | WARNING_STYLE = Style(color="yellow", bold=True) 10 | ERROR_STYLE = Style(color="red", bold=True) 11 | INPUT_STYLE = Style(color="#4169E1") # Royal Blue 12 | OUTPUT_STYLE = Style(color="green") 13 | DEBUG_STYLE = Style(color="purple") 14 | 15 | def __init__(self): 16 | super().__init__() 17 | self.llm_encoding = tiktoken.get_encoding("cl100k_base") 18 | 19 | def info(self, *args, **kwargs): 20 | super().print(*args, **kwargs, style=self.INFO_STYLE) 21 | 22 | def warning(self, *args, **kwargs): 23 | super().print(*args, **kwargs, style=self.WARNING_STYLE) 24 | 25 | def error(self, *args, **kwargs): 26 | super().print(*args, **kwargs, style=self.ERROR_STYLE) 27 | 28 | def input(self, *args, **kwargs): 29 | super().print(*args, **kwargs, style=self.INPUT_STYLE) 30 | 31 | def output(self, *args, **kwargs): 32 | super().print(*args, **kwargs, style=self.OUTPUT_STYLE) 33 | 34 | def debug(self, *args, **kwargs): 35 | super().print(*args, **kwargs, style=self.DEBUG_STYLE) 36 | 37 | def print_list(self, items, style=None): 38 | for item in items: 39 | super().print(f"{item}", style=style) 40 | 41 | def print_files(self, header, root_folder, files, style=None): 42 | if not files: 43 | return 44 | 45 | tree = self._create_tree_from_files(root_folder, files) 46 | super().print(f"\n[b]{header}[/b]", style=style) 47 | 48 | super().print(tree, style=style) 49 | 50 | super().print() 51 | 52 | def _create_tree_from_files(self, root_folder, files): 53 | """ 54 | Creates a Tree structure from a dictionary of files using the rich library. 55 | 56 | Args: 57 | files (dict): A dictionary where keys are file paths (strings) 58 | and values are file content (strings). 59 | 60 | Returns: 61 | Tree: The root of the created tree structure. 62 | """ 63 | tree = Tree(root_folder) 64 | for path, content in files.items(): 65 | parts = path.split("/") 66 | current_level = tree 67 | for part in parts: 68 | existing_level = None 69 | for child in current_level.children: 70 | if child.label == part: 71 | existing_level = child 72 | break 73 | 74 | if existing_level is None: 75 | if part == parts[-1]: 76 | if files[path] is None: 77 | current_level = current_level.add(f"{part} [red]deleted[/red]") 78 | else: 79 | file_lines = len(content.splitlines()) 80 | file_tokens = len(self.llm_encoding.encode(content)) 81 | current_level = current_level.add( 82 | f"{part} [b]({file_lines} lines, {file_tokens} tokens)[/b]" 83 | ) 84 | else: 85 | current_level = current_level.add(part) 86 | else: 87 | current_level = existing_level 88 | 89 | return tree 90 | 91 | def print_resources(self, resources_list, linked_resources): 92 | if len(resources_list) == 0: 93 | console.input("\nNo linked resources found.") 94 | return 95 | 96 | self.input("\nLinked resources:") 97 | for resource_name in resources_list: 98 | if resource_name["target"] in linked_resources: 99 | file_tokens = len(self.llm_encoding.encode(linked_resources[resource_name["target"]])) 100 | console.input( 101 | f"- {resource_name['text']} [b][#4169E1]({resource_name['target']}, {file_tokens} tokens)[/#4169E1][/b]" 102 | ) 103 | 104 | self.input() 105 | 106 | 107 | console = Plain2CodeConsole() 108 | -------------------------------------------------------------------------------- /plain2code_nodes.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Mapping, Sequence, TextIO 3 | 4 | from liquid2 import Environment, RenderContext, Template, TemplateNotFoundError 5 | from liquid2.builtin import IncludeTag 6 | from liquid2.builtin.tags.include_tag import IncludeNode 7 | 8 | 9 | class Plain2CodeIncludeNode(IncludeNode): 10 | def render_to_output(self, context: RenderContext, buffer: TextIO) -> int: 11 | """Render the node to the output buffer.""" 12 | name = self.name.evaluate(context) 13 | whitespaces = 0 14 | is_comment = False 15 | i = self.token.start 16 | while self.token.source[i] != "\n" and i >= 0: 17 | if self.token.source[i] == " ": 18 | whitespaces += 1 19 | elif self.token.source[i] == ">": 20 | is_comment = True 21 | break 22 | else: 23 | whitespaces = 0 24 | i -= 1 25 | 26 | if is_comment: 27 | return buffer.write(str(self)) 28 | 29 | try: 30 | template = context.env.get_template(str(name), context=context, tag=self.tag, whitespaces=whitespaces) 31 | except TemplateNotFoundError as err: 32 | err.token = self.name.token 33 | err.template_name = context.template.full_name() 34 | raise 35 | 36 | namespace: dict[str, object] = dict(arg.evaluate(context) for arg in self.args) 37 | 38 | character_count = 0 39 | 40 | with context.extend(namespace, template=template): 41 | if self.var: 42 | val = self.var.evaluate(context) 43 | key = self.alias or template.name.split(".")[0] 44 | 45 | if isinstance(val, Sequence) and not isinstance(val, str): 46 | context.raise_for_loop_limit(len(val)) 47 | for itm in val: 48 | namespace[key] = itm 49 | character_count += template.render_with_context(context, buffer, partial=True) 50 | else: 51 | namespace[key] = val 52 | character_count = template.render_with_context(context, buffer, partial=True) 53 | else: 54 | 55 | character_count = template.render_with_context(context, buffer, partial=True) 56 | 57 | return character_count 58 | 59 | 60 | class Plain2CodeIncludeTag(IncludeTag): 61 | node_class = Plain2CodeIncludeNode 62 | 63 | 64 | class Plain2CodeLoaderMixin: 65 | def __init__(self, *args, **kwargs): 66 | if not hasattr(self, "get_source"): 67 | raise NotImplementedError("Class must implement get_source") 68 | super().__init__(*args, **kwargs) 69 | 70 | def load( 71 | self, 72 | env: Environment, 73 | name: str, 74 | *, 75 | globals: Mapping[str, object] | None = None, 76 | context: RenderContext | None = None, 77 | **kwargs: object, 78 | ) -> Template: 79 | """ 80 | Find and parse template source code. 81 | 82 | Args: 83 | env: The `Environment` attempting to load the template source text. 84 | name: A name or identifier for a template's source text. 85 | globals: A mapping of render context variables attached to the 86 | resulting template. 87 | context: An optional render context that can be used to narrow the template 88 | source search space. 89 | kwargs: Arbitrary arguments that can be used to narrow the template source 90 | search space. 91 | """ 92 | source, full_name, uptodate, matter = self.get_source(env, name, context=context, **kwargs) 93 | whitespaces = kwargs.get("whitespaces", 0) 94 | assert isinstance(whitespaces, int) 95 | source = source.rstrip().replace("\n", "\n" + " " * whitespaces) 96 | 97 | path = Path(full_name) 98 | 99 | template = env.from_string( 100 | source, 101 | name=path.name, 102 | path=path, 103 | globals=globals, 104 | overlay_data=matter, 105 | ) 106 | 107 | template.uptodate = uptodate 108 | return template 109 | -------------------------------------------------------------------------------- /render_machine/render_utils.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import tempfile 3 | import time 4 | 5 | import file_utils 6 | import git_utils 7 | import plain_spec 8 | from plain2code_console import console 9 | 10 | SCRIPT_EXECUTION_TIMEOUT = 120 11 | TIMEOUT_ERROR_EXIT_CODE = 124 12 | 13 | 14 | def revert_uncommitted_changes(render_context): 15 | if render_context.frid_context.frid is not None: 16 | previous_frid = plain_spec.get_previous_frid(render_context.plain_source_tree, render_context.frid_context.frid) 17 | git_utils.revert_to_commit_with_frid(render_context.args.build_folder, previous_frid) 18 | 19 | 20 | def print_inputs(render_context, existing_files_content, message): 21 | tmp_resources_list = [] 22 | plain_spec.collect_linked_resources( 23 | render_context.plain_source_tree, 24 | tmp_resources_list, 25 | [ 26 | plain_spec.DEFINITIONS, 27 | plain_spec.NON_FUNCTIONAL_REQUIREMENTS, 28 | plain_spec.FUNCTIONAL_REQUIREMENTS, 29 | ], 30 | False, 31 | render_context.frid_context.frid, 32 | ) 33 | console.print_resources(tmp_resources_list, render_context.frid_context.linked_resources) 34 | 35 | console.print_files( 36 | message, 37 | render_context.args.build_folder, 38 | existing_files_content, 39 | style=console.INPUT_STYLE, 40 | ) 41 | 42 | 43 | def execute_script(script, scripts_args, verbose, script_type): 44 | try: 45 | start_time = time.time() 46 | result = subprocess.run( 47 | [file_utils.add_current_path_if_no_path(script)] + scripts_args, 48 | stdout=subprocess.PIPE, 49 | stderr=subprocess.STDOUT, 50 | text=True, 51 | timeout=SCRIPT_EXECUTION_TIMEOUT, 52 | ) 53 | elapsed_time = time.time() - start_time 54 | # Log the info about the script execution 55 | if verbose: 56 | with tempfile.NamedTemporaryFile(mode="w+", delete=False, suffix=".script_output") as temp_file: 57 | temp_file.write(f"\n═════════════════════════ {script_type} Script Output ═════════════════════════\n") 58 | temp_file.write(result.stdout) 59 | temp_file.write("\n══════════════════════════════════════════════════════════════════════\n") 60 | temp_file_path = temp_file.name 61 | if result.returncode != 0: 62 | temp_file.write(f"{script_type} script {script} failed with exit code {result.returncode}.\n") 63 | else: 64 | temp_file.write(f"{script_type} script {script} successfully passed.\n") 65 | temp_file.write(f"{script_type} script execution time: {elapsed_time:.2f} seconds.\n") 66 | 67 | console.info(f"[b]{script_type} script output stored in: {temp_file_path}[/b]") 68 | 69 | if result.returncode != 0: 70 | console.info( 71 | f"[b]The {script_type} script has failed. Initiating the patching mode to automatically correct the discrepancies.[/b]\n" 72 | ) 73 | else: 74 | console.info(f"[b]All {script_type} script passed successfully.[/b]\n") 75 | 76 | return result.returncode, result.stdout 77 | except subprocess.TimeoutExpired as e: 78 | # Store timeout output in a temporary file 79 | if verbose: 80 | with tempfile.NamedTemporaryFile(mode="w+", delete=False, suffix=".script_timeout") as temp_file: 81 | temp_file.write(f"{script_type} script {script} timed out after {SCRIPT_EXECUTION_TIMEOUT} seconds.") 82 | if e.stdout: 83 | decoded_output = e.stdout.decode("utf-8") if isinstance(e.stdout, bytes) else e.stdout 84 | temp_file.write(f"{script_type} script partial output before the timeout:\n{decoded_output}") 85 | else: 86 | temp_file.write(f"{script_type} script did not produce any output before the timeout.") 87 | temp_file_path = temp_file.name 88 | console.warning( 89 | f"The {script_type} script timed out after {SCRIPT_EXECUTION_TIMEOUT} seconds. {script_type} script output stored in: {temp_file_path}\n" 90 | ) 91 | 92 | return TIMEOUT_ERROR_EXIT_CODE, f"{script_type} script did not finish in {SCRIPT_EXECUTION_TIMEOUT} seconds." 93 | -------------------------------------------------------------------------------- /docs/plain2code_cli.md: -------------------------------------------------------------------------------- 1 | # Plain2Code CLI Reference 2 | 3 | ```text 4 | usage: generate_cli.py [-h] [--verbose] [--base-folder BASE_FOLDER] [--build-folder BUILD_FOLDER] [--config-name CONFIG_NAME] 5 | [--render-range RENDER_RANGE | --render-from RENDER_FROM] [--unittests-script UNITTESTS_SCRIPT] 6 | [--conformance-tests-folder CONFORMANCE_TESTS_FOLDER] [--conformance-tests-script CONFORMANCE_TESTS_SCRIPT] 7 | [--api [API]] [--api-key API_KEY] [--full-plain] [--dry-run] [--replay-with REPLAY_WITH] 8 | [--template-dir TEMPLATE_DIR] [--copy-build] [--build-dest BUILD_DEST] [--copy-conformance-tests] 9 | [--conformance-tests-dest CONFORMANCE_TESTS_DEST] 10 | filename 11 | 12 | Render plain code to target code. 13 | 14 | positional arguments: 15 | filename Path to the plain file to render. The directory containing this file has highest precedence for template 16 | loading, so you can place custom templates here to override the defaults. See --template-dir for more 17 | details about template loading. 18 | 19 | options: 20 | -h, --help show this help message and exit 21 | --verbose, -v Enable verbose output 22 | --base-folder BASE_FOLDER 23 | Base folder for the build files 24 | --build-folder BUILD_FOLDER 25 | Folder for build files 26 | --render-range RENDER_RANGE 27 | Specify a range of functional requirements to render (e.g. '1.1,2.3'). Use comma to separate start and end 28 | IDs. If only one ID is provided, only that requirement is rendered. Range is inclusive of both start and 29 | end IDs. 30 | --render-from RENDER_FROM 31 | Continue generation starting from this specific functional requirement (e.g. '2.1'). The requirement with 32 | this ID will be included in the output. The ID must match one of the functional requirements in your plain 33 | file. 34 | --unittests-script UNITTESTS_SCRIPT 35 | Shell script to run unit tests on generated code. Receives the build folder path as its first argument 36 | (default: 'build'). 37 | --conformance-tests-folder CONFORMANCE_TESTS_FOLDER 38 | Folder for conformance test files 39 | --conformance-tests-script CONFORMANCE_TESTS_SCRIPT 40 | Path to conformance tests shell script. The conformance tests shell script should accept the build folder 41 | path (containing generated source code) as its first argument and the conformance tests folder path 42 | (containing test files) as its second argument. 43 | --api [API] Alternative base URL for the API. Default: `https://api.codeplain.ai` 44 | --api-key API_KEY API key used to access the API. If not provided, the CLAUDE_API_KEY environment variable is used. 45 | --full-plain Display the complete plain specification before code generation. This shows your plain file with any 46 | included template content expanded. Useful for understanding what content is being processed. 47 | --dry-run Preview of what Codeplain would do without actually making any changes. 48 | --replay-with REPLAY_WITH 49 | --template-dir TEMPLATE_DIR 50 | Path to a custom template directory. Templates are searched in the following order: 1) directory containing 51 | the plain file, 2) this custom template directory (if provided), 3) built-in standard_template_library 52 | directory 53 | --copy-build If set, copy the build folder to `--build-dest` after every successful rendering. 54 | --build-dest BUILD_DEST 55 | Target folder to copy build output to (used only if --copy-build is set). 56 | --copy-conformance-tests 57 | If set, copy the conformance tests folder to `--conformance-tests-dest` after every successful rendering. 58 | Requires --conformance-tests-script. 59 | --conformance-tests-dest CONFORMANCE_TESTS_DEST 60 | Target folder to copy conformance tests output to (used only if --copy-conformance-tests is set). 61 | 62 | configuration: 63 | --config-name CONFIG_NAME 64 | Path to the config file, defaults to config.yaml 65 | 66 | ``` -------------------------------------------------------------------------------- /render_machine/actions/fix_conformance_test.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import file_utils 4 | import plain_spec 5 | from plain2code_console import console 6 | from plain2code_exceptions import UnexpectedState 7 | from render_machine.actions.base_action import BaseAction 8 | from render_machine.conformance_test_helpers import ConformanceTestHelpers 9 | from render_machine.implementation_code_helpers import ImplementationCodeHelpers 10 | from render_machine.render_context import RenderContext 11 | 12 | 13 | class FixConformanceTest(BaseAction): 14 | IMPLEMENTATION_CODE_NOT_UPDATED = "implementation_code_not_updated" 15 | IMPLEMENTATION_CODE_UPDATED = "implementation_code_updated" 16 | 17 | def execute(self, render_context: RenderContext, previous_action_payload: Any | None): 18 | console.info( 19 | f"Fixing conformance test for functional requirement {render_context.conformance_tests_running_context.current_testing_frid}." 20 | ) 21 | 22 | if not previous_action_payload.get("previous_conformance_tests_issue"): 23 | raise UnexpectedState("Previous action payload does not contain previous conformance tests issue.") 24 | previous_conformance_tests_issue = previous_action_payload["previous_conformance_tests_issue"] 25 | 26 | if render_context.conformance_tests_running_context.current_testing_frid == render_context.frid_context.frid: 27 | console_message = f"Fixing conformance test for functional requirement {render_context.conformance_tests_running_context.current_testing_frid}." 28 | else: 29 | console_message = f"While implementing functional requirement {render_context.frid_context.frid}, conformance tests for functional requirement {render_context.conformance_tests_running_context.current_testing_frid} broke. Fixing them..." 30 | 31 | existing_files, existing_files_content = ImplementationCodeHelpers.fetch_existing_files(render_context) 32 | ( 33 | existing_conformance_test_files, 34 | existing_conformance_test_files_content, 35 | ) = ConformanceTestHelpers.fetch_existing_conformance_test_files( 36 | render_context.conformance_tests_running_context # type: ignore 37 | ) 38 | previous_frid_code_diff = ImplementationCodeHelpers.get_code_diff(render_context) 39 | 40 | if render_context.args.verbose: 41 | tmp_resources_list = [] 42 | plain_spec.collect_linked_resources( 43 | render_context.plain_source_tree, 44 | tmp_resources_list, 45 | None, 46 | False, 47 | render_context.frid_context.frid, 48 | ) 49 | console.print_resources(tmp_resources_list, render_context.frid_context.linked_resources) 50 | 51 | console.print_files( 52 | "Implementation files sent as input for fixing conformance tests issues:", 53 | render_context.args.build_folder, 54 | existing_files_content, 55 | style=console.INPUT_STYLE, 56 | ) 57 | 58 | console.print_files( 59 | "Conformance tests files sent as input for fixing conformance tests issues:", 60 | ConformanceTestHelpers.get_current_conformance_test_folder_name( 61 | render_context.conformance_tests_running_context # type: ignore 62 | ), 63 | existing_conformance_test_files_content, 64 | style=console.INPUT_STYLE, 65 | ) 66 | 67 | acceptance_tests = ConformanceTestHelpers.get_current_acceptance_tests( 68 | render_context.conformance_tests_running_context # type: ignore 69 | ) 70 | conformance_tests_folder_name = ConformanceTestHelpers.get_current_conformance_test_folder_name( 71 | render_context.conformance_tests_running_context # type: ignore 72 | ) 73 | with console.status(console_message): 74 | [conformance_tests_fixed, response_files] = render_context.codeplain_api.fix_conformance_tests_issue( 75 | render_context.frid_context.frid, 76 | render_context.conformance_tests_running_context.current_testing_frid, 77 | render_context.plain_source_tree, 78 | render_context.frid_context.linked_resources, 79 | existing_files_content, 80 | previous_frid_code_diff, 81 | existing_conformance_test_files_content, 82 | acceptance_tests, 83 | previous_conformance_tests_issue, 84 | render_context.conformance_tests_running_context.fix_attempts, 85 | conformance_tests_folder_name, 86 | render_context.conformance_tests_running_context.current_testing_frid_high_level_implementation_plan, 87 | render_context.run_state, 88 | ) 89 | 90 | if conformance_tests_fixed: 91 | file_utils.store_response_files( 92 | ConformanceTestHelpers.get_current_conformance_test_folder_name( 93 | render_context.conformance_tests_running_context # type: ignore 94 | ), 95 | response_files, 96 | existing_conformance_test_files, 97 | ) 98 | if render_context.args.verbose: 99 | console.print_files( 100 | "Conformance test files fixed:", 101 | ConformanceTestHelpers.get_current_conformance_test_folder_name( 102 | render_context.conformance_tests_running_context # type: ignore 103 | ), 104 | response_files, 105 | style=console.OUTPUT_STYLE, 106 | ) 107 | return self.IMPLEMENTATION_CODE_NOT_UPDATED, None 108 | else: 109 | if len(response_files) > 0: 110 | file_utils.store_response_files(render_context.args.build_folder, response_files, existing_files) 111 | if render_context.args.verbose: 112 | console.print_files( 113 | "Files fixed:", 114 | render_context.args.build_folder, 115 | response_files, 116 | style=console.OUTPUT_STYLE, 117 | ) 118 | render_context.conformance_tests_running_context.should_prepare_testing_environment = True 119 | return self.IMPLEMENTATION_CODE_UPDATED, None 120 | else: 121 | return self.IMPLEMENTATION_CODE_NOT_UPDATED, None 122 | -------------------------------------------------------------------------------- /render_machine/actions/render_conformance_tests.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Any 3 | 4 | import file_utils 5 | import plain_spec 6 | from plain2code_console import console 7 | from render_machine.actions.base_action import BaseAction 8 | from render_machine.conformance_test_helpers import ConformanceTestHelpers 9 | from render_machine.implementation_code_helpers import ImplementationCodeHelpers 10 | from render_machine.render_context import RenderContext 11 | 12 | 13 | class RenderConformanceTests(BaseAction): 14 | SUCCESSFUL_OUTCOME = "conformance_test_rendered" 15 | 16 | def execute(self, render_context: RenderContext, _previous_action_payload: Any | None): 17 | if self._should_render_conformance_tests(render_context): 18 | return self._render_conformance_tests(render_context) 19 | else: 20 | return self._render_acceptance_test(render_context) 21 | 22 | def _should_render_conformance_tests(self, render_context: RenderContext) -> bool: 23 | return render_context.conformance_tests_running_context.conformance_test_phase_index == 0 24 | 25 | def _render_conformance_tests(self, render_context: RenderContext): 26 | existing_conformance_test_folder_names = ConformanceTestHelpers.fetch_existing_conformance_test_folder_names( 27 | render_context.args.conformance_tests_folder 28 | ) 29 | if render_context.args.verbose: 30 | console.info("\n[b]Implementing test requirements:[/b]") 31 | console.print_list( 32 | render_context.conformance_tests_running_context.current_testing_frid_specifications[ 33 | plain_spec.TEST_REQUIREMENTS 34 | ], 35 | style=console.INFO_STYLE, 36 | ) 37 | console.info() 38 | if not ConformanceTestHelpers.current_conformance_tests_exist(render_context.conformance_tests_running_context): # type: ignore 39 | with console.status( 40 | f"[{console.INFO_STYLE}]Generating folder name for conformance tests for functional requirement {render_context.conformance_tests_running_context.current_testing_frid}...\n" 41 | ): 42 | fr_subfolder_name = render_context.codeplain_api.generate_folder_name_from_functional_requirement( 43 | frid=render_context.conformance_tests_running_context.current_testing_frid, 44 | functional_requirement=render_context.conformance_tests_running_context.current_testing_frid_specifications[ 45 | plain_spec.FUNCTIONAL_REQUIREMENTS 46 | ][ 47 | -1 48 | ], 49 | existing_folder_names=existing_conformance_test_folder_names, 50 | run_state=render_context.run_state, 51 | ) 52 | 53 | conformance_tests_folder_name = os.path.join( 54 | render_context.args.conformance_tests_folder, fr_subfolder_name 55 | ) 56 | 57 | if render_context.args.verbose: 58 | console.info(f"Storing conformance test files in subfolder {conformance_tests_folder_name}/") 59 | 60 | render_context.conformance_tests_running_context.conformance_tests_json[ 61 | render_context.conformance_tests_running_context.current_testing_frid 62 | ] = { 63 | "folder_name": conformance_tests_folder_name, 64 | "functional_requirement": render_context.frid_context.specifications[ 65 | plain_spec.FUNCTIONAL_REQUIREMENTS 66 | ][-1], 67 | } 68 | else: 69 | conformance_tests_folder_name = ConformanceTestHelpers.get_current_conformance_test_folder_name( 70 | render_context.conformance_tests_running_context # type: ignore 71 | ) 72 | 73 | _, existing_files_content = ImplementationCodeHelpers.fetch_existing_files(render_context) 74 | if render_context.args.verbose: 75 | tmp_resources_list = [] 76 | plain_spec.collect_linked_resources( 77 | render_context.plain_source_tree, 78 | tmp_resources_list, 79 | [ 80 | plain_spec.DEFINITIONS, 81 | plain_spec.TEST_REQUIREMENTS, 82 | plain_spec.FUNCTIONAL_REQUIREMENTS, 83 | ], 84 | False, 85 | render_context.frid_context.frid, 86 | ) 87 | console.print_resources(tmp_resources_list, render_context.frid_context.linked_resources) 88 | 89 | console.print_files( 90 | "Files sent as input for generating conformance tests:", 91 | render_context.args.build_folder, 92 | existing_files_content, 93 | style=console.INPUT_STYLE, 94 | ) 95 | 96 | all_acceptance_tests = render_context.frid_context.specifications.get(plain_spec.ACCEPTANCE_TESTS, []) 97 | with console.status( 98 | f"[{console.INFO_STYLE}]Rendering conformance test for functional requirement {render_context.conformance_tests_running_context.current_testing_frid}...\n" 99 | ): 100 | response_files, implementation_plan_summary = render_context.codeplain_api.render_conformance_tests( 101 | render_context.frid_context.frid, 102 | render_context.conformance_tests_running_context.current_testing_frid, 103 | render_context.plain_source_tree, 104 | render_context.frid_context.linked_resources, 105 | existing_files_content, 106 | conformance_tests_folder_name, 107 | render_context.conformance_tests_running_context.conformance_tests_json, 108 | all_acceptance_tests, 109 | render_context.run_state, 110 | ) 111 | 112 | render_context.conformance_tests_running_context.current_testing_frid_high_level_implementation_plan = ( 113 | implementation_plan_summary 114 | ) 115 | 116 | file_utils.store_response_files(conformance_tests_folder_name, response_files, []) 117 | 118 | if render_context.args.verbose: 119 | console.print_files( 120 | "Conformance test files generated:", 121 | conformance_tests_folder_name, 122 | response_files, 123 | style=console.OUTPUT_STYLE, 124 | ) 125 | 126 | return self.SUCCESSFUL_OUTCOME, None 127 | 128 | def _render_acceptance_test(self, render_context: RenderContext): 129 | _, existing_files_content = ImplementationCodeHelpers.fetch_existing_files(render_context) 130 | ( 131 | conformance_tests_files, 132 | conformance_tests_files_content, 133 | ) = ConformanceTestHelpers.fetch_existing_conformance_test_files( 134 | render_context.conformance_tests_running_context # type: ignore 135 | ) 136 | 137 | acceptance_test = render_context.frid_context.specifications[plain_spec.ACCEPTANCE_TESTS][ 138 | render_context.conformance_tests_running_context.conformance_test_phase_index - 1 139 | ] 140 | 141 | if render_context.args.verbose: 142 | console.info("\n[b]Generating acceptance test:[/b]") 143 | console.info(f"[b]{acceptance_test}[/b]") 144 | console.info() 145 | 146 | with console.status( 147 | f"[{console.INFO_STYLE}]Generating acceptance test for functional requirement {render_context.frid_context.frid}...\n" 148 | ): 149 | response_files = render_context.codeplain_api.render_acceptance_tests( 150 | render_context.frid_context.frid, 151 | render_context.plain_source_tree, 152 | render_context.frid_context.linked_resources, 153 | existing_files_content, 154 | conformance_tests_files_content, 155 | acceptance_test, 156 | render_context.run_state, 157 | ) 158 | conformance_tests_folder_name = ConformanceTestHelpers.get_current_conformance_test_folder_name( 159 | render_context.conformance_tests_running_context # type: ignore 160 | ) 161 | 162 | file_utils.store_response_files(conformance_tests_folder_name, response_files, conformance_tests_files) 163 | console.print_files( 164 | f"Conformance test files in folder {conformance_tests_folder_name} updated:", 165 | conformance_tests_folder_name, 166 | response_files, 167 | style=console.OUTPUT_STYLE, 168 | ) 169 | return self.SUCCESSFUL_OUTCOME, None 170 | -------------------------------------------------------------------------------- /test_scripts/run_conformance_tests_cypress.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | UNRECOVERABLE_ERROR_EXIT_CODE=69 4 | 5 | NPM_INSTALL_OUTPUT_FILTER="up to date in|added [0-9]* packages, removed [0-9]* packages, and changed [0-9]* packages in|removed [0-9]* packages, and changed [0-9]* packages in|added [0-9]* packages in|removed [0-9]* packages in" 6 | 7 | # Function to check and kill any Node process running on port 3000 (React development server) 8 | check_and_kill_node_server() { 9 | # Find process listening on port 3000 10 | local pid=$(lsof -i :3000 -t 2>/dev/null) 11 | if [ ! -z "$pid" ]; then 12 | if ps -p $pid -o comm= | grep -q "node"; then 13 | printf "Found Node server running on port 3000. Killing it...\n" 14 | kill $pid 2>/dev/null 15 | sleep 1 # Give the process time to terminate 16 | if [ "${VERBOSE:-}" -eq 1 ] 2>/dev/null; then 17 | printf "Node server terminated.\n" 18 | fi 19 | fi 20 | fi 21 | } 22 | 23 | # Function to get all child processes of a given PID and store them in a list 24 | get_children() { 25 | local parent_pid=$1 26 | local children=$(pgrep -P $parent_pid) 27 | 28 | for child in $children 29 | do 30 | # Add the child process to the list 31 | processes_to_kill+=($child) 32 | # Recursively find the children of the child process 33 | get_children $child 34 | done 35 | } 36 | 37 | # Cleanup function to ensure all processes are terminated 38 | cleanup() { 39 | # Kill any running npm processes started by this script 40 | if [ ! -z "${NPM_PID+x}" ]; then 41 | pkill -P $NPM_PID 2>/dev/null 42 | kill $NPM_PID 2>/dev/null 43 | fi 44 | 45 | # Kill React app and its children if they exist 46 | if [ ! -z "${REACT_APP_PID+x}" ]; then 47 | local processes_to_kill=() 48 | get_children $REACT_APP_PID 49 | 50 | # Kill the main process 51 | kill $REACT_APP_PID 2>/dev/null 52 | 53 | # Kill all the subprocesses 54 | for pid in "${processes_to_kill[@]}" 55 | do 56 | kill $pid 2>/dev/null 57 | done 58 | 59 | if [ "${VERBOSE:-}" -eq 1 ] 2>/dev/null; then 60 | printf "React app is terminated!\n" 61 | fi 62 | fi 63 | 64 | # Remove temporary files if they exist 65 | [ -f "$build_output" ] && rm "$build_output" 2>/dev/null 66 | } 67 | 68 | # Set up trap to call cleanup function on script exit, interrupt, or termination 69 | trap cleanup EXIT SIGINT SIGTERM 70 | 71 | # Check for and kill any existing Node server from previous runs 72 | check_and_kill_node_server 73 | 74 | # Check if build folder name is provided 75 | if [ -z "$1" ]; then 76 | printf "Error: No build folder name provided.\n" 77 | printf "Usage: $0 \n" 78 | exit $UNRECOVERABLE_ERROR_EXIT_CODE 79 | fi 80 | 81 | # Check if conformance tests folder name is provided 82 | if [ -z "$2" ]; then 83 | printf "Error: No conformance tests folder name provided.\n" 84 | printf "Usage: $0 \n" 85 | exit $UNRECOVERABLE_ERROR_EXIT_CODE 86 | fi 87 | 88 | if [[ "$3" == "-v" || "$3" == "--verbose" ]]; then 89 | VERBOSE=1 90 | fi 91 | 92 | current_dir=$(pwd) 93 | 94 | # Ensures that if any command in the pipeline fails (like npm run build), the entire pipeline 95 | # will return a non-zero status, allowing the if condition to properly catch failures. 96 | set -o pipefail 97 | 98 | # Running React application 99 | printf "### Step 1: Starting the React application in folder $1...\n" 100 | 101 | # Define the path to the subfolder 102 | NODE_SUBFOLDER=node_$1 103 | 104 | if [ "${VERBOSE:-}" -eq 1 ] 2>/dev/null; then 105 | printf "Preparing Node subfolder: $NODE_SUBFOLDER\n" 106 | fi 107 | 108 | # Check if the node subfolder exists 109 | if [ -d "$NODE_SUBFOLDER" ]; then 110 | # Find and delete all files and folders except "node_modules", "build", and "package-lock.json" 111 | find "$NODE_SUBFOLDER" -mindepth 1 ! -path "$NODE_SUBFOLDER/node_modules*" ! -path "$NODE_SUBFOLDER/build*" ! -name "package-lock.json" -exec rm -rf {} + 112 | 113 | if [ "${VERBOSE:-}" -eq 1 ] 2>/dev/null; then 114 | printf "Cleanup completed, keeping 'node_modules' and 'package-lock.json'.\n" 115 | fi 116 | else 117 | if [ "${VERBOSE:-}" -eq 1 ] 2>/dev/null; then 118 | printf "Subfolder does not exist. Creating it...\n" 119 | fi 120 | 121 | mkdir $NODE_SUBFOLDER 122 | fi 123 | 124 | cp -R $1/* $NODE_SUBFOLDER 125 | 126 | # Move to the subfolder 127 | cd "$NODE_SUBFOLDER" 2>/dev/null 128 | 129 | if [ $? -ne 0 ]; then 130 | printf "Error: Node build folder '$NODE_SUBFOLDER' does not exist.\n" 131 | exit $UNRECOVERABLE_ERROR_EXIT_CODE 132 | fi 133 | 134 | npm install --prefer-offline --no-audit --no-fund --loglevel error | grep -Ev "$NPM_INSTALL_OUTPUT_FILTER" 135 | 136 | if [ $? -ne 0 ]; then 137 | printf "Error: Installing Node modules.\n" 138 | exit 2 139 | fi 140 | 141 | if [ "${VERBOSE:-}" -eq 1 ] 2>/dev/null; then 142 | printf "Building the application...\n" 143 | fi 144 | 145 | build_output=$(mktemp) 146 | 147 | npm run build --loglevel silent > "$build_output" 2>&1 148 | 149 | if [ $? -ne 0 ]; then 150 | printf "Error: Building application.\n" 151 | cat "$build_output" 152 | rm "$build_output" 153 | exit 2 154 | fi 155 | 156 | rm "$build_output" 157 | 158 | if [ "${VERBOSE:-}" -eq 1 ] 2>/dev/null; then 159 | printf "Starting the application...\n" 160 | fi 161 | 162 | # Start the React app in the background and redirect output to a log file 163 | BROWSER=none npm start -- --no-open > app.log 2>&1 & 164 | 165 | # Capture the process ID of the npm start command 166 | REACT_APP_PID=$! 167 | NPM_PID=$(pgrep -P $REACT_APP_PID npm) 168 | 169 | # Wait for the "compiled successfully!" message in the log file 170 | while true; do 171 | if grep -iq -E "compiled successfully|compiled with warnings" app.log; then 172 | break 173 | fi 174 | 175 | if curl -s http://localhost:3000 >/dev/null 2>&1; then 176 | break 177 | fi 178 | 179 | # Check if the React app process is still running 180 | if ! ps -p $REACT_APP_PID > /dev/null 2>&1; then 181 | printf "Error in :ImplementationCode: (React app process (PID: $REACT_APP_PID) has terminated unexpectedly).\n" 182 | cat "app.log" 183 | exit 2 184 | fi 185 | 186 | sleep 0.1 187 | done 188 | 189 | # At this point, the React app is up and running in the background 190 | if [ "${VERBOSE:-}" -eq 1 ] 2>/dev/null; then 191 | printf "React app is up and running!\n" 192 | fi 193 | 194 | # Execute all Cypress conformance tests in the build folder 195 | printf "### Step 2: Running Cypress conformance tests $2...\n" 196 | 197 | # Move back to the original directory 198 | cd $current_dir 199 | 200 | # Define the path to the conformance tests subfolder 201 | NODE_CONFORMANCE_TESTS_SUBFOLDER=node_$2 202 | 203 | if [ "${VERBOSE:-}" -eq 1 ] 2>/dev/null; then 204 | printf "Preparing conformance tests Node subfolder: $NODE_CONFORMANCE_TESTS_SUBFOLDER\n" 205 | fi 206 | 207 | # Check if the conformance tests node subfolder exists 208 | if [ -d "$NODE_CONFORMANCE_TESTS_SUBFOLDER" ]; then 209 | # Find and delete all files and folders except "node_modules", "build", and "package-lock.json" 210 | find "$NODE_CONFORMANCE_TESTS_SUBFOLDER" -mindepth 1 ! -path "$NODE_CONFORMANCE_TESTS_SUBFOLDER/node_modules*" ! -path "$NODE_CONFORMANCE_TESTS_SUBFOLDER/build*" ! -name "package-lock.json" -exec rm -rf {} + 211 | 212 | if [ "${VERBOSE:-}" -eq 1 ] 2>/dev/null; then 213 | printf "Cleanup completed, keeping 'node_modules' and 'package-lock.json'.\n" 214 | fi 215 | else 216 | if [ "${VERBOSE:-}" -eq 1 ] 2>/dev/null; then 217 | printf "Subfolder does not exist. Creating it...\n" 218 | fi 219 | 220 | mkdir -p $NODE_CONFORMANCE_TESTS_SUBFOLDER 221 | fi 222 | 223 | cp -R $2/* $NODE_CONFORMANCE_TESTS_SUBFOLDER 224 | 225 | # Move to the subfolder with Cypress tests 226 | cd "$NODE_CONFORMANCE_TESTS_SUBFOLDER" 2>/dev/null 227 | 228 | if [ $? -ne 0 ]; then 229 | printf "Error: conformance tests Node folder '$NODE_CONFORMANCE_TESTS_SUBFOLDER' does not exist.\n" 230 | exit $UNRECOVERABLE_ERROR_EXIT_CODE 231 | fi 232 | 233 | npm install cypress --save-dev --prefer-offline --no-audit --no-fund --loglevel error | grep -Ev "$NPM_INSTALL_OUTPUT_FILTER" 234 | 235 | if [ "${VERBOSE:-}" -eq 1 ] 2>/dev/null; then 236 | printf "Running Cypress conformance tests...\n" 237 | fi 238 | 239 | BROWSERSLIST_IGNORE_OLD_DATA=1 npx cypress run --browser=chrome --config video=false 2>/dev/null 240 | cypress_run_result=$? 241 | 242 | if [ $cypress_run_result -ne 0 ]; then 243 | if [ "${VERBOSE:-}" -eq 1 ] 2>/dev/null; then 244 | printf "Error: Cypress conformance tests have failed.\n" 245 | fi 246 | exit 1 247 | fi -------------------------------------------------------------------------------- /plain2code.py: -------------------------------------------------------------------------------- 1 | import importlib.util 2 | import logging 3 | import logging.config 4 | import os 5 | import traceback 6 | 7 | import yaml 8 | from liquid2.exceptions import TemplateNotFoundError 9 | from requests.exceptions import RequestException 10 | 11 | import file_utils 12 | import plain_spec 13 | from plain2code_arguments import parse_arguments 14 | from plain2code_console import console 15 | from plain2code_state import RunState 16 | from plain2code_utils import RetryOnlyFilter, print_dry_run_output 17 | from render_machine.code_renderer import CodeRenderer 18 | from system_config import system_config 19 | 20 | TEST_SCRIPT_EXECUTION_TIMEOUT = 120 # 120 seconds 21 | LOGGING_CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logging_config.yaml") 22 | 23 | DEFAULT_TEMPLATE_DIRS = "standard_template_library" 24 | 25 | MAX_UNITTEST_FIX_ATTEMPTS = 20 26 | MAX_CONFORMANCE_TEST_FIX_ATTEMPTS = 20 27 | MAX_CONFORMANCE_TEST_RUNS = 20 28 | MAX_REFACTORING_ITERATIONS = 5 29 | MAX_UNIT_TEST_RENDER_RETRIES = 2 30 | 31 | MAX_ISSUE_LENGTH = 10000 # Characters. 32 | 33 | UNRECOVERABLE_ERROR_EXIT_CODES = [69] 34 | TIMEOUT_ERROR_EXIT_CODE = 124 35 | 36 | 37 | class InvalidFridArgument(Exception): 38 | pass 39 | 40 | 41 | def get_render_range(render_range, plain_source_tree): 42 | render_range = render_range.split(",") 43 | range_end = render_range[1] if len(render_range) == 2 else render_range[0] 44 | 45 | return _get_frids_range(plain_source_tree, render_range[0], range_end) 46 | 47 | 48 | def get_render_range_from(start, plain_source_tree): 49 | return _get_frids_range(plain_source_tree, start) 50 | 51 | 52 | def _get_frids_range(plain_source_tree, start, end=None): 53 | frids = list(plain_spec.get_frids(plain_source_tree)) 54 | 55 | start = str(start) 56 | 57 | if start not in frids: 58 | raise InvalidFridArgument(f"Invalid start functional requirement ID: {start}. Valid IDs are: {frids}.") 59 | 60 | if end is not None: 61 | end = str(end) 62 | if end not in frids: 63 | raise InvalidFridArgument(f"Invalid end functional requirement ID: {end}. Valid IDs are: {frids}.") 64 | 65 | end_idx = frids.index(end) + 1 66 | else: 67 | end_idx = len(frids) 68 | 69 | start_idx = frids.index(start) 70 | if start_idx >= end_idx: 71 | raise InvalidFridArgument( 72 | f"Start functional requirement ID: {start} must be before end functional requirement ID: {end}." 73 | ) 74 | 75 | return frids[start_idx:end_idx] 76 | 77 | 78 | class IndentedFormatter(logging.Formatter): 79 | 80 | def format(self, record): 81 | original_message = record.getMessage() 82 | 83 | modified_message = original_message.replace("\n", "\n ") 84 | 85 | record.msg = modified_message 86 | return super().format(record) 87 | 88 | 89 | def render(args, run_state: RunState): # noqa: C901 90 | if args.verbose: 91 | 92 | logging.basicConfig(level=logging.DEBUG) 93 | logging.getLogger("urllib3").setLevel(logging.WARNING) 94 | logging.getLogger("httpx").setLevel(logging.WARNING) 95 | logging.getLogger("httpcore").setLevel(logging.WARNING) 96 | logging.getLogger("anthropic").setLevel(logging.WARNING) 97 | logging.getLogger("langsmith").setLevel(logging.WARNING) 98 | logging.getLogger("git").setLevel(logging.WARNING) 99 | logging.getLogger("anthropic._base_client").setLevel(logging.DEBUG) 100 | logging.getLogger("services.langsmith.langsmith_service").setLevel(logging.INFO) 101 | logging.getLogger("transitions").setLevel(logging.WARNING) 102 | 103 | # Try to load logging configuration from YAML file 104 | if os.path.exists(LOGGING_CONFIG_PATH): 105 | try: 106 | with open(LOGGING_CONFIG_PATH, "r") as f: 107 | config = yaml.safe_load(f) 108 | logging.config.dictConfig(config) 109 | console.info(f"Loaded logging configuration from {LOGGING_CONFIG_PATH}") 110 | except Exception as e: 111 | console.warning(f"Failed to load logging configuration from {LOGGING_CONFIG_PATH}: {str(e)}") 112 | 113 | # if we have debug level for anthropic._base_client to debug, catch only logs relevant to retrying (ones that are relevant for us) 114 | if logging.getLogger("anthropic._base_client").level == logging.DEBUG: 115 | logging.getLogger("anthropic._base_client").addFilter(RetryOnlyFilter()) 116 | 117 | formatter = IndentedFormatter("%(levelname)s:%(name)s:%(message)s") 118 | console_handler = logging.StreamHandler() 119 | console_handler.setFormatter(formatter) 120 | 121 | codeplain_logger = logging.getLogger("codeplain") 122 | codeplain_logger.addHandler(console_handler) 123 | codeplain_logger.propagate = False 124 | 125 | llm_logger = logging.getLogger("llm") 126 | llm_logger.addHandler(console_handler) 127 | llm_logger.propagate = False 128 | 129 | logging.getLogger("repositories").setLevel(logging.INFO) 130 | 131 | # Check system requirements before proceeding 132 | system_config.verify_requirements() 133 | 134 | with open(args.filename, "r") as fin: 135 | plain_source = fin.read() 136 | 137 | template_dirs = file_utils.get_template_directories(args.filename, args.template_dir, DEFAULT_TEMPLATE_DIRS) 138 | 139 | [full_plain_source, loaded_templates] = file_utils.get_loaded_templates(template_dirs, plain_source) 140 | 141 | if args.full_plain: 142 | if args.verbose: 143 | console.info("Full plain text:\n") 144 | 145 | console.info(full_plain_source) 146 | return 147 | 148 | codeplainAPI = codeplain_api.CodeplainAPI(args.api_key, console) 149 | codeplainAPI.verbose = args.verbose 150 | 151 | if args.api: 152 | codeplainAPI.api_url = args.api 153 | 154 | console.info(f"Rendering {args.filename} to target code.") 155 | 156 | plain_source_tree = codeplainAPI.get_plain_source_tree(plain_source, loaded_templates, run_state) 157 | 158 | if args.render_range is not None: 159 | args.render_range = get_render_range(args.render_range, plain_source_tree) 160 | elif args.render_from is not None: 161 | args.render_range = get_render_range_from(args.render_from, plain_source_tree) 162 | 163 | resources_list = [] 164 | plain_spec.collect_linked_resources(plain_source_tree, resources_list, None, True) 165 | 166 | # Handle dry run and full plain here (outside of state machine) 167 | if args.dry_run: 168 | console.info("Printing dry run output...") 169 | print_dry_run_output(plain_source_tree, args.render_range) 170 | return 171 | if args.full_plain: 172 | console.info("Printing full plain output...") 173 | console.info(full_plain_source) 174 | return 175 | 176 | console.info("Using the state machine to render the functional requirement.") 177 | code_renderer = CodeRenderer(codeplainAPI, plain_source_tree, args, run_state) 178 | 179 | if args.render_machine_graph: 180 | code_renderer.generate_render_machine_graph() 181 | return 182 | 183 | code_renderer.run() 184 | return 185 | 186 | 187 | if __name__ == "__main__": # noqa: C901 188 | args = parse_arguments() 189 | 190 | codeplain_api_module_name = "codeplain_local_api" 191 | 192 | codeplain_api_spec = importlib.util.find_spec(codeplain_api_module_name) 193 | if args.api or codeplain_api_spec is None: 194 | if not args.api: 195 | args.api = "https://api.codeplain.ai" 196 | console.debug(f"Running plain2code using REST API at {args.api}.") 197 | import codeplain_REST_api as codeplain_api 198 | else: 199 | if not args.full_plain: 200 | console.debug("Running plain2code using local API.\n") 201 | 202 | codeplain_api = importlib.import_module(codeplain_api_module_name) 203 | 204 | run_state = RunState(spec_filename=args.filename, replay_with=args.replay_with) 205 | console.debug(f"Render ID: {run_state.render_id}") 206 | 207 | try: 208 | render(args, run_state) 209 | except InvalidFridArgument as e: 210 | console.error(f"Error rendering plain code: {str(e)}.\n") 211 | # No need to print render ID since this error is going to be thrown at the very start so user will be able to 212 | # see the render ID that's printed at the very start of the rendering process. 213 | except FileNotFoundError as e: 214 | console.error(f"Error rendering plain code: {str(e)}\n") 215 | console.debug(f"Render ID: {run_state.render_id}") 216 | except TemplateNotFoundError as e: 217 | console.error(f"Error: Template not found: {str(e)}\n") 218 | console.error(system_config.get_error_message("template_not_found")) 219 | except KeyboardInterrupt: 220 | console.error("Keyboard interrupt") 221 | # Don't print the traceback here because it's going to be from keyboard interrupt and we don't really care about that 222 | console.debug(f"Render ID: {run_state.render_id}") 223 | except RequestException as e: 224 | console.error(f"Error rendering plain code: {str(e)}\n") 225 | console.debug(f"Render ID: {run_state.render_id}") 226 | except Exception as e: 227 | console.error(f"Error rendering plain code: {str(e)}\n") 228 | console.debug(f"Render ID: {run_state.render_id}") 229 | traceback.print_exc() 230 | -------------------------------------------------------------------------------- /docs/plain_language_specification.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | ## About ***plain programming language 4 | 5 | ***plain is a novel programming language that helps abstracting away complexity of using large language models for code generation. 6 | 7 | ***plain specification is rendered to software code that can be executed. You can therefore think of ***plain as *executable specification*. 8 | 9 | ## Syntax 10 | 11 | ***plain language is structured English based on markdown syntax. 12 | 13 | Here's an example of a "hello, world" program in ***plain. 14 | 15 | ```plain 16 | ***Non-Functional Requirements:*** 17 | 18 | - :Implementation: should be in Python. 19 | 20 | ***Functional Requirements:*** 21 | 22 | - Display "hello, world" 23 | ``` 24 | 25 | # Source structure 26 | 27 | ## Source organization 28 | 29 | ***plain source can be organized in sections and subsection using markdown headers. 30 | 31 | ```plain 32 | # Section 1 33 | 34 | # Section 2 35 | 36 | ***Non-Functional Requirements:*** 37 | 38 | - First simple non-functional requirement 39 | 40 | ***Functional Requirements:*** 41 | 42 | - First simple functional requirement 43 | 44 | ## Section 2.1 45 | 46 | ***Non-Functional Requirements:*** 47 | 48 | - Second simple non-functional requirement 49 | 50 | ***Functional Requirements:*** 51 | 52 | - Second simple functional requirement 53 | ``` 54 | 55 | This enables hierarchical organization of the specification. 56 | 57 | In example above: 58 | 59 | - While rendering the "First simple functional requirement", the renderer will not have access to the "Second simple non-functional requirement". 60 | - While rendering the "Second simple functional requirement", the renderer will not have access to the "First simple non-functional requirement". 61 | 62 | ### Specifications 63 | 64 | There are four types of specifications: 65 | 66 | - `***Definitions:***` 67 | - `***Non-Functional Requirements:***` 68 | - `***Functional Requirements:***` 69 | - `***Test Requirements:***` 70 | 71 | Every plain source file requires at least one functional requirement and an associated non-functional requirement. 72 | 73 | Functional requirements must reside in leaf sections while other specifications can be placed also in non-leaf sections. Specifications in non-leaf sections apply not just to the section itself but to all of its subsections. 74 | 75 | ## Definitions 76 | 77 | The `***Definitions:***` specification is a list of definitions of new concepts. 78 | 79 | Here's an example of a simple definiton. 80 | 81 | ```plain 82 | - :App: implements a task manager application. 83 | ``` 84 | 85 | In this case, the concept name is `:App:`. Concepts are important for refering to definitions in the rest of the specification. 86 | 87 | While providing definitions, you should adhere to the following 4 rules: 88 | 89 | - Every definition must start with the name of the concept you are defining. 90 | - Each concept name must be enclosed in colons (`:`) at both the beginning and end. 91 | - Valid characters for concept name include: Plus sign (`+`), Minus sign (`-`), Dot sign (`.`), Digits (`0`-`9`), Uppercase letters (`A`-`Z`), Underscore (`_`), Lowercase letters (`a`-`z`) 92 | - Examples: `:App:`, `:Tasks:`, `:ListOfUsers:`, `:CLI:`. 93 | - Concept names must be globally unique (meaning, you cannot provide two definitions with the same concept name). 94 | - When referencing concepts in ***Test Requirements:***, ***Functional Requirements:***, ***Non-Functional Requirements:*** and ***Acceptance Tests:***, the concept name must exist in the ***Definitions:*** section. 95 | 96 | Furthermore, there are special concepts that are already defined and are ready to use. They should not be redefined: 97 | 98 | - `:ConformanceTests:` 99 | - `:UnitTests:` 100 | - `:AcceptanceTests:` 101 | - `:Implementation:` 102 | 103 | Definitions are the mechanism for definining data structures in ***plain. Here's an example of two definitions. 104 | 105 | ```plain 106 | - :User: is a person who uses the application. 107 | 108 | - :Task: describes an activity that needs to be done by :User:. :Task: has the following attributes: 109 | - Name - a short description of :Task:. This is a required attribute. The name must be at least 3 characters long. 110 | - Notes - additional details about :Task: 111 | - Due Date - optional date by which :User: is supposed to complete :Task:. 112 | ``` 113 | 114 | ## Non-Functional Requirements 115 | 116 | The `***Non-Functional Requirements:***` specification is a list of instructions that steer software code implementation and provide details of execution environment. 117 | 118 | Here's an example of a simple instruction specifying only that the Plain specification should be rendered to Python software code. 119 | 120 | ```plain 121 | - :Implementation: should be in Python. 122 | ``` 123 | 124 | The instructions should be provided in natural language. There are no restrictions on the form or the complexity of the instruction except that they need to be given as a markdown list. 125 | 126 | Here's an example of more complex instructions. 127 | 128 | ```plain 129 | - :Implementation: of :App: should be in Python. 130 | 131 | - :Implementation: should include unit tests using Unittest framework. 132 | 133 | - The main executable file of :App: should be called hello_world.py 134 | ``` 135 | 136 | ## Functional Requirements 137 | 138 | The `***Functional Requirements:***` specification provides a description of functionality that should be rendered to software code. The descriptions should be provided in natural language as a markdown list. 139 | 140 | Here's an example of a simple description of the functionality of the "hello, world" application. 141 | 142 | ```plain 143 | - Display "hello, world" 144 | ``` 145 | 146 | Each functional requirement must be limited in complexity. For example, for the functional requirement 147 | 148 | ```plain 149 | - :App: should implement a task manager application. 150 | ``` 151 | 152 | the renderer of Plain source to software code should respond with 153 | 154 | ``` 155 | Functional requirement too complex! 156 | ``` 157 | 158 | In such case you need to break down the functioanlity into smaller, less-complex functional requirements. 159 | 160 | Here's an example how to do such a break down in the case of a task manager application. 161 | 162 | ```plain 163 | - Implement the entry point for :App:. 164 | 165 | - Show :Task: List. 166 | 167 | - :User: should be able to add :Task:. Only valid :Task: items can be added. 168 | 169 | - :User: should be able to delete :Task:. 170 | 171 | - :User: should be able to edit :Task:. 172 | 173 | - :User: should be able to mark :Task: as completed. 174 | ``` 175 | 176 | Functional requirements are rendered incrementally one by one. Consequently earlier functional requirements cannot reference later functional requirements. 177 | 178 | ### Acceptance Tests 179 | 180 | Acceptance tests can be used to further refine the functional requirement and especially to incorporate constraints on the implementation. 181 | 182 | Acceptance tests are specified with a keyword `***Acceptance Tests:***` as a subsection within `***Functional Requirements:***` section. Each acceptance tests must be an item in a list. 183 | 184 | Here's an example of a "Hello, World" application with one acceptance test. 185 | 186 | ```plain 187 | ***Functional Requirements:*** 188 | 189 | - Display "hello, world" 190 | 191 | ***Acceptance Tests:*** 192 | 193 | - :App: shouldn't show logging output in the console output (neither in stdout nor stderr). 194 | ``` 195 | 196 | Acceptance tests extend **conformance tests**. The acceptance tests are implemented according to the ***Test Requirements:*** specification (see next section). 197 | 198 | ## Test Requirements 199 | 200 | The `***Test Requirements:***` specification is a list of instructions that steer implementation of conformance tests and provide details of testing environment. 201 | 202 | **Conformance tests** is the generated code used to verify that the functional requirement is implemented according to the specification. 203 | 204 | Here's an example specification of test requirements. 205 | 206 | ```plain 207 | - :ConformanceTests: of :App: should be implemented in Python using Unittest framework. 208 | ``` 209 | 210 | # Extended Syntax 211 | 212 | ## Comments 213 | 214 | Lines starting with `>` are ignored when rendering software code. 215 | 216 | ```plain 217 | > This is an example of a comment in ***plain 218 | ``` 219 | 220 | ## Template System 221 | 222 | Plain supports template inclusion using the `{% include %}` syntax, which allows you to use predefined templates in your specifications. 223 | 224 | ```plain 225 | {% include "python-console-app-template.plain", main_executable_file_name: "my_app.py" %} 226 | ``` 227 | Predefined templates are available for Go console apps, Python console apps, and TypeScript React apps in the [standard template library](../standard_template_library/). You can also create your own custom templates. 228 | 229 | The template system enables code reuse and standardization across Plain projects. 230 | 231 | ## Linked Resources 232 | 233 | If you include a link using the markdown syntax, the linked resource will be passed along with the Plain specification to the renderer. 234 | 235 | Here's an example of a linked resource (see Task manager example application for the full specification). 236 | 237 | ```plain 238 | - Show :Task: List. The details of the user interface are provided in the file [task_list_ui_specification.yaml](task_list_ui_specification.yaml). 239 | ``` 240 | 241 | **Important Notes:** 242 | - Only links to files in the same folder (and its subfolders) as the Plain specification are supported. Links to external resources are not supported. 243 | - File paths are resolved relative to the location of the Plain specification file. 244 | - All types are supported, except binary files. 245 | 246 | ## Liquid templates 247 | 248 | Plain supports Liquid templates. Liquid is an open-source template language created by Shopify (https://shopify.github.io/liquid/). 249 | 250 | For a sample use of Liquid templates see [example-saas-connectors](https://github.com/Codeplain-ai/example-saas-connectors) repository. 251 | -------------------------------------------------------------------------------- /git_utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Optional, Union 3 | 4 | from git import Repo 5 | 6 | import file_utils 7 | 8 | FUNCTIONAL_REQUIREMENT_IMPLEMENTED_COMMIT_MESSAGE = ( 9 | "[Codeplain] Implemented code and unit tests for functional requirement {}" 10 | ) 11 | REFACTORED_CODE_COMMIT_MESSAGE = "[Codeplain] Refactored code after implementing functional requirement {}" 12 | CONFORMANCE_TESTS_PASSED_COMMIT_MESSAGE = ( 13 | "[Codeplain] Fixed issues in the implementation code identified during conformance testing" 14 | ) 15 | 16 | # Following messages are used as checkpoints in the git history 17 | # Changing them will break backwards compatibility so change them with care 18 | FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE = "[Codeplain] Functional requirement ID (FRID):{} fully implemented" 19 | INITIAL_COMMIT_MESSAGE = "[Codeplain] Initial commit" 20 | BASE_FOLDER_COMMIT_MESSAGE = "[Codeplain] Initialize build with Base Folder content" 21 | 22 | 23 | RENDERED_FRID_MESSAGE = "Changes related to Functional requirement ID (FRID): {}" 24 | RENDER_ID_MESSAGE = "Render ID: {}" 25 | 26 | 27 | class InvalidGitRepositoryError(Exception): 28 | """Raised when the git repository is in an invalid state.""" 29 | 30 | pass 31 | 32 | 33 | def init_git_repo(path_to_repo: Union[str, os.PathLike]) -> Repo: 34 | """ 35 | Initializes a new git repository in the given path. 36 | If folder does not exist, it creates it. 37 | If the folder already exists, it deletes the content of the folder. 38 | """ 39 | if os.path.isdir(path_to_repo): 40 | file_utils.delete_files_and_subfolders(path_to_repo) 41 | else: 42 | os.makedirs(path_to_repo) 43 | 44 | repo = Repo.init(path_to_repo) 45 | repo.git.commit("--allow-empty", "-m", INITIAL_COMMIT_MESSAGE) 46 | 47 | return repo 48 | 49 | 50 | def is_dirty(repo_path: Union[str, os.PathLike]) -> bool: 51 | """Checks if the repository is dirty.""" 52 | repo = Repo(repo_path) 53 | return repo.is_dirty(untracked_files=True) 54 | 55 | 56 | def add_all_files_and_commit( 57 | repo_path: Union[str, os.PathLike], commit_message: str, frid: Optional[str] = None, render_id: Optional[str] = None 58 | ) -> Repo: 59 | """Adds all files to the git repository and commits them.""" 60 | repo = Repo(repo_path) 61 | repo.git.add(".") 62 | 63 | message = f"{commit_message}" 64 | 65 | if frid or render_id: 66 | message += "\n\n" + "-" * 80 67 | 68 | if frid: 69 | message += f"\n\n{RENDERED_FRID_MESSAGE.format(frid)}" 70 | if render_id: 71 | message += f"\n\n{RENDER_ID_MESSAGE.format(render_id)}" 72 | 73 | # Check if there are any changes to commit 74 | if not repo.is_dirty(untracked_files=True): 75 | repo.git.commit("--allow-empty", "-m", message) 76 | else: 77 | repo.git.commit("-m", message) 78 | 79 | return repo 80 | 81 | 82 | def revert_changes(repo_path: Union[str, os.PathLike]) -> Repo: 83 | """Reverts all changes made since the last commit.""" 84 | repo = Repo(repo_path) 85 | repo.git.reset("--hard") 86 | repo.git.clean("-xdf") 87 | return repo 88 | 89 | 90 | def revert_to_commit_with_frid(repo_path: Union[str, os.PathLike], frid: Optional[str] = None) -> Repo: 91 | """ 92 | Finds commit with given frid mentioned in the commit message and reverts the branch to it. 93 | 94 | If frid argument is not provided (None), repo is reverted to the initial state. In case the base folder doesn't exist, 95 | code is reverted to the initial repo commit. Otherwise, the repo is reverted to the base folder commit. 96 | 97 | It is expected that the repo has at least one commit related to provided frid if frid is not None. 98 | In case the frid related commit is not found, an exception is raised. 99 | """ 100 | repo = Repo(repo_path) 101 | 102 | commit = _get_commit(repo, frid) 103 | 104 | if not commit: 105 | raise InvalidGitRepositoryError("Git repository is in an invalid state. Relevant commit could not be found.") 106 | 107 | repo.git.reset("--hard", commit) 108 | repo.git.clean("-xdf") 109 | return repo 110 | 111 | 112 | def checkout_commit_with_frid(repo_path: Union[str, os.PathLike], frid: Optional[str] = None) -> Repo: 113 | """ 114 | Finds commit with given frid mentioned in the commit message and checks out that commit. 115 | 116 | If frid argument is not provided (None), repo is checked out to the initial state. In case the base folder doesn't exist, 117 | code is checked out to the initial repo commit. Otherwise, the repo is checked out to the base folder commit. 118 | 119 | It is expected that the repo has at least one commit related to provided frid if frid is not None. 120 | In case the frid related commit is not found, an exception is raised. 121 | """ 122 | repo = Repo(repo_path) 123 | 124 | commit = _get_commit(repo, frid) 125 | 126 | if not commit: 127 | raise InvalidGitRepositoryError("Git repository is in an invalid state. Relevant commit could not be found.") 128 | 129 | repo.git.checkout(commit) 130 | return repo 131 | 132 | 133 | def checkout_previous_branch(repo_path: Union[str, os.PathLike]) -> Repo: 134 | """ 135 | Checks out the previous branch using 'git checkout -'. 136 | 137 | Args: 138 | repo_path (str | os.PathLike): Path to the git repository 139 | 140 | Returns: 141 | Repo: The git repository object 142 | """ 143 | repo = Repo(repo_path) 144 | repo.git.checkout("-") 145 | return repo 146 | 147 | 148 | def _get_diff_dict(diff_output: str) -> dict: 149 | diff_dict = {} 150 | current_file = None 151 | current_diff_lines = [] 152 | 153 | lines = diff_output.split("\n") 154 | i = 0 155 | 156 | while i < len(lines): 157 | line = lines[i] 158 | 159 | if line.startswith("diff --git"): 160 | # Save previous file's diff if exists 161 | if current_file and current_diff_lines: 162 | diff_dict[current_file] = "\n".join(current_diff_lines) 163 | 164 | # Extract file name from diff --git line 165 | parts = line.split(" ") 166 | if len(parts) >= 4: 167 | # Get the b/ path (new file path) 168 | current_file = parts[3][2:] if parts[3].startswith("b/") else parts[3] 169 | current_diff_lines = [] 170 | 171 | # Skip the diff --git line 172 | i += 1 173 | 174 | # Skip the index line if it exists 175 | while i < len(lines) and ( 176 | lines[i].startswith("index ") 177 | or lines[i].startswith("new file mode ") 178 | or lines[i].startswith("deleted file mode ") 179 | ): 180 | i += 1 181 | 182 | continue 183 | 184 | # Add all other lines to current diff 185 | if current_file is not None: 186 | current_diff_lines.append(line) 187 | 188 | i += 1 189 | 190 | # Don't forget the last file 191 | if current_file and current_diff_lines: 192 | diff_dict[current_file] = "\n".join(current_diff_lines) 193 | 194 | return diff_dict 195 | 196 | 197 | def diff(repo_path: Union[str, os.PathLike], previous_frid: str = None) -> dict: 198 | """ 199 | Get the git diff between the current code state and the previous frid using git's native diff command. 200 | If previous_frid is not provided, we try to find the commit related to the copy of the base folder. 201 | Removes the 'diff --git' and 'index' lines to get clean unified diff format. 202 | 203 | 204 | Args: 205 | repo_path (str | os.PathLike): Path to the git repository 206 | previous_frid (str): Functional requirement ID (FRID) of the previous commit 207 | 208 | Returns: 209 | dict: Dictionary with file names as keys and their clean diff strings as values 210 | """ 211 | repo = Repo(repo_path) 212 | 213 | commit = _get_commit(repo, previous_frid) 214 | 215 | # Add all files to the index to get a clean diff 216 | repo.git.add("-N", ".") 217 | 218 | # Get the raw git diff output, excluding .pyc files 219 | diff_output = repo.git.diff(commit, "--text", ":!*.pyc") 220 | 221 | if not diff_output: 222 | return {} 223 | 224 | return _get_diff_dict(diff_output) 225 | 226 | 227 | def _get_commit(repo: Repo, frid: Optional[str]) -> str: 228 | if frid: 229 | commit = _get_commit_with_frid(repo, frid) 230 | else: 231 | commit = _get_base_folder_commit(repo) 232 | if not commit: 233 | commit = _get_initial_commit(repo) 234 | 235 | return commit 236 | 237 | 238 | def _get_commit_with_frid(repo: Repo, frid: str) -> str: 239 | """Finds commit with given frid mentioned in the commit message.""" 240 | commit = _get_commit_with_message(repo, FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format(frid)) 241 | if not commit: 242 | raise InvalidGitRepositoryError(f"No commit with frid {frid} found.") 243 | return commit 244 | 245 | 246 | def _get_base_folder_commit(repo: Repo) -> str: 247 | """Finds commit related to copy of the base folder.""" 248 | return _get_commit_with_message(repo, BASE_FOLDER_COMMIT_MESSAGE) 249 | 250 | 251 | def _get_initial_commit(repo: Repo) -> str: 252 | """Finds initial commit.""" 253 | return _get_commit_with_message(repo, INITIAL_COMMIT_MESSAGE) 254 | 255 | 256 | def _get_commit_with_message(repo: Repo, message: str) -> str: 257 | """Finds commit with given message.""" 258 | escaped_message = message.replace("[", "\\[").replace("]", "\\]") 259 | 260 | return repo.git.rev_list(repo.active_branch.name, "--grep", escaped_message, "-n", "1") 261 | 262 | 263 | def get_implementation_code_diff(repo_path: Union[str, os.PathLike], frid: str, previous_frid: str) -> dict: 264 | repo = Repo(repo_path) 265 | 266 | implementation_commit = _get_commit_with_message(repo, REFACTORED_CODE_COMMIT_MESSAGE.format(frid)) 267 | if not implementation_commit: 268 | implementation_commit = _get_commit_with_message( 269 | repo, FUNCTIONAL_REQUIREMENT_IMPLEMENTED_COMMIT_MESSAGE.format(frid) 270 | ) 271 | 272 | previous_frid_commit = _get_commit(repo, previous_frid) 273 | 274 | # Get the raw git diff output, excluding .pyc files 275 | diff_output = repo.git.diff(previous_frid_commit, implementation_commit, "--text", ":!*.pyc") 276 | 277 | if not diff_output: 278 | return {} 279 | 280 | return _get_diff_dict(diff_output) 281 | 282 | 283 | def get_fixed_implementation_code_diff(repo_path: Union[str, os.PathLike], frid: str) -> dict: 284 | repo = Repo(repo_path) 285 | 286 | implementation_commit = _get_commit_with_message(repo, REFACTORED_CODE_COMMIT_MESSAGE.format(frid)) 287 | if not implementation_commit: 288 | implementation_commit = _get_commit_with_message( 289 | repo, FUNCTIONAL_REQUIREMENT_IMPLEMENTED_COMMIT_MESSAGE.format(frid) 290 | ) 291 | 292 | conformance_tests_passed_commit = _get_commit_with_message( 293 | repo, CONFORMANCE_TESTS_PASSED_COMMIT_MESSAGE.format(frid) 294 | ) 295 | if not conformance_tests_passed_commit: 296 | return None 297 | 298 | # Get the raw git diff output, excluding .pyc files 299 | diff_output = repo.git.diff(implementation_commit, conformance_tests_passed_commit, "--text", ":!*.pyc") 300 | 301 | if not diff_output: 302 | return {} 303 | 304 | return _get_diff_dict(diff_output) 305 | -------------------------------------------------------------------------------- /plain2code_arguments.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | import re 4 | 5 | from plain2code_read_config import get_args_from_config 6 | 7 | CODEPLAIN_API_KEY = os.getenv("CODEPLAIN_API_KEY") 8 | if not CODEPLAIN_API_KEY: 9 | CLAUDE_API_KEY = os.getenv("CLAUDE_API_KEY") 10 | if not CLAUDE_API_KEY: 11 | raise ValueError("CODEPLAIN_API_KEY or CLAUDE_API_KEY environment variable is not set") 12 | 13 | 14 | DEFAULT_BUILD_FOLDER = "build" 15 | DEFAULT_CONFORMANCE_TESTS_FOLDER = "conformance_tests" 16 | DEFAULT_BUILD_DEST = "dist" 17 | DEFAULT_CONFORMANCE_TESTS_DEST = "dist_conformance_tests" 18 | 19 | UNIT_TESTS_SCRIPT_NAME = "unittests_script" 20 | CONFORMANCE_TESTS_SCRIPT_NAME = "conformance_tests_script" 21 | 22 | 23 | def process_test_script_path(script_arg_name, config): 24 | """Resolve script paths in config.""" 25 | config_file = config.config_name 26 | script_input_path = getattr(config, script_arg_name, None) 27 | if script_input_path is None: 28 | return config 29 | 30 | # Check if the script path is absolute and keep the same path 31 | if isinstance(script_input_path, str) and script_input_path.startswith("/"): 32 | if not os.path.exists(script_input_path): 33 | raise FileNotFoundError( 34 | f"Path for {script_arg_name} not found: {script_input_path}. Set it to the absolute path or relative to the config file." 35 | ) 36 | return config 37 | 38 | # Otherwise the script path is relative 39 | # First look for it in the config file directory, then the renderer directory 40 | config_dir = os.path.dirname(os.path.abspath(config_file)) 41 | config_relative_path = os.path.join(config_dir, script_input_path) 42 | renderer_dir = os.path.dirname(os.path.abspath(__file__)) 43 | renderer_relative_path = os.path.join(renderer_dir, script_input_path) 44 | if os.path.exists(config_relative_path): 45 | setattr(config, script_arg_name, config_relative_path) 46 | elif os.path.exists(renderer_relative_path): 47 | setattr(config, script_arg_name, renderer_relative_path) 48 | else: 49 | raise FileNotFoundError( 50 | f"Path for {script_arg_name} not found: {script_input_path}. Set it to the absolute path or relative to the config file." 51 | ) 52 | return config 53 | 54 | 55 | def non_empty_string(s): 56 | if not s: 57 | raise argparse.ArgumentTypeError("The string cannot be empty.") 58 | return s 59 | 60 | 61 | def frid_string(s): 62 | """Validate that the string contains only numbers separated by dots.""" 63 | if not s: 64 | raise argparse.ArgumentTypeError("The functional requirement ID cannot be empty.") 65 | 66 | if not re.match(r"^\d+(\.\d+)*$", s): 67 | raise argparse.ArgumentTypeError( 68 | "Functional requirement ID string must contain only numbers optionally separated by dots (e.g. '1', '1.2.3')" 69 | ) 70 | return s 71 | 72 | 73 | def frid_range_string(s): 74 | """Validate that the string contains two frids separated by comma.""" 75 | if not s: 76 | raise argparse.ArgumentTypeError("The range cannot be empty.") 77 | 78 | parts = s.split(",") 79 | if len(parts) > 2: 80 | raise argparse.ArgumentTypeError("Range must contain at most two functional requirement IDs separated by comma") 81 | 82 | for part in parts: 83 | frid_string(part) 84 | 85 | return s 86 | 87 | 88 | def update_args_with_config(args, parser): 89 | try: 90 | config_args = get_args_from_config(args.config_name, parser) 91 | # Get all action types from the parser 92 | action_types = {action.dest: action for action in parser._actions} 93 | 94 | # Update args with config values, but command line args take precedence 95 | for key, value in vars(config_args).items(): 96 | # Skip if the argument was provided on command line 97 | if key in vars(args): 98 | arg_action = action_types.get(key) 99 | if arg_action and isinstance(arg_action, argparse._StoreAction): 100 | # For regular arguments, only skip if explicitly provided 101 | if getattr(args, key) is not None and (arg_action.default is None or value == arg_action.default): 102 | continue 103 | elif arg_action and isinstance(arg_action, argparse._StoreTrueAction): 104 | # For boolean flags, skip if True (explicitly set) 105 | if getattr(args, key): 106 | continue 107 | 108 | # Set the value from config 109 | if key in action_types: 110 | setattr(args, key, value) 111 | else: 112 | parser.error(f"Invalid argument: {key}") 113 | 114 | except Exception as e: 115 | parser.error(f"Error reading config file: {str(e)}") 116 | 117 | return args 118 | 119 | 120 | def create_parser(): 121 | """Create the argument parser without parsing arguments.""" 122 | parser = argparse.ArgumentParser(description="Render plain code to target code.") 123 | 124 | parser.add_argument( 125 | "filename", 126 | type=str, 127 | help="Path to the plain file to render. The directory containing this file has highest precedence for template loading, " 128 | "so you can place custom templates here to override the defaults. See --template-dir for more details about template loading.", 129 | ) 130 | parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose output") 131 | parser.add_argument("--base-folder", type=str, help="Base folder for the build files") 132 | parser.add_argument( 133 | "--build-folder", type=non_empty_string, default=DEFAULT_BUILD_FOLDER, help="Folder for build files" 134 | ) 135 | 136 | # Add config file arguments 137 | config_group = parser.add_argument_group("configuration") 138 | config_group.add_argument( 139 | "--config-name", 140 | type=non_empty_string, 141 | default="config.yaml", 142 | help="Path to the config file, defaults to config.yaml", 143 | ) 144 | 145 | render_range_group = parser.add_mutually_exclusive_group() 146 | render_range_group.add_argument( 147 | "--render-range", 148 | type=frid_range_string, 149 | help="Specify a range of functional requirements to render (e.g. '1.1,2.3'). " 150 | "Use comma to separate start and end IDs. If only one ID is provided, only that requirement is rendered. " 151 | "Range is inclusive of both start and end IDs.", 152 | ) 153 | render_range_group.add_argument( 154 | "--render-from", 155 | type=frid_string, 156 | help="Continue generation starting from this specific functional requirement (e.g. '2.1'). " 157 | "The requirement with this ID will be included in the output. The ID must match one of the functional requirements in your plain file.", 158 | ) 159 | 160 | parser.add_argument( 161 | "--unittests-script", 162 | type=str, 163 | help="Shell script to run unit tests on generated code. Receives the build folder path as its first argument (default: 'build').", 164 | ) 165 | parser.add_argument( 166 | "--conformance-tests-folder", 167 | type=non_empty_string, 168 | default=DEFAULT_CONFORMANCE_TESTS_FOLDER, 169 | help="Folder for conformance test files", 170 | ) 171 | parser.add_argument( 172 | "--conformance-tests-script", 173 | type=str, 174 | help="Path to conformance tests shell script. The script should accept two arguments: " 175 | "1) First argument: path to a folder (e.g. 'build') containing generated source code, " 176 | "2) Second argument: path to a subfolder of the conformance tests folder (e.g. 'conformance_tests/subfoldername') containing test files.", 177 | ) 178 | 179 | parser.add_argument( 180 | "--prepare-environment-script", 181 | type=str, 182 | help="Path to a shell script that prepares the testing environment. The script should accept the build folder path as its first argument (default: 'build').", 183 | ) 184 | 185 | parser.add_argument( 186 | "--api", 187 | type=str, 188 | nargs="?", 189 | const="https://api.codeplain.ai", 190 | help="Alternative base URL for the API. Default: `https://api.codeplain.ai`", 191 | ) 192 | parser.add_argument( 193 | "--api-key", 194 | type=str, 195 | default=CODEPLAIN_API_KEY, 196 | help="API key used to access the API. If not provided, the CODEPLAIN_API_KEY environment variable is used.", 197 | ) 198 | parser.add_argument( 199 | "--full-plain", 200 | action="store_true", 201 | help="Display the complete plain specification before code generation. " 202 | "This shows your plain file with " 203 | "any included template content expanded. Useful for understanding what content is being processed.", 204 | ) 205 | parser.add_argument( 206 | "--dry-run", action="store_true", help="Preview of what Codeplain would do without actually making any changes." 207 | ) 208 | parser.add_argument( 209 | "--replay-with", 210 | type=str, 211 | default=None, 212 | help="", 213 | ) 214 | 215 | parser.add_argument( 216 | "--template-dir", 217 | type=str, 218 | default=None, 219 | help="Path to a custom template directory. Templates are searched in the following order: " 220 | "1) directory containing the plain file, " 221 | "2) this custom template directory (if provided), " 222 | "3) built-in standard_template_library directory", 223 | ) 224 | parser.add_argument( 225 | "--copy-build", 226 | action="store_true", 227 | default=False, 228 | help="If set, copy the build folder to `--build-dest` after every successful rendering.", 229 | ) 230 | parser.add_argument( 231 | "--build-dest", 232 | type=non_empty_string, 233 | default=DEFAULT_BUILD_DEST, 234 | help="Target folder to copy build output to (used only if --copy-build is set).", 235 | ) 236 | parser.add_argument( 237 | "--copy-conformance-tests", 238 | action="store_true", 239 | default=False, 240 | help="If set, copy the conformance tests folder to `--conformance-tests-dest` after every successful rendering. Requires --conformance-tests-script.", 241 | ) 242 | parser.add_argument( 243 | "--conformance-tests-dest", 244 | type=non_empty_string, 245 | default=DEFAULT_CONFORMANCE_TESTS_DEST, 246 | help="Target folder to copy conformance tests output to (used only if --copy-conformance-tests is set).", 247 | ) 248 | 249 | parser.add_argument( 250 | "--render-machine-graph", 251 | action="store_true", 252 | default=False, 253 | help="If set, render the state machine graph.", 254 | ) 255 | 256 | return parser 257 | 258 | 259 | def parse_arguments(): 260 | parser = create_parser() 261 | 262 | args = parser.parse_args() 263 | args = update_args_with_config(args, parser) 264 | 265 | if args.build_folder == args.build_dest: 266 | parser.error("--build-folder and --build-dest cannot be the same") 267 | if args.conformance_tests_folder == args.conformance_tests_dest: 268 | parser.error("--conformance-tests-folder and --conformance-tests-dest cannot be the same") 269 | 270 | args.render_conformance_tests = args.conformance_tests_script is not None 271 | 272 | if not args.render_conformance_tests and args.copy_conformance_tests: 273 | parser.error("--copy-conformance-tests requires --conformance-tests-script to be set") 274 | 275 | script_arg_names = [UNIT_TESTS_SCRIPT_NAME, CONFORMANCE_TESTS_SCRIPT_NAME] 276 | for script_name in script_arg_names: 277 | args = process_test_script_path(script_name, args) 278 | 279 | return args 280 | -------------------------------------------------------------------------------- /file_utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | from pathlib import Path 4 | 5 | from liquid2 import Environment, FileSystemLoader, StrictUndefined 6 | from liquid2.exceptions import UndefinedError 7 | 8 | import plain_spec 9 | from plain2code_nodes import Plain2CodeIncludeTag, Plain2CodeLoaderMixin 10 | 11 | BINARY_FILE_EXTENSIONS = [".pyc"] 12 | 13 | # Dictionary mapping of file extensions to type names 14 | FILE_EXTENSION_MAPPING = { 15 | "": "plaintext", 16 | ".py": "python", 17 | ".txt": "plaintext", 18 | ".md": "markdown", 19 | ".ts": "typescript", 20 | ".tsx": "typescript", 21 | ".js": "javascript", 22 | ".html": "HTML", 23 | ".css": "CSS", 24 | ".scss": "SASS/SCSS", 25 | ".java": "java", 26 | ".cpp": "C++", 27 | ".c": "C", 28 | ".cs": "C#", 29 | ".php": "PHP", 30 | ".rb": "Ruby", 31 | ".go": "Go", 32 | ".rs": "Rust", 33 | ".swift": "Swift", 34 | ".kt": "Kotlin", 35 | ".sql": "SQL", 36 | ".json": "JSON", 37 | ".xml": "XML", 38 | ".yaml": "YAML", 39 | ".yml": "YAML", # YAML has two common extensions 40 | ".sh": "Shell Script", 41 | ".bat": "Batch File", 42 | } 43 | 44 | 45 | def get_file_type(file_name): 46 | 47 | # Extract the file extension 48 | ext = Path(file_name).suffix.lower() # Convert to lowercase to handle case-insensitive matching 49 | 50 | # Use the dictionary to get the file type, defaulting to 'unknown' if the extension is not found 51 | return FILE_EXTENSION_MAPPING.get(ext, "unknown") 52 | 53 | 54 | def list_all_text_files(directory): 55 | all_files = [] 56 | for root, dirs, files in os.walk(directory, topdown=True): 57 | # Skip .git directory 58 | if ".git" in dirs: 59 | dirs.remove(".git") 60 | 61 | modified_root = os.path.relpath(root, directory) 62 | if modified_root == ".": 63 | modified_root = "" 64 | 65 | for filename in files: 66 | if not any(filename.endswith(ending) for ending in BINARY_FILE_EXTENSIONS): 67 | try: 68 | with open(os.path.join(root, filename), "rb") as f: 69 | f.read().decode("utf-8") 70 | except UnicodeDecodeError: 71 | print(f"WARNING! Not listing {filename} in {root}. File is not a text file. Skipping it.") 72 | continue 73 | 74 | all_files.append(os.path.join(modified_root, filename)) 75 | 76 | return all_files 77 | 78 | 79 | def list_folders_in_directory(directory): 80 | # List all items in the directory 81 | items = os.listdir(directory) 82 | 83 | # Filter out the folders 84 | folders = [item for item in items if os.path.isdir(os.path.join(directory, item))] 85 | 86 | return folders 87 | 88 | 89 | # delete a folder and all its subfolders and files 90 | def delete_folder(folder_name): 91 | if os.path.exists(folder_name): 92 | shutil.rmtree(folder_name) 93 | 94 | 95 | def delete_files_and_subfolders(directory): 96 | total_files_deleted = 0 97 | total_folders_deleted = 0 98 | 99 | # Walk the directory in reverse order (bottom-up) 100 | for root, dirs, files in os.walk(directory, topdown=False): 101 | # Delete files 102 | for file in files: 103 | file_path = os.path.join(root, file) 104 | os.remove(file_path) 105 | total_files_deleted += 1 106 | 107 | # Delete directories 108 | for dir_ in dirs: 109 | dir_path = os.path.join(root, dir_) 110 | os.rmdir(dir_path) 111 | total_folders_deleted += 1 112 | 113 | 114 | def copy_file(source_path, destination_path): 115 | # Ensure the destination directory exists 116 | os.makedirs(os.path.dirname(destination_path), exist_ok=True) 117 | 118 | # Open the source file in read-binary ('rb') mode 119 | with open(source_path, "rb") as source_file: 120 | # Open the destination file in write-binary ('wb') mode 121 | with open(destination_path, "wb") as destination_file: 122 | # Copy the content from source to destination 123 | while True: 124 | # Read a chunk of the source file 125 | chunk = source_file.read(4096) # Reading in chunks of 4KB 126 | if not chunk: 127 | break # End of file reached 128 | # Write the chunk to the destination file 129 | destination_file.write(chunk) 130 | 131 | 132 | def add_current_path_if_no_path(filename): 133 | # Extract the base name of the file (ignoring any path information) 134 | basename = os.path.basename(filename) 135 | 136 | # Compare the basename to the original filename 137 | # If they are the same, there was no path information in the filename 138 | if basename == filename: 139 | # Prepend the current working directory 140 | return os.path.join(os.getcwd(), filename) 141 | # If the basename and the original filename differ, path information was present 142 | return filename 143 | 144 | 145 | def get_existing_files_content(build_folder, existing_files): 146 | existing_files_content = {} 147 | for file_name in existing_files: 148 | with open(os.path.join(build_folder, file_name), "rb") as f: 149 | content = f.read() 150 | try: 151 | existing_files_content[file_name] = content.decode("utf-8") 152 | except UnicodeDecodeError: 153 | print(f"WARNING! Error loading {file_name}. File is not a text file. Skipping it.") 154 | 155 | return existing_files_content 156 | 157 | 158 | def store_response_files(target_folder, response_files, existing_files): 159 | for file_name in response_files: 160 | full_file_name = os.path.join(target_folder, file_name) 161 | 162 | if response_files[file_name] is None: 163 | # None content indicates that the file should be deleted. 164 | if os.path.exists(full_file_name): 165 | os.remove(full_file_name) 166 | existing_files.remove(file_name) 167 | else: 168 | print(f"WARNING! Cannot delete file! File {full_file_name} does not exist.") 169 | 170 | continue 171 | 172 | os.makedirs(os.path.dirname(full_file_name), exist_ok=True) 173 | 174 | with open(full_file_name, "w") as f: 175 | f.write(response_files[file_name]) 176 | 177 | if file_name not in existing_files: 178 | existing_files.append(file_name) 179 | 180 | return existing_files 181 | 182 | 183 | def load_linked_resources(template_dirs: list[str], resources_list): 184 | linked_resources = {} 185 | 186 | for resource in resources_list: 187 | resource_found = False 188 | for template_dir in template_dirs: 189 | file_name = resource["target"] 190 | if file_name in linked_resources: 191 | continue 192 | 193 | full_file_name = os.path.join(template_dir, file_name) 194 | if not os.path.isfile(full_file_name): 195 | continue 196 | 197 | with open(full_file_name, "rb") as f: 198 | content = f.read() 199 | try: 200 | linked_resources[file_name] = content.decode("utf-8") 201 | except UnicodeDecodeError: 202 | print( 203 | f"WARNING! Error loading {resource['text']} ({resource['target']}). File is not a text file. Skipping it." 204 | ) 205 | resource_found = True 206 | if not resource_found: 207 | raise FileNotFoundError( 208 | f""" 209 | Resource file {resource['target']} not found. Resource files are searched in the following order (highest to lowest precedence): 210 | 211 | 1. The directory containing your .plain file 212 | 2. The directory specified by --template-dir (if provided) 213 | 3. The built-in 'standard_template_library' directory 214 | 215 | Please ensure that the resource exists in one of these locations, or specify the correct --template-dir if using custom templates. 216 | """ 217 | ) 218 | 219 | return linked_resources 220 | 221 | 222 | class TrackingFileSystemLoader(Plain2CodeLoaderMixin, FileSystemLoader): 223 | def __init__(self, *args, **kwargs): 224 | super().__init__(*args, **kwargs) 225 | self.loaded_templates = {} 226 | 227 | def get_source(self, environment, template_name, **kwargs): 228 | source = super().get_source(environment, template_name, **kwargs) 229 | self.loaded_templates[template_name] = source.source 230 | return source 231 | 232 | 233 | def get_loaded_templates(source_path, plain_source): 234 | # Render the plain source with Liquid templating engine 235 | # to identify the templates that are being loaded 236 | 237 | liquid_loader = TrackingFileSystemLoader(source_path) 238 | liquid_env = Environment(loader=liquid_loader, undefined=StrictUndefined) 239 | liquid_env.tags["include"] = Plain2CodeIncludeTag(liquid_env) 240 | 241 | liquid_env.filters["code_variable"] = plain_spec.code_variable_liquid_filter 242 | liquid_env.filters["prohibited_chars"] = plain_spec.prohibited_chars_liquid_filter 243 | 244 | plain_source_template = liquid_env.from_string(plain_source) 245 | try: 246 | plain_source = plain_source_template.render() 247 | except UndefinedError as e: 248 | raise Exception(f"Undefined liquid variable: {str(e)}") 249 | 250 | return plain_source, liquid_loader.loaded_templates 251 | 252 | 253 | def update_build_folder_with_rendered_files(build_folder, existing_files, response_files): 254 | changed_files = set() 255 | changed_files.update(response_files.keys()) 256 | 257 | existing_files = store_response_files(build_folder, response_files, existing_files) 258 | 259 | return existing_files, changed_files 260 | 261 | 262 | def copy_folder_content(source_folder, destination_folder, ignore_folders=None): 263 | """ 264 | Recursively copy all files and folders from source_folder to destination_folder. 265 | Uses shutil.copytree which handles all edge cases including permissions and symlinks. 266 | 267 | Args: 268 | source_folder: Source directory to copy from 269 | destination_folder: Destination directory to copy to 270 | ignore_folders: List of folder names to ignore during copy (default: empty list) 271 | """ 272 | if ignore_folders is None: 273 | ignore_folders = [] 274 | 275 | ignore_func = ( 276 | (lambda dir, files: [f for f in files if f in ignore_folders]) if ignore_folders else None # noqa: U100,U101 277 | ) 278 | shutil.copytree(source_folder, destination_folder, dirs_exist_ok=True, ignore=ignore_func) 279 | 280 | 281 | def get_template_directories(plain_file_path, custom_template_dir=None, default_template_dir=None) -> list[str]: 282 | """Set up template search directories with specific precedence order. 283 | 284 | The order of directories in the returned list determines template loading precedence. 285 | Earlier indices (lower numbers) have higher precedence - the first matching template found will be used. 286 | 287 | Precedence order (highest to lowest): 288 | 1. Directory containing the plain file - for project-specific template overrides 289 | 2. Custom template directory (if provided) - for shared custom templates 290 | 3. Default template directory - for standard/fallback templates 291 | """ 292 | template_dirs = [ 293 | os.path.dirname(plain_file_path), # Highest precedence - directory containing plain file 294 | ] 295 | 296 | if custom_template_dir: 297 | template_dirs.append(os.path.abspath(custom_template_dir)) # Second highest - custom template dir 298 | 299 | if default_template_dir: 300 | # Add standard template directory last - lowest precedence 301 | template_dirs.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), default_template_dir)) 302 | 303 | return template_dirs 304 | 305 | 306 | def copy_folder_to_output(source_folder, output_folder): 307 | """Copy source folder contents directly to the specified output folder.""" 308 | # Create output folder if it doesn't exist 309 | os.makedirs(output_folder, exist_ok=True) 310 | 311 | # If output folder exists, clean it first to ensure clean copy 312 | if os.path.exists(output_folder): 313 | delete_files_and_subfolders(output_folder) 314 | 315 | # Copy source folder contents directly to output folder (excluding .git) 316 | copy_folder_content(source_folder, output_folder, ignore_folders=[".git"]) 317 | -------------------------------------------------------------------------------- /plain_spec.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import json 3 | import uuid 4 | from typing import Optional 5 | 6 | from liquid2.filter import with_context 7 | 8 | DEFINITIONS = "Definitions:" 9 | NON_FUNCTIONAL_REQUIREMENTS = "Non-Functional Requirements:" 10 | TEST_REQUIREMENTS = "Test Requirements:" 11 | FUNCTIONAL_REQUIREMENTS = "Functional Requirements:" 12 | ACCEPTANCE_TESTS = "acceptance_tests" 13 | ACCEPTANCE_TEST_HEADING = "Acceptance Tests:" 14 | 15 | ALLOWED_SPECIFICATION_HEADINGS = [ 16 | DEFINITIONS, 17 | NON_FUNCTIONAL_REQUIREMENTS, 18 | TEST_REQUIREMENTS, 19 | FUNCTIONAL_REQUIREMENTS, 20 | ACCEPTANCE_TEST_HEADING, 21 | ] 22 | 23 | 24 | class InvalidLiquidVariableName(Exception): 25 | pass 26 | 27 | 28 | def collect_specification_linked_resources(specification, specification_heading, linked_resources_list): 29 | linked_resources = [] 30 | if "linked_resources" in specification: 31 | linked_resources.extend(specification["linked_resources"]) 32 | 33 | for resource in linked_resources: 34 | resource_found = False 35 | for resource_map in linked_resources_list: 36 | if resource["text"] == resource_map["text"]: 37 | if resource["target"] != resource_map["target"]: 38 | raise Exception( 39 | f"The file {resource['target']} is linked to multiple linked resources with the same text: {resource['text']}" 40 | ) 41 | elif resource["target"] == resource_map["target"]: 42 | if resource["text"] != resource_map["text"]: 43 | raise Exception( 44 | f"The linked resource {resource['text']} is linked to multiple files: {resource_map['target']}" 45 | ) 46 | else: 47 | continue 48 | 49 | if resource_found: 50 | raise Exception( 51 | "Duplicate linked resource found: " + resource["text"] + " (" + resource["target"] + ")" 52 | ) 53 | 54 | resource_found = True 55 | resource_map["sections"].append(specification_heading) 56 | 57 | if not resource_found: 58 | linked_resources_list.append( 59 | {"text": resource["text"], "target": resource["target"], "sections": [specification_heading]} 60 | ) 61 | 62 | 63 | def collect_linked_resources_in_section( 64 | section, linked_resources_list, specifications_list, include_acceptance_tests, frid=Optional[str] 65 | ): 66 | # When should we collect specification headings in the current section. Either when one of the following holds true: 67 | # - frid wasn't specified 68 | # - section has no ID (and that's exactly when it's the root section) 69 | # - section has ID, frid was specified and the specified frid inside the current section tree 70 | should_collect_specification_headings_in_current_section = ( 71 | frid is None or "ID" not in section or frid.startswith(section["ID"]) 72 | ) 73 | if should_collect_specification_headings_in_current_section: 74 | specifications_to_collect = list(set([DEFINITIONS, NON_FUNCTIONAL_REQUIREMENTS, TEST_REQUIREMENTS])) 75 | if specifications_list: 76 | specifications_to_collect = list(set(specifications_to_collect) & set(specifications_list)) 77 | for specification_heading in specifications_to_collect: 78 | if specification_heading in section: 79 | for requirement in section[specification_heading]: 80 | collect_specification_linked_resources(requirement, specification_heading, linked_resources_list) 81 | 82 | if FUNCTIONAL_REQUIREMENTS in section and ( 83 | not specifications_list or FUNCTIONAL_REQUIREMENTS in specifications_list 84 | ): 85 | functional_requirement_count = 0 86 | for requirement in section[FUNCTIONAL_REQUIREMENTS]: 87 | collect_specification_linked_resources(requirement, FUNCTIONAL_REQUIREMENTS, linked_resources_list) 88 | 89 | functional_requirement_count += 1 90 | section_id = section.get("ID", None) 91 | current_frid = get_current_frid(section_id, functional_requirement_count) 92 | 93 | if ACCEPTANCE_TESTS in requirement and include_acceptance_tests and (frid is None or frid == current_frid): 94 | for acceptance_test in requirement[ACCEPTANCE_TESTS]: 95 | collect_specification_linked_resources( 96 | acceptance_test, FUNCTIONAL_REQUIREMENTS, linked_resources_list 97 | ) 98 | 99 | if frid is not None and current_frid == frid: 100 | # here, we rely on the fact that FRIDs are incrementing. And if we have reached the current FRID, we should 101 | # not collect any FRIDs after the current one. 102 | return True 103 | 104 | if "sections" in section: 105 | for subsection in section["sections"]: 106 | if collect_linked_resources_in_section( 107 | subsection, linked_resources_list, specifications_list, include_acceptance_tests, frid 108 | ): 109 | return True 110 | 111 | return False 112 | 113 | 114 | # TODO: check if this function can be refactored to return the list of linked resources instead of modifying it in place 115 | def collect_linked_resources( 116 | plain_source_tree, linked_resources_list, specifications_list, include_acceptance_tests, frid=None 117 | ): 118 | 119 | if not isinstance(plain_source_tree, dict): 120 | raise ValueError("[plain_source_tree must be a dictionary.") 121 | 122 | if frid is not None: 123 | functional_requirements = get_frids(plain_source_tree) 124 | if frid not in functional_requirements: 125 | raise ValueError(f"frid {frid} does not exist.") 126 | 127 | result = collect_linked_resources_in_section( 128 | plain_source_tree, linked_resources_list, specifications_list, include_acceptance_tests, frid 129 | ) 130 | 131 | # Sort linked_resources_list by the "text" field 132 | linked_resources_list.sort(key=lambda x: x["text"]) 133 | 134 | return result 135 | 136 | 137 | def get_frids(plain_source_tree): 138 | if FUNCTIONAL_REQUIREMENTS in plain_source_tree: 139 | for functional_requirement_id in range(1, len(plain_source_tree[FUNCTIONAL_REQUIREMENTS]) + 1): 140 | if "ID" in plain_source_tree: 141 | yield plain_source_tree["ID"] + "." + str(functional_requirement_id) 142 | else: 143 | yield str(functional_requirement_id) 144 | 145 | if "sections" in plain_source_tree: 146 | for section in plain_source_tree["sections"]: 147 | yield from get_frids(section) 148 | 149 | 150 | def get_first_frid(plain_source_tree): 151 | return next(get_frids(plain_source_tree), None) 152 | 153 | 154 | def get_current_frid(section_id: Optional[str], functional_requirement_count: int) -> str: 155 | if section_id is None: 156 | return str(functional_requirement_count) 157 | else: 158 | return section_id + "." + str(functional_requirement_count) 159 | 160 | 161 | def get_next_frid(plain_source_tree, frid): 162 | functional_requirements = get_frids(plain_source_tree) 163 | for temp_frid in functional_requirements: 164 | if temp_frid == frid: 165 | return next(functional_requirements, None) 166 | 167 | raise Exception(f"Functional requirement {frid} does not exist.") 168 | 169 | 170 | def get_previous_frid(plain_source_tree, frid): 171 | previous_frid = None 172 | for temp_frid in get_frids(plain_source_tree): 173 | if temp_frid == frid: 174 | return previous_frid 175 | 176 | previous_frid = temp_frid 177 | 178 | raise Exception(f"Functional requirement {frid} does not exist.") 179 | 180 | 181 | def get_specification_item_markdown(specification_item, code_variables, replace_code_variables): 182 | markdown = specification_item["markdown"] 183 | if "code_variables" in specification_item: 184 | for code_variable in specification_item["code_variables"]: 185 | if code_variable["name"] in code_variables: 186 | if code_variables[code_variable["name"]] != code_variable["value"]: 187 | raise Exception( 188 | f"Code variable {code_variable['name']} has multiple values: {code_variables[code_variable['name']]} and {code_variable['value']}" 189 | ) 190 | else: 191 | code_variables[code_variable["name"]] = code_variable["value"] 192 | 193 | if replace_code_variables: 194 | markdown = markdown.replace(f"{{{{ {code_variable['name']} }}}}", code_variable["value"]) 195 | 196 | return markdown 197 | 198 | 199 | def get_specifications_from_plain_source_tree( 200 | frid, 201 | plain_source_tree, 202 | definitions, 203 | non_functional_requirements, 204 | test_requirements, 205 | functional_requirements, 206 | acceptance_tests, 207 | code_variables, 208 | replace_code_variables, 209 | section_id=None, 210 | ): 211 | return_frid = None 212 | if FUNCTIONAL_REQUIREMENTS in plain_source_tree and len(plain_source_tree[FUNCTIONAL_REQUIREMENTS]) > 0: 213 | functional_requirement_count = 0 214 | for functional_requirement in plain_source_tree[FUNCTIONAL_REQUIREMENTS]: 215 | functional_requirement_count += 1 216 | if section_id is None: 217 | current_frid = str(functional_requirement_count) 218 | else: 219 | current_frid = section_id + "." + str(functional_requirement_count) 220 | 221 | functional_requirements.append( 222 | get_specification_item_markdown(functional_requirement, code_variables, replace_code_variables) 223 | ) 224 | 225 | if current_frid == frid: 226 | if ACCEPTANCE_TESTS in functional_requirement: 227 | for acceptance_test in functional_requirement[ACCEPTANCE_TESTS]: 228 | acceptance_tests.append( 229 | get_specification_item_markdown(acceptance_test, code_variables, replace_code_variables) 230 | ) 231 | 232 | return_frid = current_frid 233 | break 234 | 235 | if "sections" in plain_source_tree: 236 | for section in plain_source_tree["sections"]: 237 | sub_frid = get_specifications_from_plain_source_tree( 238 | frid, 239 | section, 240 | definitions, 241 | non_functional_requirements, 242 | test_requirements, 243 | functional_requirements, 244 | acceptance_tests, 245 | code_variables, 246 | replace_code_variables, 247 | section["ID"], 248 | ) 249 | if sub_frid is not None: 250 | return_frid = sub_frid 251 | break 252 | 253 | if return_frid is not None: 254 | if DEFINITIONS in plain_source_tree and plain_source_tree[DEFINITIONS] is not None: 255 | definitions[0:0] = [ 256 | get_specification_item_markdown(specification, code_variables, replace_code_variables) 257 | for specification in plain_source_tree[DEFINITIONS] 258 | ] 259 | if ( 260 | NON_FUNCTIONAL_REQUIREMENTS in plain_source_tree 261 | and plain_source_tree[NON_FUNCTIONAL_REQUIREMENTS] is not None 262 | ): 263 | non_functional_requirements[0:0] = [ 264 | get_specification_item_markdown(specification, code_variables, replace_code_variables) 265 | for specification in plain_source_tree[NON_FUNCTIONAL_REQUIREMENTS] 266 | ] 267 | if TEST_REQUIREMENTS in plain_source_tree and plain_source_tree[TEST_REQUIREMENTS] is not None: 268 | test_requirements[0:0] = [ 269 | get_specification_item_markdown(specification, code_variables, replace_code_variables) 270 | for specification in plain_source_tree[TEST_REQUIREMENTS] 271 | ] 272 | 273 | return return_frid 274 | 275 | 276 | def get_specifications_for_frid(plain_source_tree, frid, replace_code_variables=True): 277 | definitions = [] 278 | non_functional_requirements = [] 279 | test_requirements = [] 280 | functional_requirements = [] 281 | acceptance_tests = [] 282 | 283 | code_variables = {} 284 | 285 | result = get_specifications_from_plain_source_tree( 286 | frid, 287 | plain_source_tree, 288 | definitions, 289 | non_functional_requirements, 290 | test_requirements, 291 | functional_requirements, 292 | acceptance_tests, 293 | code_variables, 294 | replace_code_variables, 295 | ) 296 | if result is None: 297 | raise Exception(f"Functional requirement {frid} does not exist.") 298 | 299 | specifications = { 300 | DEFINITIONS: definitions, 301 | NON_FUNCTIONAL_REQUIREMENTS: non_functional_requirements, 302 | TEST_REQUIREMENTS: test_requirements, 303 | FUNCTIONAL_REQUIREMENTS: functional_requirements, 304 | } 305 | 306 | if acceptance_tests: 307 | specifications[ACCEPTANCE_TESTS] = acceptance_tests 308 | 309 | if code_variables: 310 | return specifications, code_variables 311 | else: 312 | return specifications, None 313 | 314 | 315 | @with_context 316 | def code_variable_liquid_filter(value, *, context): 317 | if len(context.scope) == 0: 318 | raise Exception("Invalid use of code_variable filter!") 319 | 320 | if "code_variables" in context.globals: 321 | code_variables = context.globals["code_variables"] 322 | 323 | variable = next(iter(context.scope.items())) 324 | 325 | unique_str = uuid.uuid4().hex 326 | 327 | code_variables[unique_str] = {variable[0]: value} 328 | 329 | return unique_str 330 | else: 331 | 332 | return value 333 | 334 | 335 | @with_context 336 | def prohibited_chars_liquid_filter(value, prohibited_chars, *, context): 337 | if not isinstance(value, str): 338 | value = str(value) 339 | 340 | if len(context.scope) == 0: 341 | raise Exception("Invalid use of prohibited_chars filter!") 342 | 343 | variable = next(iter(context.scope.items())) 344 | variable_name = variable[0] 345 | 346 | for char in prohibited_chars: 347 | if char in value: 348 | raise InvalidLiquidVariableName( 349 | f"'{char}' is not a valid character for variable '{variable_name}' (value: '{value}')." 350 | ) 351 | 352 | return value 353 | 354 | 355 | def hash_text(text): 356 | return hashlib.sha256(text.encode()).hexdigest() 357 | 358 | 359 | def get_hash_value(specifications): 360 | return hash_text(json.dumps(specifications, indent=4)) 361 | -------------------------------------------------------------------------------- /render_machine/render_context.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import file_utils 4 | import git_utils 5 | import plain_spec 6 | from codeplain_REST_api import CodeplainAPI 7 | from plain2code_console import console 8 | from plain2code_state import CONFORMANCE_TESTS_DEFINITION_FILE_NAME, ConformanceTestsUtils, RunState 9 | from render_machine import triggers 10 | from render_machine.conformance_test_helpers import ConformanceTestHelpers 11 | from render_machine.render_types import ConformanceTestsRunningContext, FridContext, UnitTestsRunningContext 12 | 13 | DEFAULT_TEMPLATE_DIRS = "standard_template_library" 14 | 15 | MAX_UNITTEST_FIX_ATTEMPTS = 20 16 | MAX_CODE_GENERATION_RETRIES = 2 17 | MAX_CONFORMANCE_TEST_RERENDER_ATTEMPTS = 1 18 | MAX_REFACTORING_ITERATIONS = 5 19 | MAX_CONFORMANCE_TEST_FIX_ATTEMPTS = 20 20 | MAX_FUNCTIONAL_REQUIREMENT_RENDER_ATTEMPTS_FAILED_UNIT_DURING_CONFORMANCE_TESTS = 2 21 | 22 | 23 | class RenderContext: 24 | def __init__(self, codeplain_api, plain_source_tree: dict, args: dict, run_state: RunState): 25 | self.codeplain_api: CodeplainAPI = codeplain_api 26 | self.plain_source_tree = plain_source_tree 27 | self.args = args 28 | self.run_state = run_state 29 | self.starting_frid = None 30 | 31 | template_dirs = file_utils.get_template_directories(args.filename, args.template_dir, DEFAULT_TEMPLATE_DIRS) 32 | resources_list = [] 33 | plain_spec.collect_linked_resources(plain_source_tree, resources_list, None, True) 34 | self.all_linked_resources = file_utils.load_linked_resources(template_dirs, resources_list) 35 | 36 | # Initialize context objects 37 | self.frid_context: Optional[FridContext] = None 38 | self.unit_tests_running_context: Optional[UnitTestsRunningContext] = None 39 | self.conformance_tests_running_context: Optional[ConformanceTestsRunningContext] = None 40 | # Constants that should remain for a single frid, but possible over multiple rerenderings of the same frid 41 | self.functional_requirements_render_attempts_failed_unit_during_conformance_tests = 0 42 | # Initialize conformance tests utilities 43 | self.conformance_tests_utils = ConformanceTestsUtils( 44 | conformance_tests_folder=args.conformance_tests_folder, 45 | conformance_tests_definition_file_name=CONFORMANCE_TESTS_DEFINITION_FILE_NAME, 46 | verbose=args.verbose, 47 | ) 48 | 49 | self.machine = None 50 | 51 | def set_machine(self, machine): 52 | self.machine = machine 53 | 54 | def start_implementing_frid(self): 55 | if self.starting_frid is not None: 56 | frid = self.starting_frid 57 | self.starting_frid = None 58 | elif self.frid_context is None: 59 | frid = plain_spec.get_first_frid(self.plain_source_tree) 60 | else: 61 | frid = plain_spec.get_next_frid(self.plain_source_tree, self.frid_context.frid) 62 | 63 | if frid is None: 64 | # If frid context is empty, it means that all frids have been implemented 65 | self.frid_context = None 66 | self.machine.dispatch(triggers.PREPARE_FINAL_OUTPUT) 67 | return 68 | 69 | specifications, _ = plain_spec.get_specifications_for_frid(self.plain_source_tree, frid) 70 | functional_requirement_text = specifications[plain_spec.FUNCTIONAL_REQUIREMENTS][-1] 71 | 72 | resources_list = [] 73 | plain_spec.collect_linked_resources(self.plain_source_tree, resources_list, None, True, frid) 74 | 75 | linked_resources = {} 76 | for resource in resources_list: 77 | linked_resources[resource["target"]] = self.all_linked_resources[resource["target"]] 78 | 79 | self.frid_context = FridContext( 80 | frid=frid, 81 | specifications=specifications, 82 | functional_requirement_text=functional_requirement_text, 83 | linked_resources=linked_resources, 84 | functional_requirement_render_attempts=0, 85 | ) 86 | 87 | def check_frid_iteration_limit(self): 88 | # If frid context is not set, it means that all frids have been implemented 89 | if self.frid_context is None: 90 | return 91 | 92 | if self.frid_context.functional_requirement_render_attempts >= MAX_CODE_GENERATION_RETRIES: 93 | console.error( 94 | f"Unittests could not be fixed after rendering the functional requirement {self.frid_context.frid} for the {MAX_CODE_GENERATION_RETRIES} times." 95 | ) 96 | self.machine.dispatch(triggers.HANDLE_ERROR) 97 | 98 | self.frid_context.functional_requirement_render_attempts += 1 99 | 100 | if self.frid_context.functional_requirement_render_attempts > 1: 101 | # this if is intended just for logging 102 | console.info( 103 | f"Unittests could not be fixed after rendering the functional requirement. " 104 | f"Restarting rendering the functional requirement {self.frid_context.frid} from scratch." 105 | ) 106 | 107 | def finish_implementing_frid(self): 108 | self.functional_requirements_render_attempts_failed_unit_during_conformance_tests = 0 109 | pass 110 | 111 | def start_unittests_processing(self): 112 | self.unit_tests_running_context = UnitTestsRunningContext(fix_attempts=0) 113 | self.run_state.increment_unittest_batch_id() 114 | 115 | def start_unittests_processing_in_conformance_tests(self): 116 | self.start_unittests_processing() 117 | # set to first FRID 118 | self.conformance_tests_running_context.current_testing_frid = plain_spec.get_first_frid(self.plain_source_tree) 119 | 120 | def finish_unittests_processing(self): 121 | existing_files = file_utils.list_all_text_files(self.args.build_folder) 122 | 123 | # TODO: Double check if this logic is what we want 124 | for file_name in self.unit_tests_running_context.changed_files: 125 | if file_name not in existing_files: 126 | self.frid_context.changed_files.discard(file_name) 127 | else: 128 | self.frid_context.changed_files.add(file_name) 129 | self.unit_tests_running_context.fix_attempts = 1 130 | 131 | def start_fixing_unit_tests(self): 132 | self.unit_tests_running_context.fix_attempts += 1 133 | 134 | if self.unit_tests_running_context.fix_attempts > MAX_UNITTEST_FIX_ATTEMPTS: 135 | self.machine.dispatch(triggers.RESTART_FRID_PROCESSING) 136 | 137 | def start_fixing_unit_tests_in_conformance_tests(self): 138 | self.unit_tests_running_context.fix_attempts += 1 139 | 140 | if self.unit_tests_running_context.fix_attempts > MAX_UNITTEST_FIX_ATTEMPTS: 141 | self.functional_requirements_render_attempts_failed_unit_during_conformance_tests += 1 142 | if ( 143 | self.functional_requirements_render_attempts_failed_unit_during_conformance_tests 144 | >= MAX_FUNCTIONAL_REQUIREMENT_RENDER_ATTEMPTS_FAILED_UNIT_DURING_CONFORMANCE_TESTS 145 | ): 146 | console.error( 147 | f"Failed to adjust unit tests after implementation code was update while fixing conformance tests for functional requirement {self.frid_context.frid} for the {MAX_FUNCTIONAL_REQUIREMENT_RENDER_ATTEMPTS_FAILED_UNIT_DURING_CONFORMANCE_TESTS} times." 148 | ) 149 | self.machine.dispatch(triggers.HANDLE_ERROR) 150 | else: 151 | console.info( 152 | f"Failed to adjust unit tests after implementation code was update while fixing conformance tests for functional requirement {self.frid_context.frid}." 153 | ) 154 | console.info(f"Restarting rendering the functional requirement {self.frid_context.frid} from scratch.") 155 | self.machine.dispatch(triggers.RESTART_FRID_PROCESSING) 156 | 157 | def start_fixing_unit_tests_in_refactoring(self): 158 | self.unit_tests_running_context.fix_attempts += 1 159 | 160 | if self.unit_tests_running_context.fix_attempts > MAX_UNITTEST_FIX_ATTEMPTS: 161 | git_utils.revert_changes(self.args.build_folder) 162 | self.machine.dispatch(triggers.START_NEW_REFACTORING_ITERATION) 163 | 164 | def start_refactoring_code(self): 165 | 166 | if self.frid_context.refactoring_iteration == 0: 167 | console.info("\n[b]Refactoring the generated code...[/b]\n") 168 | 169 | self.frid_context.refactoring_iteration += 1 170 | 171 | if self.frid_context.refactoring_iteration >= MAX_REFACTORING_ITERATIONS: 172 | if self.args.verbose: 173 | console.info( 174 | f"Refactoring iterations limit of {MAX_REFACTORING_ITERATIONS} reached for functional requirement {self.frid_context.frid}." 175 | ) 176 | self.machine.dispatch(triggers.PROCEED_FRID_PROCESSING) 177 | 178 | def start_testing_environment_preparation(self): 179 | if ( 180 | self.args.prepare_environment_script is None 181 | or not self.conformance_tests_running_context.should_prepare_testing_environment 182 | ): 183 | self.machine.dispatch(triggers.MARK_TESTING_ENVIRONMENT_PREPARED) 184 | 185 | def start_conformance_tests_processing(self): 186 | console.info("\n[b]Implementing conformance tests...[/b]\n") 187 | conformance_tests_json = self.conformance_tests_utils.get_conformance_tests_json() 188 | self.conformance_tests_running_context = ConformanceTestsRunningContext( 189 | current_testing_frid=None, 190 | current_testing_frid_specifications=None, 191 | conformance_test_phase_index=0, 192 | fix_attempts=0, 193 | conformance_tests_json=conformance_tests_json, 194 | conformance_tests_render_attempts=0, 195 | should_prepare_testing_environment=True, 196 | ) 197 | 198 | def finish_conformance_tests_processing(self): 199 | self.conformance_tests_running_context = None 200 | 201 | def start_conformance_tests_for_frid(self): 202 | if self.conformance_tests_running_context.regenerating_conformance_tests: 203 | if self.args.verbose: 204 | console.info( 205 | f"Recreating conformance tests for functional requirement {self.conformance_tests_running_context.current_testing_frid}." 206 | ) 207 | 208 | existing_conformance_tests_folder = self.conformance_tests_running_context.conformance_tests_json.pop( 209 | self.conformance_tests_running_context.current_testing_frid 210 | ) 211 | 212 | file_utils.delete_folder(existing_conformance_tests_folder["folder_name"]) 213 | 214 | self.conformance_tests_running_context.conformance_tests_render_attempts += 1 215 | self.conformance_tests_running_context.fix_attempts = 0 216 | self.conformance_tests_running_context.regenerating_conformance_tests = False 217 | else: 218 | if self.conformance_tests_running_context.current_testing_frid == self.frid_context.frid: 219 | 220 | if not self.frid_context.specifications.get( 221 | plain_spec.ACCEPTANCE_TESTS 222 | ) or self.conformance_tests_running_context.conformance_test_phase_index == len( 223 | self.frid_context.specifications[plain_spec.ACCEPTANCE_TESTS] 224 | ): 225 | self.machine.dispatch(triggers.MARK_ALL_CONFORMANCE_TESTS_PASSED) 226 | return 227 | 228 | should_reset_high_level_implementation_plan = ( 229 | self.conformance_tests_running_context.current_testing_frid == self.frid_context.frid 230 | and self.conformance_tests_running_context.conformance_test_phase_index == 0 231 | ) 232 | if should_reset_high_level_implementation_plan: 233 | self.conformance_tests_running_context.current_testing_frid_high_level_implementation_plan = None 234 | 235 | self.conformance_tests_running_context.conformance_test_phase_index += 1 236 | current_acceptance_tests = self.frid_context.specifications[plain_spec.ACCEPTANCE_TESTS][ 237 | : self.conformance_tests_running_context.conformance_test_phase_index 238 | ] 239 | self.conformance_tests_running_context.conformance_tests_json[self.frid_context.frid][ 240 | plain_spec.ACCEPTANCE_TESTS 241 | ] = current_acceptance_tests 242 | return 243 | 244 | if self.conformance_tests_running_context.current_testing_frid is None: 245 | self.conformance_tests_running_context.current_testing_frid = plain_spec.get_first_frid( 246 | self.plain_source_tree 247 | ) 248 | else: 249 | self.conformance_tests_running_context.current_testing_frid = plain_spec.get_next_frid( 250 | self.plain_source_tree, self.conformance_tests_running_context.current_testing_frid 251 | ) 252 | self.conformance_tests_running_context.current_testing_frid_specifications, _ = ( 253 | plain_spec.get_specifications_for_frid( 254 | self.plain_source_tree, self.conformance_tests_running_context.current_testing_frid 255 | ) 256 | ) 257 | if ConformanceTestHelpers.current_conformance_tests_exist(self.conformance_tests_running_context): # type: ignore 258 | self.machine.dispatch(triggers.MARK_CONFORMANCE_TESTS_READY) 259 | 260 | def start_fixing_conformance_tests(self): 261 | self.conformance_tests_running_context.fix_attempts += 1 262 | 263 | if self.conformance_tests_running_context.fix_attempts >= MAX_CONFORMANCE_TEST_FIX_ATTEMPTS: 264 | if ( 265 | self.conformance_tests_running_context.conformance_tests_render_attempts 266 | >= MAX_CONFORMANCE_TEST_RERENDER_ATTEMPTS 267 | ): 268 | console.error( 269 | f"We've already tried to fix the issue by recreating the conformance tests but tests still fail. Please fix the issues manually. FRID: {self.frid_context.frid}, Render ID: {self.run_state.render_id}" 270 | ) 271 | self.machine.dispatch(triggers.HANDLE_ERROR) 272 | else: 273 | self.conformance_tests_running_context.regenerating_conformance_tests = True 274 | self.machine.dispatch(triggers.MARK_REGENERATION_OF_CONFORMANCE_TESTS) 275 | 276 | def finish_fixing_conformance_tests(self): 277 | if self.args.verbose: 278 | console.info( 279 | f"[b]Running conformance tests attempt {self.conformance_tests_running_context.fix_attempts + 1}.[/b]" 280 | ) 281 | --------------------------------------------------------------------------------