Skip to content

Commit 7492cb0

Browse files
committed
chore(release): Release version 1.3.3
This patch release includes critical fixes to the security scanner's accuracy and the reliability of the multiverse analysis test. It also improves the user experience for the 'run' command with better live output streaming.
1 parent 21a4837 commit 7492cb0

3 files changed

Lines changed: 137 additions & 66 deletions

File tree

omnipkg/commands/run.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,13 +97,34 @@ def execute_run_command(cmd_args: list, config_manager: ConfigManager):
9797
initial_cmd = ['uv', 'run', '--no-project', '--python', python_exe, '--'] + cmd_args
9898

9999
start_time_ns = time.perf_counter_ns()
100-
process = subprocess.Popen(initial_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding='utf-8', cwd=Path.cwd())
101100

102-
# FIXED: Stream output live instead of collecting it all first
101+
# CRITICAL FIX: Use stderr=subprocess.STDOUT and set bufsize=1 for line buffering
102+
# Also add universal_newlines=True for better text handling
103+
process = subprocess.Popen(
104+
initial_cmd,
105+
stdout=subprocess.PIPE,
106+
stderr=subprocess.STDOUT, # Merge stderr into stdout
107+
text=True,
108+
encoding='utf-8',
109+
cwd=Path.cwd(),
110+
bufsize=1, # Line buffered
111+
universal_newlines=True # Ensures proper line ending handling
112+
)
113+
114+
# FIXED: Stream output live with immediate flushing
103115
output_lines = []
104-
for line in iter(process.stdout.readline, ''):
105-
print(line, end='') # Print each line immediately as it comes
106-
output_lines.append(line) # Still collect for error analysis
116+
try:
117+
while True:
118+
line = process.stdout.readline()
119+
if not line: # EOF reached
120+
break
121+
print(line, end='', flush=True) # CRITICAL: Add flush=True for immediate output
122+
output_lines.append(line)
123+
except KeyboardInterrupt:
124+
print("\n🛑 Process interrupted by user")
125+
process.terminate()
126+
process.wait()
127+
return 130 # Standard exit code for SIGINT
107128

108129
return_code = process.wait()
109130
end_time_ns = time.perf_counter_ns()

omnipkg/package_meta_builder.py

Lines changed: 92 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -231,107 +231,149 @@ def _store_active_versions(self, active_packages: Dict[str, str]):
231231

