Skip to content

Commit a4c15e4

Browse files
committed
Add headless task runner and CLI entrypoint
Introduce headless execution support and a CLI for running one-off tasks without the GUI. Adds HeadlessApp, OK.headless_app, OK.run_task and run_task helper to select tasks by index, name, class or instance, plus safer wait/join and exit handling. Integrates argument parsing (parse_arguments_to_map) and switches TaskManager to use the headless app when appropriate; enforces ASCII install-path failure in headless mode. StartController.do_start now returns a boolean success value and TaskManager avoids initializing the debug file watcher in headless mode. Add ok.cli module and ok.__main__ plus a console_scripts entry in setup.py to expose the ok command.
1 parent 657744c commit a4c15e4

6 files changed

Lines changed: 272 additions & 11 deletions

File tree

ok/__init__.py

Lines changed: 174 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
from ok.util.config import Config, ConfigOption
2929
from ok.util.handler import Handler, ExitEvent
3030
from ok.util.logger import config_logger, Logger
31-
from ok.util.process import check_mutex, get_first_gpu_free_memory_mib
31+
from ok.util.process import check_mutex, get_first_gpu_free_memory_mib, parse_arguments_to_map
3232
from ok.util.file import get_path_relative_to_exe, install_path_isascii
3333
from ok.util.window import windows_graphics_available
3434
from ok.device.intercation import DoNothingInteraction, BaseInteraction, BrowserInteraction, PostMessageInteraction, \
@@ -238,6 +238,57 @@ def handle_sigint(signum, frame):
238238

239239
sys.exit(self.app.exec())
240240

241+
242+
class HeadlessApp:
243+
"""Small app facade for running tasks without creating any UI windows."""
244+
245+
def __init__(self, config, exit_event=None):
246+
og.exit_event = exit_event
247+
og.handler = Handler(exit_event, 'global')
248+
self.config = config
249+
self.debug = config.get('debug', False)
250+
self.headless = True
251+
self.global_config = og.global_config
252+
self.icon = None
253+
self.exit_event = exit_event
254+
self.po_translation = None
255+
self.to_translate = None
256+
257+
from ok.gui.common.config import cfg
258+
self.locale = cfg.get(cfg.language).value
259+
from ok.gui.StartController import StartController
260+
self.start_controller = StartController(self.config, exit_event)
261+
262+
og.app = self
263+
if my_app := self.config.get('my_app'):
264+
og.my_app = init_class_by_name(my_app[0], my_app[1], exit_event)
265+
logger.debug('init headless app end')
266+
267+
def tr(self, key):
268+
if not key:
269+
return key
270+
if ok_tr := QCoreApplication.translate("app", key):
271+
if ok_tr != key:
272+
return ok_tr
273+
if self.po_translation is None:
274+
locale_name = self.locale.name()
275+
try:
276+
from ok.gui.i18n.GettextTranslator import get_translations
277+
self.po_translation = get_translations(locale_name)
278+
self.po_translation.install()
279+
logger.info(f'headless translation installed for {locale_name}')
280+
except:
281+
logger.error(f'install headless translations error for {locale_name}')
282+
self.po_translation = "Failed"
283+
if self.po_translation != 'Failed':
284+
return self.po_translation.gettext(key)
285+
return key
286+
287+
def quit(self):
288+
if self.exit_event:
289+
self.exit_event.set()
290+
291+
241292
def get_my_id():
242293
mac = uuid.getnode()
243294
value_with_salt = 'mac123:' + str(mac)
@@ -294,8 +345,10 @@ def __init__(self, config):
294345
logger.info(
295346
f"pyappify app_version:{pyappify.app_version}, app_profile:{pyappify.app_profile}, pyappify_version:{pyappify.pyappify_version} pyappify_upgradeable:{pyappify.pyappify_upgradeable}, pyappify_executable:{pyappify.pyappify_executable}")
296347
config['debug'] = config.get("debug", False)
348+
self.args = parse_arguments_to_map()
297349
self.task_executor = None
298350
self._app = None
351+
self._headless_app = None
299352
self.debug = config['debug']
300353
self.global_config = GlobalConfig(config.get('global_configs'))
301354
windows_config = config.get('windows')
@@ -337,9 +390,21 @@ def app(self):
337390
self._app = App(self.config, self.task_executor, self.exit_event)
338391
return self._app
339392

