@@ -58,6 +58,7 @@ def __init__(self, verbose=False):
5858 self .cf_client = None
5959 self ._is_lib_changed = False
6060 self .skip_validation = False
61+ self .lint_enabled = True
6162
6263 def clean_checksums (self ):
6364 """Delete all .checksum files in main, patterns, options, and lib directories"""
@@ -116,11 +117,77 @@ def log_error_details(self, component, error_output):
116117 f"[red]❌ { component } build failed (use --verbose for details)[/red]"
117118 )
118119
119- def run_subprocess_with_logging (self , cmd , component_name , cwd = None ):
120+ def run_subprocess_with_logging (
121+ self , cmd , component_name , cwd = None , realtime = False
122+ ):
120123 """Run subprocess with standardized logging"""
121- result = subprocess .run (cmd , capture_output = True , text = True , cwd = cwd )
122- if result .returncode != 0 :
123- error_msg = f"""Command failed: { " " .join (cmd )}
124+ if realtime :
125+ # Real-time output for long-running processes like npm install
126+ self .console .print (f"[cyan]Running: { ' ' .join (cmd )} [/cyan]" )
127+
128+ try :
129+ process = subprocess .Popen (
130+ cmd ,
131+ stdout = subprocess .PIPE ,
132+ stderr = subprocess .STDOUT ,
133+ text = True ,
134+ cwd = cwd ,
135+ bufsize = 1 ,
136+ universal_newlines = True ,
137+ )
138+
139+ output_lines = []
140+ while True :
141+ output = process .stdout .readline ()
142+ if output == "" and process .poll () is not None :
143+ break
144+ if output :
145+ line = output .strip ()
146+ output_lines .append (line )
147+ # Show progress for npm commands
148+ if "npm" in " " .join (cmd ):
149+ if any (
150+ keyword in line .lower ()
151+ for keyword in [
152+ "downloading" ,
153+ "installing" ,
154+ "added" ,
155+ "updated" ,
156+ "audited" ,
157+ ]
158+ ):
159+ self .console .print (f"[dim] { line } [/dim]" )
160+ elif "warn" in line .lower ():
161+ self .console .print (f"[yellow] { line } [/yellow]" )
162+ elif "error" in line .lower ():
163+ self .console .print (f"[red] { line } [/red]" )
164+
165+ return_code = process .poll ()
166+
167+ if return_code != 0 :
168+ error_msg = f"""Command failed: { " " .join (cmd )}
169+ Working directory: { cwd or os .getcwd ()}
170+ Return code: { return_code }
171+
172+ OUTPUT:
173+ { chr (10 ).join (output_lines )} """
174+ print (error_msg )
175+ self .log_error_details (component_name , error_msg )
176+ return False , error_msg
177+
178+ return True , None # Success, no result object needed for real-time
179+
180+ except Exception as e :
181+ error_msg = (
182+ f"Failed to execute command: { ' ' .join (cmd )} \n Error: { str (e )} "
183+ )
184+ self .log_error_details (component_name , error_msg )
185+ return False , error_msg
186+ else :
187+ # Original behavior - capture all output
188+ result = subprocess .run (cmd , capture_output = True , text = True , cwd = cwd )
189+ if result .returncode != 0 :
190+ error_msg = f"""Command failed: { " " .join (cmd )}
124191Working directory: { cwd or os .getcwd ()}
125192Return code: { result .returncode }
126193
@@ -129,10 +196,10 @@ def run_subprocess_with_logging(self, cmd, component_name, cwd=None):
129196
130197STDERR:
131198{ result .stderr } """
132- print (error_msg )
133- self .log_error_details (component_name , error_msg )
134- return False , error_msg
135- return True , result
199+ print (error_msg )
200+ self .log_error_details (component_name , error_msg )
201+ return False , error_msg
202+ return True , result
136203
137204 def print_error_summary (self ):
138205 """Print summary of all build errors"""
@@ -159,7 +226,7 @@ def print_usage(self):
159226 """Print usage information with Rich formatting"""
160227 self .console .print ("\n [bold cyan]Usage:[/bold cyan]" )
161228 self .console .print (
162- " python3 publish.py <cfn_bucket_basename> <cfn_prefix> <region> [public] [--max-workers N] [--verbose] [--no-validate] [--clean-build]"
229+ " python3 publish.py <cfn_bucket_basename> <cfn_prefix> <region> [public] [--max-workers N] [--verbose] [--no-validate] [--clean-build] [--lint on|off] "
163230 )
164231
165232 self .console .print ("\n [bold cyan]Parameters:[/bold cyan]" )
@@ -186,6 +253,9 @@ def print_usage(self):
186253 self .console .print (
187254 " [yellow][--clean-build][/yellow]: Optional. Delete all .checksum files to force full rebuild"
188255 )
256+ self .console .print (
257+ " [yellow][--lint on|off][/yellow]: Optional. Enable/disable UI linting and build validation (default: on)"
258+ )
189259
190260 def check_parameters (self , args ):
191261 """Check and validate input parameters"""
@@ -248,6 +318,19 @@ def check_parameters(self, args):
248318 self .console .print (
249319 "[yellow]CloudFormation template validation will be skipped[/yellow]"
250320 )
321+ elif arg == "--lint" :
322+ if i + 1 >= len (remaining_args ):
323+ self .console .print (
324+ "[red]Error: --lint requires 'on' or 'off'[/red]"
325+ )
326+ self .print_usage ()
327+ sys .exit (1 )
328+ lint_value = remaining_args [i + 1 ].lower ()
329+ if lint_value not in ["on" , "off" ]:
330+ self .console .print ("[red]Error: --lint must be 'on' or 'off'[/red]" )
331+ self .print_usage ()
332+ sys .exit (1 )
333+ self .lint_enabled = lint_value == "on"
251334 elif arg == "--clean-build" :
252335 self .clean_checksums ()
253336 else :
@@ -1104,6 +1187,50 @@ def upload_config_library(self):
11041187 f"[green]Configuration library uploaded to s3://{ self .bucket } /{ self .prefix_and_version } /config_library[/green]"
11051188 )
11061189
1190+ def ui_changed (self ):
1191+ """Check if UI has changed based on zipfile hash, returns (changed, zipfile_path)"""
1192+ ui_hash = self .compute_ui_hash ()
1193+ zipfile_name = f"src-{ ui_hash [:16 ]} .zip"
1194+ zipfile_path = os .path .join (".aws-sam" , zipfile_name )
1195+
1196+ existing_zipfiles = (
1197+ [
1198+ f
1199+ for f in os .listdir (".aws-sam" )
1200+ if f .startswith ("src-" ) and f .endswith (".zip" )
1201+ ]
1202+ if os .path .exists (".aws-sam" )
1203+ else []
1204+ )
1205+
1206+ if zipfile_name not in existing_zipfiles :
1207+ # Remove old zipfiles
1208+ for old_zip in existing_zipfiles :
1209+ old_path = os .path .join (".aws-sam" , old_zip )
1210+ if os .path .exists (old_path ):
1211+ os .remove (old_path )
1212+ return True , zipfile_path
1213+
1214+ return not os .path .exists (zipfile_path ), zipfile_path
1215+
1216+ def start_ui_validation_parallel (self ):
1217+ """Start UI validation in parallel if needed, returns (future, executor)"""
1218+ if not self .lint_enabled or not os .path .exists ("src/ui" ):
1219+ return None , None
1220+
1221+ changed , _ = self .ui_changed ()
1222+ if not changed :
1223+ return None , None
1224+
1225+ import concurrent .futures
1226+
1227+ ui_executor = concurrent .futures .ThreadPoolExecutor (max_workers = 1 )
1228+ ui_validation_future = ui_executor .submit (self .validate_ui_build )
1229+ self .console .print (
1230+ "[cyan]🔍 Starting UI validation in parallel with builds...[/cyan]"
1231+ )
1232+ return ui_validation_future , ui_executor
1233+
11071234 def compute_ui_hash (self ):
11081235 """Compute hash of UI folder contents"""
11091236 self .console .print ("[cyan]Computing hash of ui folder contents[/cyan]" )
@@ -1123,18 +1250,22 @@ def validate_ui_build(self):
11231250 return
11241251
11251252 # Run npm install first
1126- self .log_verbose ("Running npm install for UI dependencies..." )
1253+ self .console .print (
1254+ "[cyan]📦 Installing UI dependencies (this may take a while)...[/cyan]"
1255+ )
11271256 success , result = self .run_subprocess_with_logging (
1128- ["npm" , "install" ], "UI npm install" , ui_dir
1257+ ["npm" , "install" ], "UI npm install" , ui_dir , realtime = True
11291258 )
11301259
11311260 if not success :
11321261 raise Exception ("npm install failed" )
11331262
11341263 # Run npm run build to validate ESLint/Prettier
1135- self .log_verbose ("Running npm run build for UI validation..." )
1264+ self .console .print (
1265+ "[cyan]🔨 Building UI (validating ESLint/Prettier)...[/cyan]"
1266+ )
11361267 success , result = self .run_subprocess_with_logging (
1137- ["npm" , "run" , "build" ], "UI build validation" , ui_dir
1268+ ["npm" , "run" , "build" ], "UI build validation" , ui_dir , realtime = True
11381269 )
11391270
11401271 if not success :
@@ -1149,50 +1280,24 @@ def validate_ui_build(self):
11491280
11501281 def package_ui (self ):
11511282 """Package UI source code"""
1152- ui_hash = self .compute_ui_hash ()
1153- zipfile_name = f"src-{ ui_hash [:16 ]} .zip"
1154-
1155- # Ensure .aws-sam directory exists
1156- os .makedirs (".aws-sam" , exist_ok = True )
1157-
1158- # Check if we need to rebuild
1159- existing_zipfiles = [
1160- f
1161- for f in os .listdir (".aws-sam" )
1162- if f .startswith ("src-" ) and f .endswith (".zip" )
1163- ]
1164-
1165- if existing_zipfiles and existing_zipfiles [0 ] != zipfile_name :
1166- self .console .print (
1167- f"[yellow]WebUI zipfile name changed from { existing_zipfiles [0 ]} to { zipfile_name } , forcing rebuild[/yellow]"
1168- )
1169- # Remove old zipfile
1170- for old_zip in existing_zipfiles :
1171- old_path = os .path .join (".aws-sam" , old_zip )
1172- if os .path .exists (old_path ):
1173- os .remove (old_path )
1174-
1175- zipfile_path = os .path .join (".aws-sam" , zipfile_name )
1283+ _ , zipfile_path = self .ui_changed ()
11761284
11771285 if not os .path .exists (zipfile_path ):
1178- self .console .print ("[bold cyan]PACKAGING src/ui[/bold cyan]" )
1179- self .console .print (f"[cyan]Zipping source to { zipfile_path } [/cyan]" )
1180-
1286+ os .makedirs (".aws-sam" , exist_ok = True )
11811287 with zipfile .ZipFile (zipfile_path , "w" , zipfile .ZIP_DEFLATED ) as zipf :
11821288 ui_dir = "src/ui"
11831289 exclude_dirs = {"node_modules" , "build" , ".aws-sam" }
11841290 for root , dirs , files in os .walk (ui_dir ):
1185- # Exclude specified directories from zipping
11861291 dirs [:] = [d for d in dirs if d not in exclude_dirs ]
11871292 for file in files :
1188- # Skip .env files
11891293 if file == ".env" or file .startswith (".env." ):
11901294 continue
11911295 file_path = os .path .join (root , file )
11921296 arcname = os .path .relpath (file_path , ui_dir )
11931297 zipf .write (file_path , arcname )
11941298
11951299 # Check if file exists in S3 and upload if needed
1300+ zipfile_name = os .path .basename (zipfile_path )
11961301 s3_key = f"{ self .prefix_and_version } /{ zipfile_name } "
11971302 try :
11981303 self .s3_client .head_object (Bucket = self .bucket , Key = s3_key )
@@ -1259,10 +1364,6 @@ def build_main_template(self, webui_zipfile, components_needing_rebuild):
12591364 # Main template needs rebuilding, if any component needs rebuilding
12601365 if components_needing_rebuild :
12611366 self .console .print ("[yellow]Main template needs rebuilding[/yellow]" )
1262-
1263- # Validate UI build before rebuilding
1264- self .validate_ui_build ()
1265-
12661367 # Validate Python syntax in src directory before building
12671368 if not self ._validate_python_syntax ("src" ):
12681369 raise Exception ("Python syntax validation failed" )
@@ -1687,6 +1788,32 @@ def _validate_python_syntax(self, directory):
16871788 return False
16881789 return True
16891790
1791+ def _validate_python_linting (self ):
1792+ """Validate Python linting"""
1793+ if not self .lint_enabled :
1794+ return True
1795+
1796+ self .console .print ("[cyan]🔍 Running Python linting...[/cyan]" )
1797+
1798+ # Run ruff check (same as GitLab CI lint-cicd)
1799+ result = subprocess .run (["ruff" , "check" ], capture_output = True , text = True )
1800+ if result .returncode != 0 :
1801+ self .console .print ("[red]❌ Ruff linting failed![/red]" )
1802+ self .console .print (result .stdout , style = "red" , markup = False )
1803+ return False
1804+
1805+ # Run ruff format check (same as GitLab CI lint-cicd)
1806+ result = subprocess .run (
1807+ ["ruff" , "format" , "--check" ], capture_output = True , text = True
1808+ )
1809+ if result .returncode != 0 :
1810+ self .console .print ("[red]❌ Code formatting check failed![/red]" )
1811+ self .console .print (result .stdout , style = "red" , markup = False )
1812+ return False
1813+
1814+ self .console .print ("[green]✅ Python linting passed[/green]" )
1815+ return True
1816+
16901817 def build_lib_package (self ):
16911818 """Build lib package with syntax validation"""
16921819 try :
@@ -1856,12 +1983,19 @@ def run(self, args):
18561983 # Check prerequisites
18571984 self .check_prerequisites ()
18581985
1986+ # Validate Python linting if enabled
1987+ if not self ._validate_python_linting ():
1988+ raise Exception ("Python linting validation failed" )
1989+
18591990 # Set up S3 bucket
18601991 self .setup_artifacts_bucket ()
18611992
18621993 # Perform smart rebuild detection and cache management
18631994 components_needing_rebuild = self .smart_rebuild_detection ()
18641995
1996+ # Start UI validation early in parallel
1997+ ui_validation_future , ui_executor = self .start_ui_validation_parallel ()
1998+
18651999 # clear component cache
18662000 for comp_info in components_needing_rebuild :
18672001 if comp_info ["component" ] != "lib" : # lib doesnt have sam build
@@ -1934,7 +2068,24 @@ def run(self, args):
19342068 # Upload configuration library
19352069 self .upload_config_library ()
19362070
1937- # Package UI
2071+ # Wait for UI validation to complete if it was started
2072+ if ui_validation_future :
2073+ try :
2074+ self .console .print (
2075+ "[cyan]⏳ Waiting for UI validation to complete...[/cyan]"
2076+ )
2077+ ui_validation_future .result ()
2078+ self .console .print (
2079+ "[green]✅ UI validation completed successfully[/green]"
2080+ )
2081+ except Exception as e :
2082+ self .console .print ("[red]❌ UI validation failed:[/red]" )
2083+ self .console .print (str (e ), style = "red" , markup = False )
2084+ sys .exit (1 )
2085+ finally :
2086+ ui_executor .shutdown (wait = True )
2087+
2088+ # Package UI and start validation in parallel if needed
19382089 webui_zipfile = self .package_ui ()
19392090
19402091 # Build main template
0 commit comments