5656 import holidays
5757 import yaml
5858 from ics import Calendar , Event
59- from jinja2 import Environment
59+ from jinja2 import Environment , Undefined
6060except :
6161 print ("You lack some of the module dependencies to run this script." )
6262 print ("Please run 'pip3 install -r requirements.txt' and try again." )
7474templates = None
7575solr_news_file = None
7676
77+
78+ class ReadableUndefined (Undefined ):
79+ """Custom Undefined handler that renders undefined variables as {{ varname }}
80+
81+ This allows users to see which variables are not yet defined when displaying
82+ command templates before execution, particularly useful for persist_vars
83+ that haven't been captured yet.
84+ """
85+ def __str__ (self ):
86+ return "{{ %s }}" % self ._undefined_name
87+
88+ def __getattr__ (self , name ):
89+ # Handle special Python attributes normally
90+ if name [:2 ] == '__' :
91+ raise AttributeError (name )
92+ # Chain undefined attribute access for nested vars like {{ todo_id.var_name }}
93+ return ReadableUndefined (name = "%s.%s" % (self ._undefined_name , name ))
94+
95+
7796# Edit this to add other global jinja2 variables or filters
7897def expand_jinja (text , vars = None ):
7998 global_vars = OrderedDict ({
@@ -141,7 +160,7 @@ def expand_jinja(text, vars=None):
141160 filled = replace_templates (text )
142161
143162 try :
144- env = Environment (lstrip_blocks = True , keep_trailing_newline = False , trim_blocks = True )
163+ env = Environment (lstrip_blocks = True , keep_trailing_newline = False , trim_blocks = True , undefined = ReadableUndefined )
145164 env .filters ['path_join' ] = lambda paths : os .path .join (* paths )
146165 env .filters ['expanduser' ] = lambda path : os .path .expanduser (path )
147166 env .filters ['formatdate' ] = lambda date : (datetime .strftime (date , "%-d %B %Y" ) if date else "<date>" )
@@ -1420,19 +1439,22 @@ def tail_file(file, lines):
14201439 break
14211440
14221441
1423- def run_with_log_tail (command , cwd , logfile = None , tail_lines = 10 , tee = False , live = False , shell = None ):
1442+ def run_with_log_tail (command , cwd , logfile = None , tail_lines = 10 , tee = False , live = False , shell = None , capture_output = False ):
14241443 fh = sys .stdout
14251444 if logfile :
14261445 logdir = os .path .dirname (logfile )
14271446 if not os .path .exists (logdir ):
14281447 os .makedirs (logdir )
14291448 fh = open (logfile , 'w' )
1430- rc = run_follow (command , cwd , fh = fh , tee = tee , live = live , shell = shell )
1449+
1450+ rc , captured_output = run_follow (command , cwd , fh = fh , tee = tee , live = live , shell = shell , capture_output = capture_output )
1451+
14311452 if logfile :
14321453 fh .close ()
14331454 if not tee and tail_lines and tail_lines > 0 :
14341455 tail_file (logfile , tail_lines )
1435- return rc
1456+
1457+ return rc , captured_output
14361458
14371459
14381460def ask_yes_no (text ):
@@ -1463,7 +1485,7 @@ def print_line_cr(line, linenum, stdout=True, tee=False):
14631485 print (line .rstrip ())
14641486
14651487
1466- def run_follow (command , cwd = None , fh = sys .stdout , tee = False , live = False , shell = None ):
1488+ def run_follow (command , cwd = None , fh = sys .stdout , tee = False , live = False , shell = None , capture_output = False ):
14671489 doShell = '&&' in command or '&' in command or shell is not None
14681490 if not doShell and not isinstance (command , list ):
14691491 command = shlex .split (command )
@@ -1479,6 +1501,7 @@ def run_follow(command, cwd=None, fh=sys.stdout, tee=False, live=False, shell=No
14791501
14801502 endstdout = endstderr = False
14811503 errlines = []
1504+ captured_lines = [] if capture_output else None
14821505 while not (endstderr and endstdout ):
14831506 lines_before = lines_written
14841507 if not endstdout :
@@ -1490,6 +1513,8 @@ def run_follow(command, cwd=None, fh=sys.stdout, tee=False, live=False, shell=No
14901513 else :
14911514 fh .write (chars )
14921515 fh .flush ()
1516+ if capture_output :
1517+ captured_lines .append (chars )
14931518 if '\n ' in chars :
14941519 lines_written += 1
14951520 else :
@@ -1499,6 +1524,8 @@ def run_follow(command, cwd=None, fh=sys.stdout, tee=False, live=False, shell=No
14991524 else :
15001525 fh .write ("%s\n " % line .rstrip ())
15011526 fh .flush ()
1527+ if capture_output :
1528+ captured_lines .append (line )
15021529 lines_written += 1
15031530 print_line_cr (line , lines_written , stdout = (fh == sys .stdout ), tee = tee )
15041531
@@ -1536,7 +1563,12 @@ def run_follow(command, cwd=None, fh=sys.stdout, tee=False, live=False, shell=No
15361563 for line in errlines :
15371564 fh .write ("%s\n " % line .rstrip ())
15381565 fh .flush ()
1539- return rc
1566+
1567+ captured_output = None
1568+ if capture_output and captured_lines is not None :
1569+ captured_output = "" .join (captured_lines )
1570+
1571+ return rc , captured_output
15401572
15411573
15421574def is_windows ():
@@ -1637,6 +1669,7 @@ def run(self): # pylint: disable=inconsistent-return-statements # TODO
16371669 logfilename = cmd .logfile
16381670 logfile = None
16391671 cmd_to_run = "%s%s" % ("echo Dry run, command is: " if dry_run else "" , cmd .get_cmd ())
1672+ need_capture = cmd .persist_vars and not dry_run
16401673 if cmd .redirect :
16411674 try :
16421675 out = run (cmd_to_run , cwd = cwd )
@@ -1645,6 +1678,7 @@ def run(self): # pylint: disable=inconsistent-return-statements # TODO
16451678 outfile .write (out )
16461679 outfile .flush ()
16471680 print ("Wrote %s bytes to redirect file %s" % (len (out ), cmd .get_redirect ()))
1681+ cmd_output = out
16481682 except Exception as e :
16491683 print ("Command %s failed: %s" % (cmd_to_run , e ))
16501684 success = False
@@ -1668,8 +1702,8 @@ def run(self): # pylint: disable=inconsistent-return-statements # TODO
16681702 if cmd .comment :
16691703 print ("# %s\n " % cmd .get_comment ())
16701704 start_time = time .time ()
1671- returncode = run_with_log_tail (cmd_to_run , cwd , logfile = logfile , tee = cmd .tee , tail_lines = 25 ,
1672- live = cmd .live , shell = cmd .shell )
1705+ returncode , cmd_output = run_with_log_tail (cmd_to_run , cwd , logfile = logfile , tee = cmd .tee , tail_lines = 25 ,
1706+ live = cmd .live , shell = cmd .shell , capture_output = need_capture )
16731707 elapsed = time .time () - start_time
16741708 if not returncode == 0 :
16751709 if cmd .should_fail :
@@ -1684,9 +1718,23 @@ def run(self): # pylint: disable=inconsistent-return-statements # TODO
16841718 print ("Expected command to fail, but it succeeded." )
16851719 success = False
16861720 break
1687- else :
1688- if elapsed > 30 :
1689- print ("Command completed in %s seconds" % elapsed )
1721+
1722+ # Handle persist_vars: capture stdout and parse for --wizard-var markers
1723+ if cmd .persist_vars and not dry_run and cmd_output :
1724+ try :
1725+ parsed_vars = parse_wizard_vars (cmd_output )
1726+ if parsed_vars :
1727+ todo = state .get_todo_by_id (self .todo_id )
1728+ if todo :
1729+ for var_name , var_value in parsed_vars .items ():
1730+ todo .state [var_name ] = var_value
1731+ state .save ()
1732+ for var_name , var_value in parsed_vars .items ():
1733+ print ("Saved variable '%s' = '%s'" % (var_name , var_value ))
1734+ except Exception as e :
1735+ print ("WARNING: Failed to persist variables: %s" % e )
1736+ if elapsed > 30 :
1737+ print ("Command completed in %s seconds" % elapsed )
16901738 if not success :
16911739 print ("WARNING: One or more commands failed, you may want to check the logs" )
16921740 return success
@@ -1719,7 +1767,7 @@ def jinjaify(self, data, join=False):
17191767 return None
17201768 v = self .get_vars ()
17211769 if self .todo_id :
1722- v .update (state .get_todo_by_id (self .todo_id ).get_vars ())
1770+ v .update (state .get_todo_by_id (self .todo_id ).get_vars_and_state ())
17231771 if isinstance (data , list ):
17241772 if join :
17251773 return expand_jinja (" " .join (data ), v )
@@ -1744,11 +1792,37 @@ def abbreviate_homedir(line):
17441792 return re .sub (r'([^/]|\b)%s' % os .path .expanduser ('~' ), "\\ 1~" , line )
17451793
17461794
1795+ def parse_wizard_vars (stdout_text ):
1796+ """Parse --wizard-var markers from command stdout.
1797+
1798+ Format: --wizard-var KEY=VALUE
1799+
1800+ Returns a dict of extracted variables, with last value winning for duplicates.
1801+ """
1802+ variables = {}
1803+ if not stdout_text :
1804+ return variables
1805+
1806+ for line in stdout_text .splitlines ():
1807+ # Check if line starts with --wizard-var marker
1808+ if line .startswith ("--wizard-var " ):
1809+ # Extract the KEY=VALUE part
1810+ var_part = line [len ("--wizard-var " ):].strip ()
1811+ if '=' in var_part :
1812+ key , _ , value = var_part .partition ('=' )
1813+ key = key .strip ()
1814+ value = value .strip ()
1815+ if key : # Only store if key is not empty
1816+ variables [key ] = value
1817+
1818+ return variables
1819+
1820+
17471821class Command (SecretYamlObject ):
17481822 yaml_tag = u'!Command'
17491823 hidden_fields = ['todo_id' ]
17501824 def __init__ (self , cmd , cwd = None , stdout = None , logfile = None , tee = None , live = None , comment = None , vars = None ,
1751- todo_id = None , should_fail = None , redirect = None , redirect_append = None , shell = None ):
1825+ todo_id = None , should_fail = None , redirect = None , redirect_append = None , shell = None , persist_vars = None ):
17521826 self .cmd = cmd
17531827 self .cwd = cwd
17541828 self .comment = comment
@@ -1762,6 +1836,7 @@ def __init__(self, cmd, cwd=None, stdout=None, logfile=None, tee=None, live=None
17621836 self .todo_id = todo_id
17631837 self .redirect_append = redirect_append
17641838 self .redirect = redirect
1839+ self .persist_vars = persist_vars
17651840 if tee and stdout :
17661841 self .stdout = None
17671842 print ("Command %s specifies 'tee' and 'stdout', using only 'tee'" % self .cmd )
@@ -1806,7 +1881,7 @@ def __str__(self):
18061881 def jinjaify (self , data , join = False ):
18071882 v = self .get_vars ()
18081883 if self .todo_id :
1809- v .update (state .get_todo_by_id (self .todo_id ).get_vars ())
1884+ v .update (state .get_todo_by_id (self .todo_id ).get_vars_and_state ())
18101885 if isinstance (data , list ):
18111886 if join :
18121887 return expand_jinja (" " .join (data ), v )
0 commit comments