11import json
22import re
3+ import shlex
34import subprocess
5+ import sys
46from dataclasses import dataclass
57from pathlib import Path
68from typing import Optional
79
810import yaml
9- from IPython .core .oinspect import Bundle
1011
1112RISKS = {"edge" : 1 , "beta" : 2 , "candidate" : 3 , "stable" : 4 }
1213
@@ -29,7 +30,8 @@ def get_status(self, channel: Optional[str] = None, revision: Optional[int] = No
2930
3031 if not self ._status :
3132 output = subprocess .check_output (
32- ["charmcraft" , "status" , self .name , "--format" , "json" ]
33+ ["charmcraft" , "status" , self .name , "--format" , "json" ],
34+ stderr = subprocess .STDOUT ,
3335 )
3436
3537 self ._status = json .loads (output .decode ("utf-8" ))
@@ -80,7 +82,7 @@ def promote_version(self, risk: str, dry_run: bool = True):
8082 )
8183
8284 if dry_run :
83- return cmds
85+ return shlex . join ( cmds )
8486
8587 return subprocess .check_output (cmds ).decode ("utf-8" )
8688
@@ -97,13 +99,14 @@ class Bundle:
9799 charms : list [Charm ]
98100
99101 @classmethod
100- def from_status (cls , content : str , format : Format = "text" ):
102+ def from_status (cls , content : str , format : Format | str = Format . TEXT ):
101103 parsers = {
102104 Format .TEXT : TextParser ,
103105 Format .YAML : YAMLParser
104106 }
107+ normalized_format = Format (format )
105108
106- return parsers [format ].parse (content )
109+ return parsers [normalized_format ].parse (content )
107110
108111
109112class YAMLParser :
@@ -119,7 +122,7 @@ def parse(content: str):
119122
120123
121124class TextParser :
122- word_with_leading_spaces = re .compile ("^\s*[^\s]+" )
125+ word_with_leading_spaces = re .compile (r "^\s*[^\s]+" )
123126
124127 @staticmethod
125128 def extract_first_word (mystring ):
@@ -134,13 +137,35 @@ def parse_line(line, indices):
134137
135138 @staticmethod
136139 def parse (content : str ):
140+ lines = content .splitlines ()
141+
142+ white_spaces = re .compile (r"\s+\s+" )
143+
144+ app_header_index = next (
145+ (
146+ index
147+ for index , line in enumerate (lines )
148+ if line .strip ().startswith ("App" )
149+ and "Charm" in line
150+ and "Channel" in line
151+ ),
152+ None ,
153+ )
154+
155+ if app_header_index is None :
156+ raise ValueError ("Could not locate applications table in status text" )
137157
138- lines = content .split ("\n " )
158+ table_lines = []
159+ for line in lines [app_header_index :]:
160+ if table_lines and (not line .strip () or line .strip ().startswith ("Unit" )):
161+ break
162+ table_lines .append (line )
139163
140- white_spaces = re .compile ("\s+\s+" )
164+ if len (table_lines ) < 2 :
165+ raise ValueError ("Applications table is empty in status text" )
141166
142167 # Get header
143- header = lines [0 ]
168+ header = table_lines [0 ]
144169
145170 # First guess of width based on headers
146171 ends = [s .end () for s in white_spaces .finditer (header )]
@@ -153,7 +178,7 @@ def parse(content: str):
153178 # This is due to the fact that some columns the text extends to before the start
154179 # of the columns header (text aligned right)
155180 widths = [len (column ) for column in columns ]
156- for line in lines [1 :]:
181+ for line in table_lines [1 :]:
157182
158183 widths = list (map (max ,zip (
159184 widths ,
@@ -167,10 +192,10 @@ def parse(content: str):
167192
168193 data = [
169194 dict (zip (columns , TextParser .parse_line (line , indices )))
170- for line in lines [1 :]
195+ for line in table_lines [1 :]
196+ if line .strip ()
171197 ]
172198
173- # pd.DataFrame([TextParser.parse_line(line, indices) for line in lines[1:]], columns= columns)
174199 return Bundle ([
175200 Charm (item ["Charm" ], int (item ["Rev" ]), item ["Channel" ])
176201 for item in data
@@ -180,18 +205,25 @@ def parse(content: str):
180205
181206if __name__ == "__main__" :
182207
183- # with open("./scripts/status.txt") as fid:
184- # bundle = Bundle.from_status(fid.read(), Format.TEXT)
185-
186- # with open("./scripts/status.yaml") as fid:
187- # bundle = Bundle.from_status(fid.read(), Format.YAML)
188-
189208 import argparse
190209
191210 parser = argparse .ArgumentParser ()
192211
193212 parser .add_argument ("--file" , required = True )
194- parser .add_argument ("--apply" , default = False , action = "store_true" )
213+ action_group = parser .add_mutually_exclusive_group ()
214+ action_group .add_argument (
215+ "--dry-run" ,
216+ dest = "dry_run" ,
217+ action = "store_true" ,
218+ help = "Print release commands without executing them (default)" ,
219+ )
220+ action_group .add_argument (
221+ "--apply" ,
222+ dest = "dry_run" ,
223+ action = "store_false" ,
224+ help = "Execute charmcraft release commands" ,
225+ )
226+ parser .set_defaults (dry_run = True )
195227 parser .add_argument ("--format" , choices = ("text" , "yaml" ), default = "text" )
196228 parser .add_argument ("--promote-to" , choices = ("beta" , "candidate" , "stable" ), default = "beta" )
197229 parser .add_argument ("--exclude" , nargs = "*" , default = ["mysql-k8s" ])
@@ -202,15 +234,17 @@ def parse(content: str):
202234
203235 for charm in bundle .charms :
204236 if not charm .name in args .exclude :
205- print (charm .promote_version (args .promote_to , not args .apply ))
206-
207-
208-
209-
210-
211-
212-
213-
214-
215-
216-
237+ try :
238+ print (charm .promote_version (args .promote_to , args .dry_run ))
239+ except subprocess .CalledProcessError as err :
240+ output = (err .output or b"" ).decode ("utf-8" , errors = "replace" ).strip ()
241+ if "permission-required" in output :
242+ print (
243+ f"WARNING: skipping '{ charm .name } ' due to missing permissions. Add it to --exclude to avoid this warning." ,
244+ file = sys .stderr ,
245+ )
246+ else :
247+ print (
248+ f"WARNING: skipping '{ charm .name } ' after command failure: { shlex .join (err .cmd )} " ,
249+ file = sys .stderr ,
250+ )
0 commit comments