Source code for noctua.sequencer

#!/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()