393+
@property
394+
def headless_app(self):
395+
if self._headless_app is None:
396+
self._headless_app = HeadlessApp(self.config, self.exit_event)
397+
return self._headless_app
398+
399+
def should_init_task_manager_headless(self):
400+
return not self.config.get("use_gui") or self.args.get('task', 0) > 0
401+
340402
def start(self):
341403
logger.info(f'OK start id:{id(self)} pid:{os.getpid()}')
342404
try:
405+
if self.args.get('task', 0) > 0:
406+
self.run_task(self.args.get('task'))
407+
return
343408
if self.config.get("use_gui"):
344409
if not self.init_error:
345410
self.app.show_main_window()
@@ -374,9 +439,76 @@ def start(self):
374439
except Exception as e:
375440
logger.error("start error", e)
376441
self.exit_event.set()
377-
if self.app:
442+
if self._app or self._headless_app:
378443
self.quit()
379444

445+
def run_task(self, task=1):
446+
"""
447+
Run a one-time task without showing the main UI.
448+
449+
Args:
450+
task: 1-based one-time task index, task name, task class, or task instance.
451+
"""
452+
task = self.get_onetime_task(task)
453+
logger.info(f'run task without ui: {task.name}')
454+
started = self.headless_app.start_controller.do_start(task, exit_after=True)
455+
if not started:
456+
raise RuntimeError(f'Start task failed: {task.name}')
457+
self.wait_task(task)
458+
return True
459+
460+
def get_onetime_task(self, task):
461+
if isinstance(task, int):
462+
task_index = task - 1
463+
if task_index < 0 or task_index >= len(self.task_executor.onetime_tasks):
464+
raise IndexError(
465+
f'Task index {task} is out of range. Available range is 1-{len(self.task_executor.onetime_tasks)}')
466+
return self.task_executor.onetime_tasks[task_index]
467+
if isinstance(task, str):
468+
normalized_task = task.lower()
469+
exact_matches = [
470+
candidate for candidate in self.task_executor.onetime_tasks
471+
if candidate.name.lower() == normalized_task or candidate.__class__.__name__.lower() == normalized_task
472+
]
473+
if len(exact_matches) == 1:
474+
return exact_matches[0]
475+
if len(exact_matches) > 1:
476+
names = ', '.join(candidate.__class__.__name__ for candidate in exact_matches)
477+
raise ValueError(f'Multiple tasks matched "{task}": {names}')
478+
479+
partial_matches = [
480+
candidate for candidate in self.task_executor.onetime_tasks
481+
if normalized_task in candidate.name.lower()
482+
or normalized_task in candidate.__class__.__name__.lower()
483+
]
484+
if len(partial_matches) == 1:
485+
return partial_matches[0]
486+
if len(partial_matches) > 1:
487+
names = ', '.join(candidate.__class__.__name__ for candidate in partial_matches)
488+
raise ValueError(f'Multiple tasks matched "{task}": {names}')
489+
490+
for candidate in self.task_executor.onetime_tasks:
491+
if candidate.name == task or candidate.__class__.__name__ == task:
492+
return candidate
493+
raise ValueError(f'Task not found: {task}')
494+
if isinstance(task, type):
495+
for candidate in self.task_executor.onetime_tasks:
496+
if isinstance(candidate, task):
497+
return candidate
498+
if not issubclass(task, BaseTask):
499+
raise ValueError(f'Task class must inherit BaseTask: {task}')
500+
if issubclass(task, TriggerTask):
501+
raise ValueError(f'run_task only supports one-time BaseTask classes, got TriggerTask: {task}')
502+
task_instance = task(executor=self.task_executor, app=self.headless_app)
503+
task_instance.after_init(executor=self.task_executor, scene=self.task_executor.scene)
504+
task_instance.post_init()
505+
self.task_executor.onetime_tasks.append(task_instance)
506+
logger.info(f'created headless task from class: {task_instance}')
507+
return task_instance
508+
if task in self.task_executor.onetime_tasks:
509+
return task
510+
raise ValueError(f'Unsupported one-time task selector: {task}')
511+
380512
def do_init(self):
381513
logger.info(f"do_init, config: {self.config}")
382514
self.init_device_manager()
@@ -401,9 +533,12 @@ def do_init(self):
401533
ocr_target_height = ocr.get('target_height', 0)
402534
if not isascii:
403535
logger.info(f'show_path_ascii_error')
404-
self.app.show_path_ascii_error(path)
405536
self.init_error = True
406-
self.app.exec()
537+
if self.should_init_task_manager_headless():
538+
raise RuntimeError(f'Install dir {path} must be an English path, move to another path.')
539+
else:
540+
self.app.show_path_ascii_error(path)
541+
self.app.exec()
407542
return False
408543

