Skip to content

Commit c7dea68

Browse files
authored
Headless mode (#124)
Implemented headless mode, decoupled renderer from TUI.
1 parent 81a3a9e commit c7dea68

6 files changed

Lines changed: 101 additions & 147 deletions

File tree

docs/plain2code_cli.md

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
# Plain2Code CLI Reference
22

33
```text
4-
usage: generate_cli.py [-h] [--verbose] [--base-folder BASE_FOLDER] [--build-folder BUILD_FOLDER] [--log-to-file | --no-log-to-file] [--log-file-name LOG_FILE_NAME] [--config-name CONFIG_NAME] [--render-range RENDER_RANGE | --render-from RENDER_FROM] [--force-render]
5-
[--unittests-script UNITTESTS_SCRIPT] [--conformance-tests-folder CONFORMANCE_TESTS_FOLDER] [--conformance-tests-script CONFORMANCE_TESTS_SCRIPT] [--prepare-environment-script PREPARE_ENVIRONMENT_SCRIPT] [--api [API]] [--api-key API_KEY]
6-
[--full-plain] [--dry-run] [--replay-with REPLAY_WITH] [--template-dir TEMPLATE_DIR] [--copy-build] [--build-dest BUILD_DEST] [--copy-conformance-tests] [--conformance-tests-dest CONFORMANCE_TESTS_DEST] [--render-machine-graph]
7-
[--logging-config-path]
4+
usage: generate_cli.py [-h] [--verbose] [--base-folder BASE_FOLDER] [--build-folder BUILD_FOLDER] [--log-to-file | --no-log-to-file] [--log-file-name LOG_FILE_NAME] [--config-name CONFIG_NAME]
5+
[--render-range RENDER_RANGE | --render-from RENDER_FROM] [--force-render] [--unittests-script UNITTESTS_SCRIPT] [--conformance-tests-folder CONFORMANCE_TESTS_FOLDER]
6+
[--conformance-tests-script CONFORMANCE_TESTS_SCRIPT] [--prepare-environment-script PREPARE_ENVIRONMENT_SCRIPT] [--test-script-timeout TEST_SCRIPT_TIMEOUT] [--api [API]] [--api-key API_KEY]
7+
[--full-plain] [--dry-run] [--replay-with REPLAY_WITH] [--template-dir TEMPLATE_DIR] [--copy-build] [--build-dest BUILD_DEST] [--copy-conformance-tests]
8+
[--conformance-tests-dest CONFORMANCE_TESTS_DEST] [--render-machine-graph] [--logging-config-path] [--headless]
89
filename
910
1011
Render plain code to target code.
1112
1213
positional arguments:
13-
filename Path to the plain file to render. The directory containing this file has highest precedence for template loading, so you can place custom templates here to override the defaults. See --template-dir for more details about template loading.
14+
filename Path to the plain file to render. The directory containing this file has highest precedence for template loading, so you can place custom templates here to override the defaults. See --template-dir
15+
for more details about template loading.
1416
1517
options:
1618
-h, --help show this help message and exit
@@ -20,41 +22,48 @@ options:
2022
--build-folder BUILD_FOLDER
2123
Folder for build files
2224
--log-to-file, --no-log-to-file
23-
Enable logging to a file. Defaults to True. Use --no-log-to-file to disable.
25+
Enable logging to a file. Defaults to True. Set to False to disable.
2426
--log-file-name LOG_FILE_NAME
25-
Name of the log file. Defaults to 'codeplain.log'.Always resolved relative to the plain file directory.If file on this path already exists, it will be overwritten by the current logs.
27+
Name of the log file. Defaults to 'codeplain.log'.Always resolved relative to the plain file directory.If file on this path already exists, the already existing log file will be overwritten by the
28+
current logs.
2629
--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 IDs. If only one ID is provided, only that requirement is rendered. Range is inclusive of both start and end IDs.
30+
Specify a range of functional requirements to render (e.g. `1` , `2`, `3`). Use comma to separate start and end IDs. If only one ID is provided, only that requirement is rendered. Range is
31+
inclusive of both start and end IDs.
2832
--render-from RENDER_FROM
29-
Continue generation starting from this specific functional requirement (e.g. '2.1'). The requirement with this ID will be included in the output. The ID must match one of the functional requirements in your plain file.
33+
Continue generation starting from this specific functional requirement (e.g. `2`). The requirement with this ID will be included in the output. The ID must match one of the functional requirements
34+
in your plain file.
3035
--force-render Force re-render of all the required modules.
3136
--unittests-script UNITTESTS_SCRIPT
3237
Shell script to run unit tests on generated code. Receives the build folder path as its first argument (default: 'plain_modules').
3338
--conformance-tests-folder CONFORMANCE_TESTS_FOLDER
3439
Folder for conformance test files
3540
--conformance-tests-script CONFORMANCE_TESTS_SCRIPT
36-
Path to conformance tests shell script. The script should accept two arguments: 1) First argument: path to a folder (e.g. 'plain_modules/module_name') containing generated source code, 2) Second argument: path to a subfolder of the conformance
37-
tests folder (e.g. 'conformance_tests/subfoldername') containing test files.
41+
Path to conformance tests shell script. Every conformance test script should accept two arguments: 1) Path to a folder (e.g. `plain_modules/module_name`) containing generated source code, 2) Path
42+
to a subfolder of the conformance tests folder (e.g. `conformance_tests/subfoldername`) containing test files.
3843
--prepare-environment-script PREPARE_ENVIRONMENT_SCRIPT
39-
Path to a shell script that prepares the testing environment. The script should accept the build folder path as its first argument (default: 'plain_modules').
44+
Path to a shell script that prepares the testing environment. The script should accept the source code folder path as its first argument.
45+
--test-script-timeout TEST_SCRIPT_TIMEOUT
46+
Timeout for test scripts in seconds. If not provided, the default timeout of 120 seconds is used.
4047
--api [API] Alternative base URL for the API. Default: `https://api.codeplain.ai`
41-
--api-key API_KEY API key used to access the API. If not provided, the CODEPLAIN_API_KEY environment variable is used.
42-
--full-plain Display the complete plain specification before code generation. This shows your plain file with any included template content expanded. Useful for understanding what content is being processed.
43-
--dry-run Preview of what Codeplain would do without actually making any changes.
48+
--api-key API_KEY API key used to access the API. If not provided, the `CODEPLAIN_API_KEY` environment variable is used.
49+
--full-plain Full preview ***plain specification before code generation.Use when you want to preview context of all ***plain primitives that are going to be included in order to render the given module.
50+
--dry-run Dry run preview of the code generation (without actually making any changes).
4451
--replay-with REPLAY_WITH
4552
--template-dir TEMPLATE_DIR
46-
Path to a custom template directory. Templates are searched in the following order: 1) directory containing the plain file, 2) this custom template directory (if provided), 3) built-in standard_template_library directory
47-
--copy-build If set, copy the build folder to `--build-dest` after every successful rendering.
53+
Path to a custom template directory. Templates are searched in the following order: 1) Directory containing the plain file, 2) Custom template directory (if provided through this argument), 3)
54+
Built-in standard_template_library directory
55+
--copy-build If set, copy the rendered contents of code in `--base-folder` folder to `--build-dest` folder after successful rendering.
4856
--build-dest BUILD_DEST
49-
Target folder to copy build output to (used only if --copy-build is set).
57+
Target folder to copy rendered contents of code to (used only if --copy-build is set).
5058
--copy-conformance-tests
51-
If set, copy the conformance tests folder to `--conformance-tests-dest` after every successful rendering. Requires --conformance-tests-script.
59+
If set, copy the conformance tests of code in `--conformance-tests-folder` folder to `--conformance-tests-dest` folder successful rendering. Requires --conformance-tests-script.
5260
--conformance-tests-dest CONFORMANCE_TESTS_DEST
53-
Target folder to copy conformance tests output to (used only if --copy-conformance-tests is set).
61+
Target folder to copy conformance tests of code to (used only if --copy-conformance-tests is set).
5462
--render-machine-graph
5563
If set, render the state machine graph.
5664
--logging-config-path
5765
Path to the logging configuration file.
66+
--headless Run in headless mode: no TUI, no terminal output except a single render-started message. All logs are written to the log file.
5867
5968
configuration:
6069
--config-name CONFIG_NAME

