#!/usr/bin/env python3
# -*- coding: utf-8 -*-
'''
Sequencer module for Observation Blocks (OBs) in JSON format
'''
# System modules
import argparse
import importlib
import importlib.resources
import json
import os
import signal
import sys
from datetime import datetime
from pathlib import Path # New import
# Other templates
from .utils.logger import log # Corrected relative import
[docs]
class Sequencer():
'''
Manage a sequence of JSON Observation Blocks (OBs)
'''
def __init__(self, embedded=True):
'''Constructor'''
self.ob_file = None
self.ob = [] # Content of the ob_file
self.error = []
self.tpl = None # The running template
self.params = {} # The parameters of the running template
self.quitting = False
self.embedded = embedded # To manage sys.exit if called alone
signal.signal(signal.SIGINT, self.interrupt)
self.original_sigint = signal.getsignal(signal.SIGINT)
[docs]
def load_script(self, template_script_path, params={}):
'''Load a python file where the template is implemented'''
# template_script_path could be a full path or a module path like "noctua.templates.lampsoff"
# For simplicity with argparse, we'll assume it's a file path for now,
# or a simple name if it's a bundled template.
script_path = Path(template_script_path)
if not script_path.exists():
# Try to resolve it as a module within noctua.templates
try:
# Create noctua/templates/__init__.py if it doesn't exist
with importlib.resources.path(f"noctua.templates", f"{template_script_path}.py") as p:
script_path = p
log.info(
f"Loading script '{template_script_path}' from package resources.")
except FileNotFoundError:
log.error(
f"Template script '{template_script_path}' not found as file or package resource.")
self.error.append(
f"Template script '{template_script_path}' not found.")
return
# The stem of the script file is used as the template name
# This matches how templates are named in JSON OBs
module_stem_name = script_path.stem
self.ob = {"template": module_stem_name, "params": params}
self.ob_file = str(script_path) # Store the path for logging/reference
log.info(
f"SEQUENCER: Loaded script {
self.ob_file} with params {params}")
[docs]
def load_file(self, ob_file_path_str):
'''Load a JSON file with an OB'''
ob_file_path = Path(ob_file_path_str)
self.ob_file = str(ob_file_path)
try:
with open(ob_file_path, encoding="utf-8") as jsonfile:
log.info(f"SEQUENCER: Loading the ob from file {self.ob_file}")
self.error = []
self.ob = json.load(jsonfile)
except json.decoder.JSONDecodeError as e:
msg = f"SEQUENCER: Probably malformed json in {self.ob_file}"
log.error(msg)
log.error(e)
self.error.append(msg)
self.error.append(str(e))
except UnicodeDecodeError as e:
msg = f"SEQUENCER: Not a text file: {self.ob_file}"
log.error(msg)
log.error(e)
self.error.append(msg)
self.error.append(str(e))
except FileNotFoundError:
msg = f"SEQUENCER: OB file not found: {self.ob_file}"
log.error(msg)
self.error.append(msg)
except Exception as e:
msg = f"SEQUENCER: Not handled exception while loading {
self.ob_file}"
log.error(msg)
log.error(e)
self.error.append(msg)
self.error.append(str(e))
if not self.embedded:
raise SystemExit(e) from e
[docs]
def execute(self):
'''Execute the template(s) contained in the loaded OB'''
if not self.ob:
log.error("SEQUENCER: No OB loaded to execute.")
self.error.append("No OB loaded.")
return
self.quitting = False
ob_list = self.ob if isinstance(self.ob, list) else [self.ob]
log.info(
f"There are {
len(ob_list)} templates in the OB: {
self.ob_file}")
log.debug(ob_list)
now = datetime.utcnow()
for template_item in ob_list:
if self.quitting:
log.info("SEQUENCER: Execution quitting.")
break
log.info("--------------------------------------------")
try:
self.error = [] # Reset errors for this template execution
template_name = template_item.get("template")
if not template_name:
log.error(
"SEQUENCER: Template item missing 'template' key.")
self.error.append("Template item missing 'template' key.")
continue # Skip to next template if malformed
# Importing the module given its name in the json.
full_module_name = f"noctua.templates.{template_name}"
try:
tplmodule = importlib.import_module(full_module_name)
except ModuleNotFoundError as e:
log.error(
f"SEQUENCER: Template module '{full_module_name}' not found: {e}")
self.error.append(
f"Template module '{full_module_name}' not found.")
continue
# Every template module has a Template() class.
self.tpl = tplmodule.Template()
self.params = template_item.get("params", {})
# Run the template given its parameters in the json.
self.tpl.run(self.params)
except AttributeError as e:
msg = f"SEQUENCER: Attribute Error in template '{template_name}'"
log.error(msg)
log.error(e, exc_info=True) # Log full traceback
self.error.append(msg)
self.error.append(str(e))
except KeyError as e:
msg = f"SEQUENCER: Parameter '{e}' is not defined for template '{template_name}'"
log.error(msg)
self.error.append(msg)
except Exception as e: # Catch other unexpected errors during template execution
msg = f"SEQUENCER: Unexpected error during execution of template '{template_name}'"
log.error(msg)
log.error(e, exc_info=True)
self.error.append(msg)
self.error.append(str(e))
log.info("--------------------------------------------")
log.debug(
f"Total elapsed time for OB {
self.ob_file}: {
(
datetime.utcnow() -
now).total_seconds():.3f}s")
[docs]
def interrupt(self, signum, frame):
'''Intercept a CTRL+C instead of raising a KeyboardInterrupt exception
in order to be able to operate on the template class, for
example to modify a pause attribute or call a method.
'''
# signal.signal(signal.SIGINT, self.original_sigint)
sys.stderr.write("\n--- INTERRUPT ---\n")
sys.stderr.write("(P)ause, (R)esume, (N)ext template, (Q)uit:\n")
sys.stderr.flush()
raw_line = sys.stdin.readline()
try:
# answer = input(
# "(P)ause, (R)esume, (N)ext template, (Q)uit:\n")
if not raw_line: # THIS IS THE CHECK FOR EOF (Ctrl+D)
msg = "SEQUENCER: CTRL-D (EOF) detected with readline!"
log.critical(msg)
if not self.embedded:
log.info("SEQUENCER: EOF in non-embedded mode. Forcing exit.")
if hasattr(
self,
'tpl') and self.tpl and hasattr(
self.tpl,
'abort'):
try:
self.tpl.abort()
except Exception:
pass # Best effort
os._exit(1)
else:
log.info("SEQUENCER: EOF in embedded mode. Signaling quit.")
self.quit()
return # Exit the interrupt handler
answer = raw_line.strip().lower()
if answer.startswith('p'):
self.pause()
elif answer.startswith('r'):
self.resume()
elif answer.startswith('n'): # Abort current template, move to next
self.abort_current_template()
elif answer.startswith('q'): # Quit entire OB
self.quit()
else:
log.info("SEQUENCER: No valid action taken on interrupt.")
# except EOFError: # CTRL+D
# msg = "SEQUENCER: CTRL-D detected! Exiting for real!"
# log.critical(msg)
# if not self.embedded:
# os._exit(1) # Force exit if not embedded
# else:
# self.quit() # Attempt graceful quit if embedded
except (KeyboardInterrupt, RuntimeError): # If Ctrl+C is hit again during input
log.warning(
"SEQUENCER: Interrupted during interrupt handling. Quitting.")
self.quit()
except Exception as e: # Add a broad catch here for debugging
sys.stderr.write(
f"ERROR IN INTERRUPT HANDLER: {
type(e).__name__}: {e}\n")
log.error("Error in interrupt handler", exc_info=True)
# Fallback to quitting if the handler itself fails
self.quit()
finally:
signal.signal(signal.SIGINT, self.interrupt) # Re-hook
[docs]
def pause(self):
'''
Put the pause attribute in the running template to True.
It will be checked in a template method.
'''
if self.tpl and hasattr(self.tpl, 'paused'):
msg = f"SEQUENCER: pausing template {
self.tpl.name} in ob {
self.ob_file}"
log.warning(msg)
self.tpl.paused = True
else:
log.warning(
"SEQUENCER: No active template to pause or template doesn't support pausing.")
[docs]
def resume(self):
'''
Restore the pause attribute in the template class to its
original state. It will be checked in a template method.
'''
if self.tpl and hasattr(self.tpl, 'paused'):
msg = f"SEQUENCER: resuming template {
self.tpl.name} in ob {
self.ob_file}"
log.warning(msg)
self.tpl.paused = False
else:
log.warning(
"SEQUENCER: No active template to resume or template doesn't support pausing.")
[docs]
def abort_current_template(self):
'''
Abort the currently running template. The sequencer will pass to the
following one, if any.
'''
if self.tpl and hasattr(
self.tpl,
'aborted') and hasattr(
self.tpl,
'abort'):
msg = f"SEQUENCER: Aborting current template {
self.tpl.name} in OB {
self.ob_file}."
log.error(msg)
self.error.append(msg)
self.tpl.aborted = True # Signal the template to stop its
# current paragraph self.tpl.abort() # Call template's
# specific abort logic (e.g., stop camera)
else:
log.warning(
"SEQUENCER: No active template to abort or template doesn't support aborting.")
[docs]
def quit(self):
'''Abort the execution of the whole OB'''
msg = f"SEQUENCER: Quit detected during OB {self.ob_file}."
log.error(msg)
self.error.append(msg)
self.quitting = True # Signal the main execution loop to stop
if self.tpl and hasattr(
self.tpl,
'aborted') and hasattr(
self.tpl,
'abort'):
log.warning(
"SEQUENCER: Aborting current template as part of quitting.")
self.abort_current_template()
if not self.embedded:
log.info("SEQUENCER: Exiting CLI.")
sys.exit(1) # Exit if running standalone
[docs]
def cli():
'''
Command line interface for sequencer
'''
parser = argparse.ArgumentParser(
description="Noctua Observatory Sequencer: Loads and executes Observation Blocks (OBs) or individual template scripts.",
formatter_class=argparse.RawTextHelpFormatter)
parser.add_argument(
"target",
nargs='?', # Makes it optional
help=(
"Path to the JSON Observation Block (OB) file to execute.\n"
"If not provided and --script is not used, help is shown.\n"
"Example: defaults/bias.json\n"
"If the path is not absolute and the file is not in the current directory,\n"
"it will be searched for within the 'noctua.defaults' package resources."
)
)
parser.add_argument(
"-s",
"--script",
metavar="TEMPLATE_NAME_OR_PATH",
help=(
"Execute a single template script directly.\n"
"Provide the name of the template (e.g., 'lampsoff') or a path to a .py file.\n"
"If just a name, it's assumed to be in 'noctua.templates'.\n"
"Requires --params to be specified.\n"
"Example: -s lampsoff --params '{}'"))
parser.add_argument(
"-p", "--params",
metavar="JSON_STRING",
help=(
"JSON string of parameters for the template when using --script.\n"
"Example: --params '{\"repeat\": 5, \"filter\": \"R\"}'"
),
default="{}" # Default to empty JSON object
)
args = parser.parse_args()
seq = Sequencer(embedded=False) # For CLI, always run not embedded
# If no arguments are given, or only 'target' is missing without --script
if not args.target and not args.script:
parser.print_help()
sys.exit(0)
if args.script:
if not args.params:
log.error("Error: --params must be provided when using --script.")
parser.print_help()
sys.exit(1)
try:
script_params = json.loads(args.params)
except json.JSONDecodeError:
log.error(
f"Error: Invalid JSON string for --params: {args.params}")
parser.print_help()
sys.exit(1)
seq.load_script(args.script, script_params)
elif args.target:
# Logic to load from CWD or package resources
ob_file_path = Path(args.target)
if not ob_file_path.is_absolute() and not ob_file_path.exists():
try:
# Create noctua/defaults/__init__.py (can be empty) if it
# doesn't exist
with importlib.resources.path("noctua.defaults", ob_file_path.name) as p:
log.info(
f"Loading OB '{
ob_file_path.name}' from package resources.")
seq.load_file(str(p))
except FileNotFoundError:
log.error(
f"OB file '{
args.target}' not found in CWD or package resources (noctua.defaults).")
sys.exit(1)
else:
seq.load_file(args.target)
else:
# Should not happen due to the check above, but as a safeguard
parser.print_help()
sys.exit(0)
if not seq.error: # Only execute if loading was successful
seq.execute()
else:
log.error(f"Failed to load OB/Script. Errors: {seq.error}")
sys.exit(1) # Exit with error if loading failed
if __name__ == "__main__":
# If called alone
cli()