409544
self.task_executor = TaskExecutor(self.device_manager, exit_event=self.exit_event,
@@ -413,17 +548,29 @@ def do_init(self):
413548
global_config=self.global_config, ocr_target_height=ocr_target_height,
414549
config=self.config)
415550
from ok.gui.tasks.TaskManger import TaskManager
416-
og.task_manager = TaskManager(task_executor=self.task_executor, app=self.app,
551+
task_app = self.headless_app if self.should_init_task_manager_headless() else self.app
552+
og.task_manager = TaskManager(task_executor=self.task_executor, app=task_app,
417553
onetime_tasks=self.config.get('onetime_tasks', []),
418554
trigger_tasks=self.config.get('trigger_tasks', []),
419555
scene=self.config.get('scene'))
420556
og.executor = self.task_executor
421557
logger.info(f"do_init, end")
422558
return True
423559

424-
def wait_task(self):
425-
while not self.exit_event.is_set():
426-
time.sleep(1)
560+
def wait_task(self, task=None):
561+
try:
562+
while not self.exit_event.is_set():
563+
if task is not None and not task.enabled and self.task_executor.current_task is not task:
564+
logger.info(f'task finished without ui: {task.name}')
565+
break
566+
time.sleep(1)
567+
except KeyboardInterrupt:
568+
logger.info("Keyboard interrupt received, exiting script.")
569+
finally:
570+
if task is not None:
571+
self.exit_event.set()
572+
if self.task_executor.thread and self.task_executor.thread != threading.current_thread():
573+
self.task_executor.thread.join(timeout=10)
427574

428575
def console_handler(self, event):
429576
import win32con
@@ -450,6 +597,8 @@ def quit(self):
450597
self.exit_event.set()
451598
if self._app:
452599
self._app.quit()
600+
if self._headless_app:
601+
self._headless_app.quit()
453602

454603
def init_device_manager(self):
455604
if self.device_manager is None:
@@ -458,6 +607,23 @@ def init_device_manager(self):
458607
og.device_manager = self.device_manager
459608

460609

610+
def run_task(config, task=1, debug=False):
611+
"""
612+
Convenience entrypoint for scripts that only need to run one task.
613+
614+
Example:
615+
from ok import run_task
616+
from src.config import config
617+
618+
if __name__ == "__main__":
619+
run_task(config, task=1)
620+
"""
621+
headless_config = dict(config)
622+
headless_config["use_gui"] = False
623+
headless_config["debug"] = debug
624+
return OK(headless_config).run_task(task)
625+
626+
461627
class BaseScene:
462628
def reset(self):
463629
pass

ok/__main__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from ok.cli import main
2+
3+
4+
if __name__ == "__main__":
5+
raise SystemExit(main())

ok/cli.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import argparse
2+
import importlib
3+
import os
4+
import sys
5+
6+
7+
DEFAULT_CONFIG_TARGETS = ("src.config:config", "config:config")
8+
9+
10+
def _split_target(target):
11+
if ":" in target:
12+
module_name, attr_name = target.split(":", 1)
13+
else:
14+
module_name, attr_name = target, "config"
15+
if not module_name or not attr_name:
16+
raise ValueError(f"Invalid config target: {target}")
17+
return module_name, attr_name
18+
19+
20+
def load_config(target=None):
21+
cwd = os.getcwd()
22+
if cwd not in sys.path:
23+
sys.path.insert(0, cwd)
24+
25+
targets = (target,) if target else DEFAULT_CONFIG_TARGETS
26+
errors = []
27+
for item in targets:
28+
module_name, attr_name = _split_target(item)
29+
try:
30+
module = importlib.import_module(module_name)
31+
return getattr(module, attr_name)
32+
except Exception as e:
33+
errors.append(f"{item}: {e}")
34+
35+
details = "\n".join(errors)
36+
raise RuntimeError(
37+
"Could not load config. Tried:\n"
38+
f"{details}\n"
39+
"Use --config module:attribute to point to your project config."
40+
)
41+
42+
43+
def run_task_command(args):
44+
task = args.task_name or args.task
45+
if not task:
46+
raise ValueError("Task name is required. Use: ok run_task TaskName")
47+
48+
from ok import run_task
49+
50+
config = load_config(args.config)
51+
run_task(config, task=task, debug=args.debug)
52+
return 0
53+
54+
55+
def build_parser():
56+
parser = argparse.ArgumentParser(prog="ok")
57+
subparsers = parser.add_subparsers(dest="command", required=True)
58+
59+
run_task_parser = subparsers.add_parser("run_task", help="Run a task without starting the UI")
60+
run_task_parser.add_argument("task_name", nargs="?", help="Task name or class name to match")
61+
run_task_parser.add_argument("-t", "--task", help="Task name or class name to match")
62+
run_task_parser.add_argument("-d", "--debug", action="store_true", help="Run with debug mode enabled")
63+
run_task_parser.add_argument(
64+
"-c",
65+
"--config",
66+
help="Config import target, for example src.config:config or config:config",
67+
)
68+
run_task_parser.set_defaults(func=run_task_command)
69+
70+
return parser
71+
72+
73+
def main(argv=None):
74+
parser = build_parser()
75+
args = parser.parse_args(argv)
76+
try:
77+
return args.func(args)
78+
except Exception as e:
79+
parser.exit(1, f"ok: error: {e}\n")
80+
81+
82+
if __name__ == "__main__":
83+
raise SystemExit(main())

ok/gui/StartController.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,12 @@ def do_start(self, task=None, exit_after=False):
3333
except Exception as e:
3434
logger.error(f'do_start do_refresh exception: {e}', e)
3535
communicate.starting_emulator.emit(True, self.tr(str(e)), 0)
36-
return
36+
return False
3737

3838
try:
3939
if self.start_exe:
4040
if not self.start_device():
41-
return
41+
return False
4242
else:
4343
logger.info('windows.start_exe is False, skip start_device')
4444

@@ -66,9 +66,11 @@ def add_task_to_enable(enable_task):
6666

6767
og.executor.start()
6868
communicate.starting_emulator.emit(True, None, 0)
69+
return True
6970
except Exception as e:
7071
logger.error(f'do_start exception: {e}', e)
7172
communicate.starting_emulator.emit(True, self.tr(f'Start failed: {e}'), 0)
73+
return False
7274

7375
def start_device(self):
7476
device = og.device_manager.get_preferred_device()

ok/gui/tasks/TaskManger.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def __init__(self, task_executor, app, trigger_tasks=[], onetime_tasks=[], scene
4242
task.post_init()
4343
self.load_user_tasks()
4444
self.load_imported_tasks()
45-
if self.debug or self.custom_tasks_enabled:
45+
if (self.debug or self.custom_tasks_enabled) and not getattr(app, 'headless', False):
4646
self._init_debug_file_watcher()
4747

4848
def init_tasks(self, task_classes):

setup.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@
4343
'pycaw==20240210',
4444
'mouse==0.7.1'
4545
],
46+
entry_points={
47+
'console_scripts': [
48+
'ok=ok.cli:main',
49+
],
50+
},
4651
python_requires='==3.12.*',
4752
zip_safe=False,
4853
)

0 commit comments

Comments
 (0)