pybuilder-integration 106__tar.gz → 108__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (18) hide show
  1. {pybuilder_integration-106 → pybuilder_integration-108}/PKG-INFO +1 -1
  2. {pybuilder_integration-106 → pybuilder_integration-108}/pybuilder_integration/tasks.py +643 -42
  3. {pybuilder_integration-106 → pybuilder_integration-108}/pybuilder_integration.egg-info/PKG-INFO +1 -1
  4. {pybuilder_integration-106 → pybuilder_integration-108}/setup.py +1 -1
  5. {pybuilder_integration-106 → pybuilder_integration-108}/pybuilder_integration/__init__.py +0 -0
  6. {pybuilder_integration-106 → pybuilder_integration-108}/pybuilder_integration/artifact_manager.py +0 -0
  7. {pybuilder_integration-106 → pybuilder_integration-108}/pybuilder_integration/cloudwatchlogs_utility.py +0 -0
  8. {pybuilder_integration-106 → pybuilder_integration-108}/pybuilder_integration/directory_utility.py +0 -0
  9. {pybuilder_integration-106 → pybuilder_integration-108}/pybuilder_integration/exec_utility.py +0 -0
  10. {pybuilder_integration-106 → pybuilder_integration-108}/pybuilder_integration/properties.py +0 -0
  11. {pybuilder_integration-106 → pybuilder_integration-108}/pybuilder_integration/tool_utility.py +0 -0
  12. {pybuilder_integration-106 → pybuilder_integration-108}/pybuilder_integration.egg-info/SOURCES.txt +0 -0
  13. {pybuilder_integration-106 → pybuilder_integration-108}/pybuilder_integration.egg-info/dependency_links.txt +0 -0
  14. {pybuilder_integration-106 → pybuilder_integration-108}/pybuilder_integration.egg-info/namespace_packages.txt +0 -0
  15. {pybuilder_integration-106 → pybuilder_integration-108}/pybuilder_integration.egg-info/requires.txt +0 -0
  16. {pybuilder_integration-106 → pybuilder_integration-108}/pybuilder_integration.egg-info/top_level.txt +0 -0
  17. {pybuilder_integration-106 → pybuilder_integration-108}/pybuilder_integration.egg-info/zip-safe +0 -0
  18. {pybuilder_integration-106 → pybuilder_integration-108}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pybuilder-integration
3
- Version: 106
3
+ Version: 108
4
4
  Summary: A pybuilder plugin that runs integration tests (Tavern & Cypress) against a target.
5
5
  Home-page: https://github.com/rspitler/pybuilder-integration
6
6
  Author:
@@ -1,9 +1,12 @@
1
+ import atexit
2
+ import errno
1
3
  import json
2
4
  import os
3
5
  import re
4
6
  import shutil
5
7
  import subprocess
6
8
  import tempfile
9
+ import threading
7
10
 
8
11
  from defusedxml import ElementTree as ET
9
12
  from defusedxml.common import DefusedXmlException