event_bus.py

Lines changed: 9 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,26 +7,11 @@
77
class EventBus:
88
def __init__(self):
99
self._listeners: defaultdict[Type[BaseEvent], list[Callable[[Any], None]]] = defaultdict(list)
10-
self._main_thread_callback: Callable[[Callable], None] | None = None
11-
self._ready_callbacks: list[Callable[[], None]] = []
12-
13-
def register_main_thread_callback(self, fn: Callable[[Callable], None]):
14-
"""Set the function to call listeners from the main thread (e.g., Textual app.call_from_thread)."""
15-
self._main_thread_callback = fn
16-
17-
# Notify anyone waiting for the event bus to be ready
18-
for callback in self._ready_callbacks:
19-
callback()
20-
self._ready_callbacks.clear()
21-
22-
def on_ready(self, callback: Callable[[], None]):
23-
"""Register a callback to be called when the event bus is ready."""
24-
if self._main_thread_callback:
25-
# Already ready, call immediately
26-
callback()
27-
else:
28-
# Not ready yet, queue it
29-
self._ready_callbacks.append(callback)
10+
self._dispatch_wrapper: Callable[[Callable], None] | None = None
11+
12+
def register_dispatch_wrapper(self, fn: Callable[[Callable], None]):
13+
"""Set a wrapper for dispatching listeners (e.g., Textual's app.call_from_thread)."""
14+
self._dispatch_wrapper = fn
3015