232232
def _perform_security_scan(self, packages: Dict[str, str]):
233233
"""
234-
FIXED: Runs a security check using `safety`. The log output is now
235-
context-aware and gracefully handles cases where `safety` is not
236-
installed in the target Python interpreter. It is resilient to non-JSON output.
234+
FIXED: Runs a security check using `safety`. Now properly handles the JSON
235+
output wrapped with deprecation warnings and correctly counts vulnerabilities.
237236
"""
238237
scan_type = 'bulk' if len(packages) > 1 else 'targeted'
239238
if len(packages) == 0:
240239
scan_type = 'targeted'
240+
241241
print(f'🛡️ Performing {scan_type} security scan for {len(packages)} active package(s)...')
242+
242243
if not packages:
243244
print(_(' - No active packages found to scan.'))
244245
self.security_report = {}
245246
return
247+
246248
python_exe = self.config.get('python_executable', sys.executable)
249+
250+
# Check if safety is available
247251
try:
248252
subprocess.run([python_exe, '-m', 'safety', '--version'], check=True, capture_output=True, timeout=10)
249253
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
250254
print(f" ⚠️ Warning: The 'safety' package is not installed for the active Python interpreter ({Path(python_exe).name}).")
251255
print(_(" 💡 To enable this feature, run: '{} -m pip install safety'").format(python_exe))
252256
self.security_report = {}
253257
return
258+
259+
# Create requirements file
254260
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.txt', encoding='utf-8') as reqs_file:
255261
reqs_file_path = reqs_file.name
256262
for name, version in packages.items():
257263
reqs_file.write(f'{name}=={version}\n')
264+
258265
try:
259266
cmd = [python_exe, '-m', 'safety', 'check', '-r', reqs_file_path, '--json']
260267
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120, encoding='utf-8')
268+
261269
if result.stdout:
262270
raw_output = result.stdout.strip()
263-
json_start_index = raw_output.find('[')
264-
if json_start_index == -1:
265-
json_start_index = raw_output.find('{')
266-
if json_start_index != -1:
267-
json_string = raw_output[json_start_index:]
268-
bracket_count = 0
269-
brace_count = 0
270-
json_end_index = 0
271+
272+
# Find JSON boundaries - safety wraps JSON with deprecation warnings
273+
json_start = -1
274+
json_end = -1
275+
276+
# Look for the opening brace/bracket
277+
brace_pos = raw_output.find('{')
278+
bracket_pos = raw_output.find('[')
279+
280+
if brace_pos != -1 and bracket_pos != -1:
281+
json_start = min(brace_pos, bracket_pos)
282+
elif brace_pos != -1:
283+
json_start = brace_pos
284+
elif bracket_pos != -1:
285+
json_start = bracket_pos
286+
287+
if json_start != -1:
288+
# Find the matching closing brace/bracket
289+
opening_char = raw_output[json_start]
290+
closing_char = '}' if opening_char == '{' else ']'
291+
292+
# Count nesting to find the proper end
293+
count = 0
271294
in_string = False
272295
escape_next = False
273-
for i, char in enumerate(json_string):
296+
297+
for i in range(json_start, len(raw_output)):
298+
char = raw_output[i]
299+
274300
if escape_next:
275301
escape_next = False
276302
continue
303+
277304
if char == '\\':
278305
escape_next = True
279306
continue
280-
if char == '"' and (not escape_next):
307+
308+
if char == '"' and not escape_next:
281309
in_string = not in_string
282310
continue
311+
283312
if not in_string:
284-
if char == '[':
285-
bracket_count += 1
286-
elif char == ']':
287-
bracket_count -= 1
288-
elif char == '{':
289-
brace_count += 1
290-
elif char == '}':
291-
brace_count -= 1
292-
if bracket_count == 0 and brace_count == 0 and (i > 0):
293-
json_end_index = i + 1
294-
break
295-
if json_end_index > 0:
296-
clean_json = json_string[:json_end_index]
297-
else:
298-
clean_json = json_string
299-
try:
300-
self.security_report = json.loads(clean_json)
301-
except json.JSONDecodeError:
302-
lines = raw_output.split('\n')
303-
json_lines = []
304-
collecting = False
305-
for line in lines:
306-
if line.strip().startswith('[') or line.strip().startswith('{'):
307-
collecting = True
308-
if collecting:
309-
json_lines.append(line)
310-
try:
311-
potential_json = '\n'.join(json_lines)
312-
self.security_report = json.loads(potential_json)
313+
if char == opening_char:
314+
count += 1
315+
elif char == closing_char:
316+
count -= 1
317+
if count == 0:
318+
json_end = i + 1
313319
break
314-
except json.JSONDecodeError:
315-
continue
316-
if not hasattr(self, 'security_report') or not self.security_report:
320+
321+
if json_end > json_start:
322+
json_content = raw_output[json_start:json_end]
323+
try:
324+
self.security_report = json.loads(json_content)
325+
except json.JSONDecodeError as e:
326+
print(f' ⚠️ Could not parse safety JSON output: {e}')
327+
self.security_report = {}
328+
else:
329+
# Fallback: try parsing from json_start to end and let json.loads find the boundary
330+
try:
331+
# This will likely fail due to extra content, but worth trying
332+
self.security_report = json.loads(raw_output[json_start:])
333+
except json.JSONDecodeError:
334+
print(' ⚠️ Could not determine JSON boundaries')
317335
self.security_report = {}
318336
else:
337+
print(' ⚠️ No JSON found in safety output')
319338
self.security_report = {}
339+
320340
if result.stderr:
321341
print(_(' ⚠️ Safety command produced warnings. Stderr: {}').format(result.stderr.strip()))
322342
else:
323343
self.security_report = {}
324344
if result.stderr:
325345
print(_(' ⚠️ Safety command failed. Stderr: {}').format(result.stderr.strip()))
326-
except json.JSONDecodeError as e:
327-
print(f' ⚠️ Could not parse safety JSON output. This can happen with very old versions of `safety`. Error: {e}')
328-
self.security_report = {}
346+
329347
except Exception as e:
330348
print(_(' ⚠️ An unexpected error occurred during the security scan: {}').format(e))
331349
self.security_report = {}
332350
finally:
333-
os.unlink(reqs_file_path)
334-
issue_count = len(self.security_report) if isinstance(self.security_report, (list, dict)) else 0
351+
if os.path.exists(reqs_file_path):
352+
os.unlink(reqs_file_path)
353+
354+
# Count actual vulnerabilities from the parsed safety report
355+
issue_count = 0
356+
357+
if isinstance(self.security_report, dict):
358+
# Modern safety format has a 'vulnerabilities' key with the actual vulnerabilities
359+
if 'vulnerabilities' in self.security_report:
360+
vulnerabilities = self.security_report['vulnerabilities']
361+
if isinstance(vulnerabilities, list):
362+
issue_count = len(vulnerabilities)
363+
# Also check report_meta for vulnerabilities_found (more reliable)
364+
elif 'report_meta' in self.security_report:
365+
meta = self.security_report['report_meta']
366+
if isinstance(meta, dict) and 'vulnerabilities_found' in meta:
367+
issue_count = int(meta['vulnerabilities_found'])
368+
# Legacy format fallback
369+
else:
370+
for key, value in self.security_report.items():
371+
if isinstance(value, list) and key not in [
372+
'scanned_packages', 'scanned', 'scanned_full_path',
373+
'target_languages', 'announcements', 'ignored_vulnerabilities'
374+
]:
375+
issue_count += len(value)
376+
335377
print(_('✅ Security scan complete. Found {} potential issues.').format(issue_count))
336378