@@ -124,15 +127,21 @@ def _run_verify_environment_passes_in_subprocesses(project: Project, logger: Log
124
127
  process, log_file = _start_verify_environment_subprocess(project, logger, pass_config, properties_file)
125
128
  processes.append((pass_config, process, log_file))
126
129
 
127
- failures = []
130
+ results = []
128
131
  for pass_config, process, log_file in processes:
129
132
  exit_code = process.wait()
130
- log_handle = getattr(process, "_pybuilder_integration_log_handle", None)
131
- if log_handle:
132
- log_handle.close()
133
- if exit_code != 0:
134
- failures.append(f"{pass_config['name']} exit_code={exit_code} log={log_file}")
135
-
133
+ _close_subprocess_log(process)
134
+ results.append({
135
+ "name": pass_config["name"],
136
+ "exit_code": exit_code,
137
+ "log_file": log_file,
138
+ })
139
+
140
+ _log_parallel_verify_environment_summary(logger, results)
141
+ failures = [
142
+ f"{result['name']} exit_code={result['exit_code']} log={result['log_file']}"
143
+ for result in results if result["exit_code"] != 0
144
+ ]
136
145
  if failures:
137
146
  raise BuildFailedException("Parallel verify_environment failed: " + "; ".join(failures))
138
147
  finally:
@@ -141,55 +150,623 @@ def _run_verify_environment_passes_in_subprocesses(project: Project, logger: Log
141
150
  if callable(poll) and poll() is None:
142
151
  process.terminate()
143
152
  process.wait()
144
- log_handle = getattr(process, "_pybuilder_integration_log_handle", None)
145
- if log_handle and not log_handle.closed:
146
- log_handle.close()
153
+ _close_subprocess_log(process)
147
154
  _merge_parallel_verify_environment_outputs(project, passes)
155
+ _write_cypress_summary(project, logger)
148
156
  if os.path.exists(properties_file):
149
157
  os.remove(properties_file)
150
158
 
151
159
 
160
+ _ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m")
161
+ _SUITE_RUNNING_LINE = re.compile(
162
+ r"^\[INFO\]\s+Running (?!tavern tests\b|against target\b|cypress on host\b|args:).+"
163
+ )
164
+ _SECRET_QUOTE = r"(?:['\"]|"|')?"
165
+ _SECRET_VALUE = rf"{_SECRET_QUOTE}[^'\"\s<&>,}}]+"
166
+ _SECRET_LITERAL_VALUE = rf"(?!(?:true|false|yes|no|on|off|null|none)\b){_SECRET_VALUE}"
167
+ _SECRET_KEY_NAME = (
168
+ r"(?:[A-Za-z][A-Za-z0-9]*[_-])*"
169
+ r"(?:"
170
+ r"x-api-key|x-amz-security-token|"
171
+ r"api[_-]?key|secret[_-]?access[_-]?key|access[_-]?key(?:[_-]?id)?|"
172
+ r"client[_-]?secret|password|secret|"
173
+ r"(?:id|access|refresh|session)?[_-]?token"
174
+ r")"
175
+ )
176
+ _SECRET_HINT_LINE = re.compile(
177
+ r"("
178
+ rf"\bauthorization\b{_SECRET_QUOTE}\s*:\s*{_SECRET_QUOTE}(?:(?:bearer|basic)\s+)?{_SECRET_VALUE}"
179
+ rf"|\b(?:set-cookie|cookie)\b{_SECRET_QUOTE}\s*:\s*{_SECRET_QUOTE}{_SECRET_VALUE}(?:\s*;\s*{_SECRET_VALUE})*"
180
+ rf"|{_SECRET_KEY_NAME}{_SECRET_QUOTE}\s*[:=]\s*{_SECRET_LITERAL_VALUE}"
181
+ r"|\bbearer\s+[A-Za-z0-9._\-]+"
182
+ r"|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}(?:\.[A-Za-z0-9_-]{10,})?"
183
+ r"|https?://[^/\s]+:[^/\s]+@"
184
+ r"|\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})"
185
+ r")",
186
+ re.IGNORECASE,
187
+ )
188
+ _REDACTED_SECRET = "[redacted]"
189
+ _REDACTED_COPY_EXTENSIONS = {".html", ".json", ".log", ".md", ".out", ".txt", ".xml"}
190
+ _REDACT_STAGING_DIRNAME = ".pybuilder-integration-redact"
191
+ _REDACT_TEMP_FILES = set()
192
+ _SUBPROCESS_LOG_JOIN_SECONDS = 30
193
+
194
+
195
+ def _log_parallel_verify_environment_summary(logger: Logger, results):
196
+ summary = _build_parallel_verify_environment_summary(results)
197
+ print(summary)
198
+ github_summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
199
+ if not github_summary_path:
200
+ return
201
+ try:
202
+ with os.fdopen(
203
+ os.open(github_summary_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600),
204
+ "a",
205
+ encoding="utf-8",
206
+ ) as summary_file:
207
+ summary_file.write(summary)
208
+ if not summary.endswith("\n"):
209
+ summary_file.write("\n")
210
+ except OSError:
211
+ logger.warn("Could not append parallel verify_environment summary to GITHUB_STEP_SUMMARY")
212
+
213
+
214
+ def _build_parallel_verify_environment_summary(results):
215
+ lines = ["Parallel verify_environment summary"]
216
+ for result in results:
217
+ status = "PASSED" if result["exit_code"] == 0 else "FAILED"
218
+ lines.append(f" {result['name']}: {status} (exit_code={result['exit_code']})")
219
+ for result in results:
220
+ if result["exit_code"] == 0:
221
+ continue
222
+ lines.append(f" {result['name']} log: {result['log_file']}")
223
+ for hint in _extract_parallel_verify_failure_hints(result["log_file"]):
224
+ lines.append(f" {hint}")
225
+ return "\n".join(lines) + "\n"
226
+
227
+
228
+ def _extract_parallel_verify_failure_hints(log_file, max_hints=12):
229
+ if not log_file or not os.path.exists(log_file):
230
+ return ["child log was not found"]
231
+ try:
232
+ with open(log_file, encoding="utf-8", errors="replace") as log_handle:
233
+ raw = log_handle.read()
234
+ except OSError:
235
+ return ["child log could not be read"]
236
+ text = _ANSI_ESCAPE.sub("", raw)
237
+ lines = [line.rstrip() for line in text.splitlines()]
238
+ last_running = None
239
+ last_build_failed = None
240
+ failed_tests = []
241
+ session_summaries = []
242
+ extra_failures = []
243
+ for line in lines:
244
+ stripped = line.strip()
245
+ if not stripped:
246
+ continue
247
+ if _SUITE_RUNNING_LINE.match(stripped):
248
+ last_running = stripped
249
+ elif stripped.startswith("BUILD FAILED"):
250
+ last_build_failed = stripped
251
+ elif stripped.startswith("FAILED ") and "::" in stripped:
252
+ failed_tests.append(stripped)
253
+ elif stripped.startswith("===") and "failed" in stripped.lower():
254
+ session_summaries.append(stripped)
255
+ elif stripped.startswith("### FAIL") or "Failed to execute cypress" in stripped:
256
+ extra_failures.append(stripped)
257
+ hints = []
258
+ if last_running:
259
+ hints.append(last_running)
260
+ if last_build_failed:
261
+ hints.append(last_build_failed)
262
+ hints.extend(extra_failures[-3:])
263
+ hints.extend(failed_tests[-8:])
264
+ hints.extend(session_summaries[-3:])
265
+ if hints:
266
+ return _without_secret_hint_lines(hints)[:max_hints]
267
+ tail = [line.strip() for line in lines[-20:] if line.strip()]
268
+ if not tail:
269
+ return ["child log was empty"]
270
+ return _without_secret_hint_lines(tail)[-max_hints:]
271
+
272
+
273
+ def _start_subprocess_output_tee(process, log_handle, logger: Logger, pass_name):
274
+ stdout = getattr(process, "stdout", None)
275
+ if stdout is None or isinstance(stdout, (int, str)):
276
+ process._pybuilder_integration_log_thread = None
277
+ return
278
+
279
+ def tee():
280
+ prefix = f"[{pass_name}] "
281
+ try:
282
+ for line in stdout:
283
+ redacted = _redact_secret_log_line(_decode_subprocess_stdout_line(line))
284
+ try:
285
+ log_handle.write(redacted + "\n")
286
+ log_handle.flush()
287
+ except Exception:
288
+ pass
289
+ try:
290
+ logger.info(prefix + redacted)
291
+ except Exception:
292
+ pass
293
+ finally:
294
+ try:
295
+ stdout.close()
296
+ except Exception:
297
+ pass
298
+
299
+ thread = threading.Thread(target=tee, name=f"verify-environment-{pass_name}-log", daemon=True)
300
+ thread.start()
301
+ process._pybuilder_integration_log_thread = thread
302
+
303
+
304
+ def _close_subprocess_stdout(process):
305
+ stdout = getattr(process, "stdout", None)
306
+ if stdout is None or isinstance(stdout, (int, str)):
307
+ return
308
+ try:
309
+ stdout.close()
310
+ except Exception:
311
+ pass
312
+
313
+
314
+ def _close_subprocess_log(process):
315
+ thread = getattr(process, "_pybuilder_integration_log_thread", None)
316
+ if thread is not None:
317
+ thread.join(timeout=_SUBPROCESS_LOG_JOIN_SECONDS)
318
+ if thread.is_alive():
319
+ _close_subprocess_stdout(process)
320
+ thread.join(timeout=_SUBPROCESS_LOG_JOIN_SECONDS)
321
+ if thread.is_alive():
322
+ return
323
+ process._pybuilder_integration_log_thread = None
324
+ log_handle = getattr(process, "_pybuilder_integration_log_handle", None)
325
+ if log_handle and not getattr(log_handle, "closed", True):
326
+ log_handle.close()
327
+
328
+
329
+ def _decode_subprocess_stdout_line(line):
330
+ if isinstance(line, bytes):
331
+ line = line.decode("utf-8", "replace")
332
+ return line.rstrip("\n\r")
333
+
334
+
335
+ def _redact_secret_log_line(line):
336
+ return _SECRET_HINT_LINE.sub(_REDACTED_SECRET, line)
337
+
338
+
339
+ def _redact_secret_log_text(text):
340
+ if not text:
341
+ return text
342
+ return "\n".join(_redact_secret_log_line(line) for line in text.splitlines()) + ("\n" if text.endswith("\n") else "")
343
+
344
+
345
+ def _without_secret_hint_lines(lines):
346
+ return [_redact_secret_log_line(line) for line in lines]
347
+
348
+
349
+ _CHILD_OUTPUT_SOURCES = (
350
+ ("logs", "logs"),
351
+ ("reports", "reports"),
352
+ ("reports", "test-reports"),
353
+ )
354
+ _SUBPROCESS_WORKDIR_SKIP = {".pybuilder", "target", ".git", ".nudge-ci-venv"}
355
+ _CHILD_TARGET_LINK_SKIP = {"verify_environment", "logs", "reports", "test-reports"}
356
+
357
+
358
+ def _child_output_source_roots(stage_target):
359
+ return (
360
+ stage_target,
361
+ os.path.join(stage_target, "project", "target"),
362
+ )
363
+
364
+
365
+ def _first_child_cypress_log(stage_target):
366
+ for source_root in _child_output_source_roots(stage_target):
367
+ candidate = os.path.join(source_root, "logs", "integration", "cypress_run.log")
368
+ if os.path.isfile(candidate):
369
+ return candidate
370
+ return None
371
+
372
+
373
+ def _copy_maybe_redacted_file(src, dst, *, follow_symlinks=True):
374
+ if os.path.splitext(src)[1].lower() in _REDACTED_COPY_EXTENSIONS:
375
+ _copy_redacted_text_file(src, dst)
376
+ return dst
377
+ return shutil.copy2(src, dst, follow_symlinks=follow_symlinks)
378
+
379
+
380
+ def _redact_text_file_inplace(path):
381
+ if os.path.islink(path) or not os.path.isfile(path):
382
+ return
383
+ try:
384
+ with open(path, encoding="utf-8", errors="replace") as source_file:
385
+ text = source_file.read()
386
+ except OSError:
387
+ return
388
+ redacted = _redact_secret_log_text(text)
389
+ if redacted == text:
390
+ return
391
+ fd, temporary_copy = tempfile.mkstemp(
392
+ prefix=f".{os.path.basename(path)}.",
393
+ suffix=".redact",
394
+ dir=tempfile.gettempdir(),
395
+ )
396
+ _register_redact_temp(temporary_copy)
397
+ try:
398
+ with os.fdopen(fd, "w", encoding="utf-8") as dest_file:
399
+ dest_file.write(redacted)
400
+ dest_file.flush()
401
+ os.fchmod(dest_file.fileno(), 0o600)
402
+ _replace_redacted_tempfile(temporary_copy, path)
403
+ except OSError:
404
+ _unlink_redact_temp(temporary_copy)
405
+
406
+
407
+ def _register_redact_temp(path):
408
+ _REDACT_TEMP_FILES.add(path)
409
+ if not getattr(_register_redact_temp, "_atexit_registered", False):
410
+ atexit.register(_cleanup_redact_temps)
411
+ _register_redact_temp._atexit_registered = True
412
+
413
+
414
+ def _unlink_redact_temp(path):
415
+ try:
416
+ os.unlink(path)
417
+ except OSError:
418
+ pass
419
+ _REDACT_TEMP_FILES.discard(path)
420
+
421
+
422
+ def _cleanup_redact_temps():
423
+ for path in list(_REDACT_TEMP_FILES):
424
+ _unlink_redact_temp(path)
425
+
426
+
427
+ def _same_device_staging_base(path):
428
+ dest = os.path.abspath(path)
429
+ dest_dev = os.stat(dest).st_dev
430
+ current = dest if os.path.isdir(dest) else os.path.dirname(dest)
431
+ last_same = current
432
+ outside_target = None
433
+ while True:
434
+ try:
435
+ if os.stat(current).st_dev != dest_dev:
436
+ break
437
+ except OSError:
438
+ break
439
+ last_same = current
440
+ if "target" not in os.path.abspath(current).split(os.sep):
441
+ outside_target = current
442
+ break
443
+ parent = os.path.dirname(current)
444
+ if parent == current:
445
+ break
446
+ current = parent
447
+ return outside_target or last_same
448
+
449
+
450
+ def _same_device_staging_dir(path):
451
+ staging = os.path.join(_same_device_staging_base(path), _REDACT_STAGING_DIRNAME)
452
+ os.makedirs(staging, mode=0o700, exist_ok=True)
453
+ try:
454
+ os.chmod(staging, 0o700)
455
+ except OSError:
456
+ pass
457
+ return staging
458
+
459
+
460
+ def _remove_empty_redact_staging_dir(staging):
461
+ try:
462
+ os.rmdir(staging)
463
+ except OSError:
464
+ pass
465
+
466
+
467
+ def _purge_redact_staging_dir(staging):
468
+ if os.path.basename(os.path.abspath(staging)) != _REDACT_STAGING_DIRNAME:
469
+ return
470
+ if os.path.islink(staging) or not os.path.isdir(staging):
471
+ return
472
+ for name in os.listdir(staging):
473
+ path = os.path.join(staging, name)
474
+ try:
475
+ if os.path.isdir(path) and not os.path.islink(path):
476
+ shutil.rmtree(path)
477
+ else:
478
+ os.unlink(path)
479
+ except OSError:
480
+ pass
481
+ _REDACT_TEMP_FILES.discard(path)
482
+ _remove_empty_redact_staging_dir(staging)
483
+
484
+
485
+ def _redact_staging_candidates(path):
486
+ candidates = []
487
+ try:
488
+ candidates.append(os.path.join(_same_device_staging_base(path), _REDACT_STAGING_DIRNAME))
489
+ except OSError:
490
+ pass
491
+ abs_path = os.path.abspath(path)
492
+ if os.path.isdir(abs_path):
493
+ candidates.append(os.path.join(abs_path, _REDACT_STAGING_DIRNAME))
494
+ return candidates
495
+
496
+
497
+ def _purge_known_redact_staging_dirs(*paths):
498
+ seen = set()
499
+ for path in paths:
500
+ if not path or not os.path.exists(path):
501
+ continue
502
+ for staging in _redact_staging_candidates(path):
503
+ if staging in seen:
504
+ continue
505
+ seen.add(staging)
506
+ _purge_redact_staging_dir(staging)
507
+
508
+
509
+ def _replace_redacted_tempfile(temporary_copy, path):
510
+ try:
511
+ os.replace(temporary_copy, path)
512
+ _REDACT_TEMP_FILES.discard(temporary_copy)
513
+ return
514
+ except OSError as error:
515
+ if getattr(error, "errno", None) != errno.EXDEV:
516
+ raise
517
+ staging = _same_device_staging_dir(path)
518
+ fd, local_copy = tempfile.mkstemp(
519
+ prefix=f".{os.path.basename(path)}.",
520
+ suffix=".redact",
521
+ dir=staging,
522
+ )
523
+ _register_redact_temp(local_copy)
524
+ try:
525
+ with os.fdopen(fd, "wb") as destination, open(temporary_copy, "rb") as source:
526
+ shutil.copyfileobj(source, destination)
527
+ destination.flush()
528
+ os.fchmod(destination.fileno(), 0o600)
529
+ os.replace(local_copy, path)
530
+ _REDACT_TEMP_FILES.discard(local_copy)
531
+ except OSError:
532
+ _unlink_redact_temp(local_copy)
533
+ raise
534
+ finally:
535
+ _unlink_redact_temp(temporary_copy)
536
+ _remove_empty_redact_staging_dir(staging)
537
+
538
+
539
+ def _redact_directory_text_files(root):
540
+ if not os.path.isdir(root):
541
+ return
542
+ _purge_known_redact_staging_dirs(root)
543
+ for dirpath, dirnames, filenames in os.walk(root):
544
+ if _REDACT_STAGING_DIRNAME in dirnames:
545
+ dirnames.remove(_REDACT_STAGING_DIRNAME)
546
+ for name in filenames:
547
+ if os.path.splitext(name)[1].lower() not in _REDACTED_COPY_EXTENSIONS:
548
+ continue
549
+ _redact_text_file_inplace(os.path.join(dirpath, name))
550
+
551
+
552
+ def _copy_redacted_text_file(src, dst):
553
+ parent = os.path.dirname(dst)
554
+ if parent:
555
+ os.makedirs(parent, exist_ok=True)
556
+ try:
557
+ with open(src, encoding="utf-8", errors="replace") as source_file:
558
+ text = _redact_secret_log_text(source_file.read())
559
+ except OSError:
560
+ shutil.copy2(src, dst)
561
+ return
562
+ with os.fdopen(os.open(dst, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), "w", encoding="utf-8") as dest_file:
563
+ dest_file.write(text)
564
+
565
+
152
566
  def _merge_parallel_verify_environment_outputs(project: Project, passes):
153
567
  parent_target = project.expand_path("$dir_target")
568
+ _purge_known_redact_staging_dirs(parent_target, project.basedir)
154
569
  parent_logs_directory = prepare_logs_directory(project)
155
- parent_output_directories = [
156
- ("logs", parent_logs_directory),
157
- ("reports", prepare_reports_directory(project)),
158
- ]
570
+ parent_destinations = {
571
+ "logs": parent_logs_directory,
572
+ "reports": prepare_reports_directory(project),
573
+ }
159
574
  parent_cypress_log = os.path.join(parent_logs_directory, "cypress_run.log")
160
575
  has_parent_cypress_log = False
161
576
  for pass_config in passes:
162
577
  stage_target = os.path.join(parent_target, "verify_environment", pass_config["name"])
163
- for output_directory, destination_directory in parent_output_directories:
164
- source = os.path.join(stage_target, output_directory, "integration")
165
- if os.path.exists(source):
578
+ for source_root in _child_output_source_roots(stage_target):
579
+ for destination_kind, source_dirname in _CHILD_OUTPUT_SOURCES:
580
+ source = os.path.join(source_root, source_dirname, "integration")
581
+ if not os.path.exists(source):
582
+ continue
583
+ _redact_directory_text_files(source)
166
584
  destination = os.path.join(
167
- destination_directory,
585
+ parent_destinations[destination_kind],
168
586
  "parallel",
169
587
  pass_config["name"],
170
588
  )
171
- shutil.copytree(source, destination, dirs_exist_ok=True)
172
- if output_directory == "logs":
173
- cypress_log = os.path.join(source, "cypress_run.log")
174
- if os.path.exists(cypress_log):
175
- _copy_cypress_log(cypress_log, parent_cypress_log, has_parent_cypress_log)
176
- has_parent_cypress_log = True
589
+ shutil.copytree(source, destination, dirs_exist_ok=True, copy_function=_copy_maybe_redacted_file)
590
+ cypress_log = _first_child_cypress_log(stage_target)
591
+ if cypress_log:
592
+ _copy_cypress_log(cypress_log, parent_cypress_log, has_parent_cypress_log)
593
+ has_parent_cypress_log = True
177
594
 
178
595
 
179
596
  def _copy_cypress_log(source, destination, append):
597
+ try:
598
+ with open(source, encoding="utf-8", errors="replace") as source_file:
599
+ text = _redact_secret_log_text(source_file.read())
600
+ except OSError:
601
+ return
602
+ if text and not text.endswith("\n"):
603
+ text += "\n"
180
604
  flags = os.O_WRONLY | os.O_CREAT | (os.O_APPEND if append else os.O_TRUNC)
181
- mode = "ab" if append else "wb"
182
- with open(source, "rb") as source_file:
183
- with os.fdopen(os.open(destination, flags, 0o600), mode) as destination_file:
184
- shutil.copyfileobj(source_file, destination_file)
605
+ mode = "a" if append else "w"
606
+ with os.fdopen(os.open(destination, flags, 0o600), mode, encoding="utf-8") as destination_file:
607
+ destination_file.write(text)
185
608
 
186
609
 
187
- def _start_verify_environment_subprocess(project: Project, logger: Logger, pass_config, properties_file):
188
- log_file = os.path.join(prepare_logs_directory(project), f"verify_environment_{pass_config['name']}.log")
189
- command = _build_verify_environment_subprocess_command(project, pass_config)
610
+ def _verify_environment_subprocess_stage_dir(project: Project, pass_config):
611
+ return os.path.join(
612
+ project.expand_path("$dir_target"),
613
+ "verify_environment",
614
+ pass_config["name"],
615
+ )
616
+
617
+
618
+ def _verify_environment_subprocess_workdir_path(project: Project, pass_config):
619
+ return os.path.join(_verify_environment_subprocess_stage_dir(project, pass_config), "project")
620
+
621
+
622
+ def _prepare_verify_environment_subprocess_workdir(project: Project, pass_config):
623
+ stage_dir = _verify_environment_subprocess_stage_dir(project, pass_config)
624
+ workdir = _verify_environment_subprocess_workdir_path(project, pass_config)
625
+ if os.path.exists(stage_dir):
626
+ shutil.rmtree(stage_dir)
627
+ os.makedirs(workdir)
628
+ for name in os.listdir(project.basedir):
629
+ if name in _SUBPROCESS_WORKDIR_SKIP:
630
+ continue
631
+ os.symlink(os.path.join(project.basedir, name), os.path.join(workdir, name))
632
+ _link_parent_target_tree(project, workdir)
633
+ source_plugins = os.path.join(project.basedir, ".pybuilder")
634
+ if os.path.isdir(source_plugins):
635
+ destination_plugins = os.path.join(workdir, ".pybuilder")
636
+ shutil.copytree(source_plugins, destination_plugins, symlinks=True)
637
+ _materialize_external_venv_pythons(os.path.join(destination_plugins, "plugins"))
638
+ return workdir
639
+
640
+
641
+ def _link_parent_target_tree(project: Project, workdir):
642
+ parent_target = project.expand_path("$dir_target")
643
+ child_target = os.path.join(workdir, "target")
644
+ os.makedirs(child_target, exist_ok=True)
645
+ if not os.path.isdir(parent_target):
646
+ return
647
+ for name in os.listdir(parent_target):
648
+ if name in _CHILD_TARGET_LINK_SKIP:
649
+ continue
650
+ os.symlink(os.path.join(parent_target, name), os.path.join(child_target, name))
651
+
652
+
653
+ def _is_venv_python_launcher(name):
654
+ if name in {"python", "python3"}:
655
+ return True
656
+ if name.startswith("python3."):
657
+ return name[len("python"):].replace(".", "").isdigit()
658
+ return False
659
+
660
+
661
+ def _is_real_directory(path):
662
+ return os.path.isdir(path) and not os.path.islink(path)
663
+
664
+
665
+ def _is_path_within(path, root):
666
+ root_real = os.path.realpath(root)
667
+ try:
668
+ return os.path.commonpath([root_real, os.path.realpath(path)]) == root_real
669
+ except ValueError:
670
+ return False
671
+
672
+
673
+ def _materialize_external_venv_pythons(plugins_root):
674
+ if not _is_real_directory(plugins_root):
675
+ return
676
+ plugins_root_real = os.path.realpath(plugins_root)
677
+ for version_dir in os.listdir(plugins_root):
678
+ version_root = os.path.join(plugins_root, version_dir)
679
+ bin_dir = os.path.join(version_root, "bin")
680
+ if not _is_real_directory(version_root) or not _is_real_directory(bin_dir):
681
+ continue
682
+ bin_dir_real = os.path.realpath(bin_dir)
683
+ if not _is_path_within(bin_dir_real, plugins_root_real):
684
+ continue
685
+ version_root_real = os.path.realpath(version_root)
686
+ for name in os.listdir(bin_dir_real):
687
+ if not _is_venv_python_launcher(name):
688
+ continue
689
+ path = os.path.join(bin_dir_real, name)
690
+ if not os.path.islink(path):
691
+ continue
692
+ target = os.path.realpath(path)
693
+ if not os.path.isfile(target):
694
+ continue
695
+ if _is_path_within(target, version_root_real):
696
+ continue
697
+ # mkstemp uses O_EXCL so a planted *.materialize symlink cannot
698
+ # redirect the write outside the staged workdir.
699
+ fd, temporary_copy = tempfile.mkstemp(
700
+ prefix=f".{name}.",
701
+ suffix=".materialize",
702
+ dir=bin_dir_real,
703
+ )
704
+ try:
705
+ with os.fdopen(fd, "wb") as destination, open(target, "rb") as source:
706
+ shutil.copyfileobj(source, destination)
707
+ destination.flush()
708
+ os.fchmod(destination.fileno(), 0o700)
709
+ os.replace(temporary_copy, path)
710
+ except Exception:
711
+ try:
712
+ os.unlink(temporary_copy)
713
+ except OSError:
714
+ pass
715
+ raise
716
+
717
+
718
+ def _plugin_python_executable(plugins_root):
719
+ if not os.path.isdir(plugins_root):
720
+ return None
721
+ for version_dir in sorted(os.listdir(plugins_root)):
722
+ candidate = os.path.join(plugins_root, version_dir, "bin", "python")
723
+ if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
724
+ return candidate
725
+ return None
726
+
727
+
728
+ _PYBUILDER_CLI_MAIN = "import sys; from pybuilder.cli import main; raise SystemExit(main(*sys.argv[1:]))"
729
+
730
+
731
+ def _isolated_plugin_python_command(plugin_python, args):
732
+ pyb_script = os.path.join(os.path.dirname(plugin_python), "pyb")
733
+ if os.path.isfile(pyb_script):
734
+ return [plugin_python, "-u", pyb_script] + args
735
+ return [plugin_python, "-u", "-c", _PYBUILDER_CLI_MAIN] + args
736
+
737
+
738
+ def _uses_isolated_plugin_python(command):
739
+ if len(command) < 2:
740
+ return False
741
+ args = command[1:]
742
+ if args and args[0] == "-u":
743
+ args = args[1:]
744
+ if not args:
745
+ return False
746
+ if args[0] == "-c":
747
+ return len(args) >= 2 and args[1] == _PYBUILDER_CLI_MAIN
748
+ return os.path.basename(args[0]) == "pyb"
749
+
750
+
751
+ def _verify_environment_subprocess_env(workdir, command, properties_file):
190
752
  env = os.environ.copy()
191
753
  env[SUBPROCESS_PROPERTIES_FILE_ENV] = properties_file
192
- logger.info(f"Starting {pass_config['name']} verify_environment subprocess: {' '.join(command)}")
754
+ env["PYTHONUNBUFFERED"] = "1"
755
+ env.pop("GITHUB_STEP_SUMMARY", None)
756
+ env.pop("PYTHONHOME", None)
757
+ env.pop("VIRTUAL_ENV", None)
758
+ env.pop("PYTHONPATH", None)
759
+ return env
760
+
761
+
762
+ def _start_verify_environment_subprocess(project: Project, logger: Logger, pass_config, properties_file):
763
+ log_file = os.path.join(prepare_logs_directory(project), f"verify_environment_{pass_config['name']}.log")
764
+ workdir = _prepare_verify_environment_subprocess_workdir(project, pass_config)
765
+ command = _build_verify_environment_subprocess_command(project, pass_config, workdir)
766
+ env = _verify_environment_subprocess_env(workdir, command, properties_file)
767
+ logger.info(
768
+ f"Starting {pass_config['name']} verify_environment subprocess in {workdir}: {' '.join(command)}"
769
+ )
193
770
  log_handle = os.fdopen(
194
771
  os.open(log_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600),
195
772
  "w",
@@ -197,30 +774,42 @@ def _start_verify_environment_subprocess(project: Project, logger: Logger, pass_
197
774
  )
198
775
  try:
199
776
  process = subprocess.Popen(command,
200
- stdout=log_handle,
777
+ stdout=subprocess.PIPE,
201
778
  stderr=subprocess.STDOUT,
202
- cwd=project.basedir,
779
+ cwd=workdir,
203
780
  env=env,
204
781
  shell=False)
782
+ process._pybuilder_integration_log_handle = log_handle
783
+ _start_subprocess_output_tee(process, log_handle, logger, pass_config["name"])
205
784
  except Exception:
206
785
  log_handle.close()
207
786
  raise
208
- process._pybuilder_integration_log_handle = log_handle
209
787
  return process, log_file
210
788
 
211
789
 
212
- def _build_verify_environment_subprocess_command(project: Project, pass_config):
213
- stage_target = os.path.join(project.expand_path("$dir_target"), "verify_environment", pass_config["name"])
214
- return [
215
- "pyb",
790
+ def _build_verify_environment_subprocess_command(project: Project, pass_config, workdir=None):
791
+ stage_target = _verify_environment_subprocess_stage_dir(project, pass_config)
792
+ args = [
793
+ "--no-venvs",
216
794
  pass_config["task"],
217
795
  "-P", f"{WORKING_TEST_DIR}={pass_config['test_dir']}",
218
796
  "-P", f"{PARALLEL_VERIFY_ENVIRONMENT}=False",
219
797
  "-P", f"{PROMOTE_ARTIFACT}=False",
220
798
  "-P", f"dir_target={stage_target}",
221
799
  "-P", f"dir_logs={os.path.join(stage_target, 'logs')}",
222
- "-P", f"dir_reports={os.path.join(stage_target, 'reports')}",
800
+ "-P", f"dir_reports={os.path.join(stage_target, 'test-reports')}",
223
801
  ]
802
+ if not workdir:
803
+ raise BuildFailedException(
804
+ f"Cannot start isolated verify_environment subprocess for {pass_config['name']}: workdir is required"
805
+ )
806
+ plugin_python = _plugin_python_executable(os.path.join(workdir, ".pybuilder", "plugins"))
807
+ if not plugin_python:
808
+ raise BuildFailedException(
809
+ f"Cannot start isolated verify_environment subprocess for {pass_config['name']}: "
810
+ "copied plugin python is missing or not executable"
811
+ )
812
+ return _isolated_plugin_python_command(plugin_python, args)
224
813
 
225
814
 
226
815
  def _write_subprocess_properties_file(project: Project):
@@ -372,11 +961,23 @@ def _run_cypress_tests_in_directory(work_dir, logger, project, reactor: Reactor)
372
961
 
373
962
 
374
963
  def _write_cypress_summary(project, logger):
375
- """Parse cypress_run.log and write a portable Markdown summary artifact."""
964
+ """Parse cypress_run.log and write a portable Markdown summary artifact.
965
+
966
+ No-op when Cypress did not run (missing or empty cypress_run.log), so
967
+ verify_environment jobs without Cypress do not emit a false warning.
968
+ Parallel child subprocesses also skip it; the parent writes one summary
969
+ after merging child Cypress logs.
970
+ """
376
971
  log_path = os.path.join(prepare_logs_directory(project), "cypress_run.log")
972
+ if os.environ.get(SUBPROCESS_PROPERTIES_FILE_ENV):
973
+ logger.debug("Skipping Cypress summary in verify_environment subprocess")
974
+ return None
975
+ if not os.path.isfile(log_path) or os.path.getsize(log_path) == 0:
976
+ logger.debug("Skipping Cypress summary: cypress_run.log not found")
977
+ return None
377
978
  summary_path = os.path.join(prepare_reports_directory(project), "cypress-summary.md")
378
979
  try:
379
- output = _build_cypress_summary(log_path)
980
+ output = _redact_secret_log_text(_build_cypress_summary(log_path))
380
981
  with os.fdopen(
381
982
  os.open(summary_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600),
382
983
  "w",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pybuilder-integration
3
- Version: 106
3
+ Version: 108
4
4
  Summary: A pybuilder plugin that runs integration tests (Tavern & Cypress) against a target.
5
5
  Home-page: https://github.com/rspitler/pybuilder-integration
6
6
  Author:
@@ -21,7 +21,7 @@ class install(_install):
21
21
  if __name__ == '__main__':
22
22
  setup(
23
23
  name = 'pybuilder-integration',
24
- version = '106',
24
+ version = '108',
25
25
  description = 'A pybuilder plugin that runs integration tests (Tavern & Cypress) against a target.',
26
26
  long_description = 'A pybuilder plugin that runs integration tests against a target. This is intended to be a broader scope than unit-tests encompassing dependant functionality.',
27
27
  long_description_content_type = None,