3116
def subscribe(self, event_type: Type[BaseEvent], listener: Callable[[Any], None]):
3217
"""Registers a listener for a specific event type."""
@@ -39,7 +24,7 @@ def _dispatch():
3924
for listener in self._listeners[type(event)]:
4025
listener(event)
4126

42-
if not self._main_thread_callback:
43-
raise RuntimeError("No main thread callback set. Call register_main_thread_callback() first.")
44-
45-
self._main_thread_callback(_dispatch)
27+
if self._dispatch_wrapper:
28+
self._dispatch_wrapper(_dispatch)
29+
else:
30+
_dispatch()

plain2code.py

Lines changed: 43 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import logging.config
44
import os
55
import sys
6+
import threading
67
from pathlib import Path
78
from typing import Optional
89

@@ -18,6 +19,7 @@
1819
from module_renderer import ModuleRenderer
1920
from plain2code_arguments import parse_arguments
2021
from plain2code_console import console
22+
from plain2code_events import RenderFailed
2123
from plain2code_exceptions import (
2224
ConflictingRequirements,
2325
CreditBalanceTooLow,
@@ -122,6 +124,7 @@ def setup_logging(
122124
log_file_name: str,
123125
plain_file_path: Optional[str],
124126
render_id: str,
127+
headless: bool = False,
125128
):
126129
# Set default level to INFO for everything not explicitly configured
127130
logging.getLogger().setLevel(logging.INFO)
@@ -162,10 +165,12 @@ def setup_logging(
162165
for h in root_logger.handlers[:]:
163166
root_logger.removeHandler(h)
164167

165-
handler = TuiLoggingHandler(event_bus)
166168
formatter = IndentedFormatter("%(levelname)s:%(name)s:%(message)s")
167-
handler.setFormatter(formatter)
168-
root_logger.addHandler(handler)
169+
170+
if not headless:
171+
handler = TuiLoggingHandler(event_bus)
172+
handler.setFormatter(formatter)
173+
root_logger.addHandler(handler)
169174

170175
if log_to_file and log_file_path:
171176
try:
@@ -232,24 +237,36 @@ def render(args, run_state: RunState, event_bus: EventBus): # noqa: C901
232237
event_bus,
233238
)
234239

235-
app = Plain2CodeTUI(
236-
event_bus=event_bus,
237-
worker_fun=module_renderer.render_module,
238-
render_id=run_state.render_id,
239-
unittests_script=args.unittests_script,
240-
conformance_tests_script=args.conformance_tests_script,
241-
prepare_environment_script=args.prepare_environment_script,
242-
state_machine_version=system_config.client_version,
243-
css_path="styles.css",
244-
)
245-
result = app.run()
240+
render_error: list[Exception] = []
246241

247-
# If the app exited due to a worker error, re-raise it here
248-
# so it hits the exception handlers in main()
249-
if isinstance(result, Exception):
250-
raise result
242+
def run_render():
243+
try:
244+
module_renderer.render_module()
245+
except Exception as e:
246+
render_error.append(e)
247+
event_bus.publish(RenderFailed(error_message=str(e)))
251248

252-
return
249+
if args.headless:
250+
print(f"Render started. Render ID: {run_state.render_id}")
251+
module_renderer.render_module()
252+
return
253+
else:
254+
render_thread = threading.Thread(target=run_render, daemon=True)
255+
app = Plain2CodeTUI(
256+
event_bus=event_bus,
257+
on_ready=render_thread.start,
258+
render_id=run_state.render_id,
259+
unittests_script=args.unittests_script,
260+
conformance_tests_script=args.conformance_tests_script,
261+
prepare_environment_script=args.prepare_environment_script,
262+
state_machine_version=system_config.client_version,
263+
css_path="styles.css",
264+
)
265+
app.run()
266+
render_thread.join(timeout=1)
267+
268+
if render_error:
269+
raise render_error[0]
253270

254271

255272
def main(): # noqa: C901
@@ -285,7 +302,13 @@ def main(): # noqa: C901
285302

286303
run_state = RunState(spec_filename=args.filename, replay_with=args.replay_with)
287304

288-
setup_logging(args, event_bus, args.log_to_file, args.log_file_name, args.filename, run_state.render_id)
305+
if args.headless:
306+
# Suppress Rich console output.
307+
console.quiet = True
308+
309+
setup_logging(
310+
args, event_bus, args.log_to_file, args.log_file_name, args.filename, run_state.render_id, args.headless
311+
)
289312

290313
exc_info = None
291314
try:

plain2code_arguments.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,14 @@ def create_parser():
288288
help="Path to the logging configuration file.",
289289
)
290290