337379
def run(self, targeted_packages: Optional[List[str]]=None, newly_active_packages: Optional[Dict[str, str]]=None):

tests/multiverse_analysis.py

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -82,21 +82,29 @@ def get_interpreter_path(version: str) -> str:
8282
return path
8383
raise RuntimeError(f"Could not find managed Python {version} via 'omnipkg info python'.")
8484

85-
def prepare_dimension_in_context(packages: list):
86-
"""Installs packages into the CURRENTLY ACTIVE dimension (must be called after swap)."""
87-
print(f" PREPARING CURRENT DIMENSION: Installing {', '.join(packages)}...")
85+
def prepare_dimension_with_packages(version: str, packages: list):
86+
"""Swaps to a dimension and installs packages in that context."""
87+
print(f" PREPARING DIMENSION {version}: Installing {', '.join(packages)}...")
88+
89+
# First, swap to the target dimension
90+
swap_dimension(version)
8891

8992
start_time = time.perf_counter()
9093

9194
original_strategy = get_config_value("install_strategy")
9295
try:
96+
# Ensure we're using latest-active strategy for current dimension installs
9397
if original_strategy != 'latest-active':
98+
print(f" SETTING STRATEGY: Temporarily setting install_strategy to 'latest-active'...")
9499
subprocess.run(["omnipkg", "config", "set", "install_strategy", "latest-active"], check=True, capture_output=True)
95100

101+
# Install packages in the current (swapped) dimension
96102
cmd = ["omnipkg", "install"] + packages
97-
subprocess.run(cmd, check=True)
103+
print(f" INSTALLING: Running {' '.join(cmd)} in Python {version} context...")
104+
subprocess.run(cmd, check=True, capture_output=True, text=True)
98105

99106
finally:
107+
# Always restore the original install strategy
100108
current_strategy = get_config_value("install_strategy")
101109
if current_strategy != original_strategy:
102110
print(f" RESTORING STRATEGY: Setting install_strategy back to '{original_strategy}'...")
@@ -105,7 +113,7 @@ def prepare_dimension_in_context(packages: list):
105113
end_time = time.perf_counter()
106114
duration_ms = (end_time - start_time) * 1000
107115

108-
print(f" PREPARATION COMPLETE: {', '.join(packages)} are now available in current context.")
116+
print(f" PREPARATION COMPLETE: {', '.join(packages)} are now available in Python {version} context.")
109117
print(f" ⏱️ Package installation took: {duration_ms:.2f} ms")
110118

111119
def multiverse_analysis():
@@ -124,10 +132,10 @@ def multiverse_analysis():
124132
print("✅ All required dimensions are available.")
125133

126134
# ===============================================================
127-
# MISSION STEP 1: JUMP TO PYTHON 3.9 DIMENSION & PREPARE IT
135+
# MISSION STEP 1: PREPARE PYTHON 3.9 DIMENSION WITH PACKAGES
128136
# ===============================================================
129-
swap_dimension("3.9") # CRITICAL: Swap BEFORE preparing
130-
prepare_dimension_in_context(["numpy", "scipy"]) # Now installs in 3.9 context
137+
print("\n📦 MISSION STEP 1: Setting up Python 3.9 dimension...")
138+
prepare_dimension_with_packages("3.9", ["numpy", "scipy"])
131139
python_3_9_exe = get_interpreter_path("3.9")
132140

133141
print(" EXECUTING PAYLOAD in 3.9 dimension...")
@@ -141,10 +149,10 @@ def multiverse_analysis():
141149
print(f" ⏱️ 3.9 payload execution took: {(end_time - start_time) * 1000:.2f} ms")
142150

143151
# ===============================================================
144-
# MISSION STEP 2: JUMP TO PYTHON 3.11 DIMENSION & PREPARE IT
152+
# MISSION STEP 2: PREPARE PYTHON 3.11 DIMENSION WITH PACKAGES
145153
# ===============================================================
146-
swap_dimension("3.11") # CRITICAL: Swap BEFORE preparing
147-
prepare_dimension_in_context(["tensorflow"]) # Now installs in 3.11 context
154+
print("\n📦 MISSION STEP 2: Setting up Python 3.11 dimension...")
155+
prepare_dimension_with_packages("3.11", ["tensorflow"])
148156
python_3_11_exe = get_interpreter_path("3.11")
149157

150158
print(" EXECUTING PAYLOAD in 3.11 dimension...")

0 commit comments

Comments
 (0)