291+
parser.add_argument(
292+
"--headless",
293+
action="store_true",
294+
default=False,
295+
help="Run in headless mode: no TUI, no terminal output except a single render-started message. "
296+
"All logs are written to the log file.",
297+
)
298+
291299
return parser
292300

293301

plain2code_logger.py

Lines changed: 2 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -40,15 +40,10 @@ class TuiLoggingHandler(logging.Handler):
4040
def __init__(self, event_bus: EventBus):
4141
super().__init__()
4242
self.event_bus = event_bus
43-
self._buffer = []
44-
self.start_time = time.time() # Record start time for offset calculation
45-
46-
# Register to be notified when event bus is ready
47-
self.event_bus.on_ready(self._flush_buffer)
43+
self.start_time = time.time()
4844

4945
def emit(self, record):
5046
try:
51-
# Extract structured data from the log record
5247
offset_seconds = record.created - self.start_time
5348
minutes = int(offset_seconds // 60)
5449
seconds = int(offset_seconds % 60)
@@ -60,28 +55,14 @@ def emit(self, record):
6055
message=record.getMessage(),
6156
timestamp=timestamp,
6257
)
63-
64-
# Try to publish, fall back to buffering if not ready
65-
if self.event_bus._main_thread_callback:
66-
self.event_bus.publish(event)
67-
else:
68-
self._buffer.append(event)
58+
self.event_bus.publish(event)
6959
except RuntimeError:
7060
# We're going to get this crash after the TUI app is closed (forcefully).
7161
# NOTE: This should be more thought out.
7262
pass
7363
except Exception:
7464
self.handleError(record)
7565

76-
def _flush_buffer(self):
77-
"""Flush buffered log messages to the event bus."""
78-
for event in self._buffer:
79-
try:
80-
self.event_bus.publish(event)
81-
except Exception:
82-
pass
83-
self._buffer.clear()
84-
8566

8667
class CrashLogHandler(logging.Handler):
8768
def __init__(self):

0 commit comments

Comments
 (0)