pybuilder-integration 107__tar.gz → 109__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-107 → pybuilder_integration-109}/PKG-INFO +1 -1
  2. {pybuilder_integration-107 → pybuilder_integration-109}/pybuilder_integration/directory_utility.py +1 -2
  3. {pybuilder_integration-107 → pybuilder_integration-109}/pybuilder_integration/properties.py +2 -0
  4. {pybuilder_integration-107 → pybuilder_integration-109}/pybuilder_integration/tasks.py +750 -81
  5. {pybuilder_integration-107 → pybuilder_integration-109}/pybuilder_integration.egg-info/PKG-INFO +1 -1
  6. {pybuilder_integration-107 → pybuilder_integration-109}/setup.py +1 -1
  7. {pybuilder_integration-107 → pybuilder_integration-109}/pybuilder_integration/__init__.py +0 -0
  8. {pybuilder_integration-107 → pybuilder_integration-109}/pybuilder_integration/artifact_manager.py +0 -0
  9. {pybuilder_integration-107 → pybuilder_integration-109}/pybuilder_integration/cloudwatchlogs_utility.py +0 -0
  10. {pybuilder_integration-107 → pybuilder_integration-109}/pybuilder_integration/exec_utility.py +0 -0
  11. {pybuilder_integration-107 → pybuilder_integration-109}/pybuilder_integration/tool_utility.py +0 -0
  12. {pybuilder_integration-107 → pybuilder_integration-109}/pybuilder_integration.egg-info/SOURCES.txt +0 -0
  13. {pybuilder_integration-107 → pybuilder_integration-109}/pybuilder_integration.egg-info/dependency_links.txt +0 -0
  14. {pybuilder_integration-107 → pybuilder_integration-109}/pybuilder_integration.egg-info/namespace_packages.txt +0 -0
  15. {pybuilder_integration-107 → pybuilder_integration-109}/pybuilder_integration.egg-info/requires.txt +0 -0
  16. {pybuilder_integration-107 → pybuilder_integration-109}/pybuilder_integration.egg-info/top_level.txt +0 -0
  17. {pybuilder_integration-107 → pybuilder_integration-109}/pybuilder_integration.egg-info/zip-safe +0 -0
  18. {pybuilder_integration-107 → pybuilder_integration-109}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pybuilder-integration
3
- Version: 107
3
+ Version: 109
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:
@@ -29,8 +29,7 @@ def prepare_directory(dir_variable, project):
29
29
 
30
30
 
31
31
  def _ensure_directory_exists(path):
32
- if not os.path.exists(path):
33
- os.makedirs(path)
32
+ os.makedirs(path, exist_ok=True)
34
33
  return path
35
34
 
36
35
 
@@ -20,5 +20,7 @@ CONSOLIDATE_TESTS = "consolidate_tavern"
20
20
  SKIP_NON_MATCHING_TAVERN_ARTIFACTS = "skip_non_matching_tavern_artifacts"
21
21
  TESTING_SCOPE = "testing_scope"
22
22
  PARALLEL_VERIFY_ENVIRONMENT = "parallel_verify_environment"
23
+ PARALLEL_TAVERN_SUITES = "parallel_tavern_suites"
24
+ PARALLEL_TAVERN_SUITES_WORKERS = "parallel_tavern_suites_workers"
23
25
  SUBPROCESS_PROPERTIES_FILE_ENV = "PYBUILDER_INTEGRATION_PROPERTIES_FILE"
24
26
  CYPRESS_CACHE_FOLDER = "cypress_cache_folder"
@@ -1,9 +1,13 @@
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
10
+ from concurrent.futures import ThreadPoolExecutor, as_completed
7
11
 
8
12
  from defusedxml import ElementTree as ET
9
13
  from defusedxml.common import DefusedXmlException
@@ -45,11 +49,16 @@ _VERIFY_ENVIRONMENT_SUBPROCESS_PROPERTIES = [
45
49
  CONSOLIDATE_TESTS,
46
50
  SKIP_NON_MATCHING_TAVERN_ARTIFACTS,
47
51
  TESTING_SCOPE,
52
+ PARALLEL_TAVERN_SUITES,
53
+ PARALLEL_TAVERN_SUITES_WORKERS,
48
54
  "abort_upload",
49
55
  "record_cypress",
50
56
  "verbose",
51
57
  ]
52
58
 
59
+ _TAVERN_INSTALL_LOCK = threading.Lock()
60
+ _DEFAULT_PARALLEL_TAVERN_SUITE_WORKERS = 8
61
+
53
62
  def integration_artifact_push(project: Project, logger: Logger, reactor: Reactor):
54
63
  logger.info("Starting upload of integration artifacts")
55
64
  manager = get_artifact_manager(project)
@@ -124,15 +133,21 @@ def _run_verify_environment_passes_in_subprocesses(project: Project, logger: Log
124
133
  process, log_file = _start_verify_environment_subprocess(project, logger, pass_config, properties_file)
125
134
  processes.append((pass_config, process, log_file))
126
135
 
127
- failures = []
136
+ results = []
128
137
  for pass_config, process, log_file in processes:
129
138
  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
-
139
+ _close_subprocess_log(process)
140
+ results.append({
141
+ "name": pass_config["name"],
142
+ "exit_code": exit_code,
143
+ "log_file": log_file,
144
+ })
145
+
146
+ _log_parallel_verify_environment_summary(logger, results)
147
+ failures = [
148
+ f"{result['name']} exit_code={result['exit_code']} log={result['log_file']}"
149
+ for result in results if result["exit_code"] != 0
150
+ ]
136
151
  if failures:
137
152
  raise BuildFailedException("Parallel verify_environment failed: " + "; ".join(failures))
138
153
  finally:
@@ -141,24 +156,422 @@ def _run_verify_environment_passes_in_subprocesses(project: Project, logger: Log
141
156
  if callable(poll) and poll() is None:
142
157
  process.terminate()
143
158
  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()
159
+ _close_subprocess_log(process)
147
160
  _merge_parallel_verify_environment_outputs(project, passes)
161
+ _write_cypress_summary(project, logger)
148
162
  if os.path.exists(properties_file):
149
163
  os.remove(properties_file)
150
164
 
151
165
 
166
+ _ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m")
167
+ _SUITE_RUNNING_LINE = re.compile(
168
+ r"^\[INFO\]\s+Running (?!tavern tests\b|against target\b|cypress on host\b|args:).+"
169
+ )
170
+ _SECRET_QUOTE = r"(?:['\"]|"|')?"
171
+ _SECRET_VALUE = rf"{_SECRET_QUOTE}[^'\"\s<&>,}}]+"
172
+ _SECRET_LITERAL_VALUE = rf"(?!(?:true|false|yes|no|on|off|null|none)\b){_SECRET_VALUE}"
173
+ _SECRET_KEY_NAME = (
174
+ r"(?:[A-Za-z][A-Za-z0-9]*[_-])*"
175
+ r"(?:"
176
+ r"x-api-key|x-amz-security-token|"
177
+ r"api[_-]?key|secret[_-]?access[_-]?key|access[_-]?key(?:[_-]?id)?|"
178
+ r"client[_-]?secret|password|secret|"
179
+ r"(?:id|access|refresh|session)?[_-]?token"
180
+ r")"
181
+ )
182
+ _SECRET_HINT_LINE = re.compile(
183
+ r"("
184
+ rf"\bauthorization\b{_SECRET_QUOTE}\s*:\s*{_SECRET_QUOTE}(?:(?:bearer|basic)\s+)?{_SECRET_VALUE}"
185
+ rf"|\b(?:set-cookie|cookie)\b{_SECRET_QUOTE}\s*:\s*{_SECRET_QUOTE}{_SECRET_VALUE}(?:\s*;\s*{_SECRET_VALUE})*"
186
+ rf"|{_SECRET_KEY_NAME}{_SECRET_QUOTE}\s*[:=]\s*{_SECRET_LITERAL_VALUE}"
187
+ r"|\bbearer\s+[A-Za-z0-9._\-]+"
188
+ r"|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}(?:\.[A-Za-z0-9_-]{10,})?"
189
+ r"|https?://[^/\s]+:[^/\s]+@"
190
+ r"|\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})"
191
+ r")",
192
+ re.IGNORECASE,
193
+ )
194
+ _REDACTED_SECRET = "[redacted]"
195
+ _REDACTED_COPY_EXTENSIONS = {".html", ".json", ".log", ".md", ".out", ".txt", ".xml"}
196
+ _REDACT_STAGING_DIRNAME = ".pybuilder-integration-redact"
197
+ _REDACT_TEMP_FILES = set()
198
+ _SUBPROCESS_LOG_JOIN_SECONDS = 30
199
+
200
+
201
+ def _log_parallel_verify_environment_summary(logger: Logger, results):
202
+ summary = _build_parallel_verify_environment_summary(results)
203
+ print(summary)
204
+ github_summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
205
+ if not github_summary_path:
206
+ return
207
+ try:
208
+ with os.fdopen(
209
+ os.open(github_summary_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600),
210
+ "a",
211
+ encoding="utf-8",
212
+ ) as summary_file:
213
+ summary_file.write(summary)
214
+ if not summary.endswith("\n"):
215
+ summary_file.write("\n")
216
+ except OSError:
217
+ logger.warn("Could not append parallel verify_environment summary to GITHUB_STEP_SUMMARY")
218
+
219
+
220
+ def _build_parallel_verify_environment_summary(results):
221
+ lines = ["Parallel verify_environment summary"]
222
+ for result in results:
223
+ status = "PASSED" if result["exit_code"] == 0 else "FAILED"
224
+ lines.append(f" {result['name']}: {status} (exit_code={result['exit_code']})")
225
+ for result in results:
226
+ if result["exit_code"] == 0:
227
+ continue
228
+ lines.append(f" {result['name']} log: {result['log_file']}")
229
+ for hint in _extract_parallel_verify_failure_hints(result["log_file"]):
230
+ lines.append(f" {hint}")
231
+ return "\n".join(lines) + "\n"
232
+
233
+
234
+ def _extract_parallel_verify_failure_hints(log_file, max_hints=12):
235
+ if not log_file or not os.path.exists(log_file):
236
+ return ["child log was not found"]
237
+ try:
238
+ with open(log_file, encoding="utf-8", errors="replace") as log_handle:
239
+ raw = log_handle.read()
240
+ except OSError:
241
+ return ["child log could not be read"]
242
+ text = _ANSI_ESCAPE.sub("", raw)
243
+ lines = [line.rstrip() for line in text.splitlines()]
244
+ last_running = None
245
+ last_build_failed = None
246
+ failed_tests = []
247
+ session_summaries = []
248
+ extra_failures = []
249
+ for line in lines:
250
+ stripped = line.strip()
251
+ if not stripped:
252
+ continue
253
+ if _SUITE_RUNNING_LINE.match(stripped):
254
+ last_running = stripped
255
+ elif stripped.startswith("BUILD FAILED"):
256
+ last_build_failed = stripped
257
+ elif stripped.startswith("FAILED ") and "::" in stripped:
258
+ failed_tests.append(stripped)
259
+ elif stripped.startswith("===") and "failed" in stripped.lower():
260
+ session_summaries.append(stripped)
261
+ elif stripped.startswith("### FAIL") or "Failed to execute cypress" in stripped:
262
+ extra_failures.append(stripped)
263
+ hints = []
264
+ if last_running:
265
+ hints.append(last_running)
266
+ if last_build_failed:
267
+ hints.append(last_build_failed)
268
+ hints.extend(extra_failures[-3:])
269
+ hints.extend(failed_tests[-8:])
270
+ hints.extend(session_summaries[-3:])
271
+ if hints:
272
+ return _without_secret_hint_lines(hints)[:max_hints]
273
+ tail = [line.strip() for line in lines[-20:] if line.strip()]
274
+ if not tail:
275
+ return ["child log was empty"]
276
+ return _without_secret_hint_lines(tail)[-max_hints:]
277
+
278
+
279
+ def _start_subprocess_output_tee(process, log_handle, logger: Logger, pass_name):
280
+ stdout = getattr(process, "stdout", None)
281
+ if stdout is None or isinstance(stdout, (int, str)):
282
+ process._pybuilder_integration_log_thread = None
283
+ return
284
+
285
+ def tee():
286
+ prefix = f"[{pass_name}] "
287
+ try:
288
+ for line in stdout:
289
+ redacted = _redact_secret_log_line(_decode_subprocess_stdout_line(line))
290
+ try:
291
+ log_handle.write(redacted + "\n")
292
+ log_handle.flush()
293
+ except Exception:
294
+ pass
295
+ try:
296
+ logger.info(prefix + redacted)
297
+ except Exception:
298
+ pass
299
+ finally:
300
+ try:
301
+ stdout.close()
302
+ except Exception:
303
+ pass
304
+
305
+ thread = threading.Thread(target=tee, name=f"verify-environment-{pass_name}-log", daemon=True)
306
+ thread.start()
307
+ process._pybuilder_integration_log_thread = thread
308
+
309
+
310
+ def _close_subprocess_stdout(process):
311
+ stdout = getattr(process, "stdout", None)
312
+ if stdout is None or isinstance(stdout, (int, str)):
313
+ return
314
+ try:
315
+ stdout.close()
316
+ except Exception:
317
+ pass
318
+
319
+
320
+ def _close_subprocess_log(process):
321
+ thread = getattr(process, "_pybuilder_integration_log_thread", None)
322
+ if thread is not None:
323
+ thread.join(timeout=_SUBPROCESS_LOG_JOIN_SECONDS)
324
+ if thread.is_alive():
325
+ _close_subprocess_stdout(process)
326
+ thread.join(timeout=_SUBPROCESS_LOG_JOIN_SECONDS)
327
+ if thread.is_alive():
328
+ return
329
+ process._pybuilder_integration_log_thread = None
330
+ log_handle = getattr(process, "_pybuilder_integration_log_handle", None)
331
+ if log_handle and not getattr(log_handle, "closed", True):
332
+ log_handle.close()
333
+
334
+
335
+ def _decode_subprocess_stdout_line(line):
336
+ if isinstance(line, bytes):
337
+ line = line.decode("utf-8", "replace")
338
+ return line.rstrip("\n\r")
339
+
340
+
341
+ def _redact_secret_log_line(line):
342
+ return _SECRET_HINT_LINE.sub(_REDACTED_SECRET, line)
343
+
344
+
345
+ def _redact_secret_log_text(text):
346
+ if not text:
347
+ return text
348
+ return "\n".join(_redact_secret_log_line(line) for line in text.splitlines()) + ("\n" if text.endswith("\n") else "")
349
+
350
+
351
+ def _without_secret_hint_lines(lines):
352
+ return [_redact_secret_log_line(line) for line in lines]
353
+
354
+
152
355
  _CHILD_OUTPUT_SOURCES = (
153
356
  ("logs", "logs"),
154
357
  ("reports", "reports"),
155
358
  ("reports", "test-reports"),
156
359
  )
157
360
  _SUBPROCESS_WORKDIR_SKIP = {".pybuilder", "target", ".git", ".nudge-ci-venv"}
361
+ _CHILD_TARGET_LINK_SKIP = {"verify_environment", "logs", "reports", "test-reports"}
362
+
363
+
364
+ def _child_output_source_roots(stage_target):
365
+ return (
366
+ stage_target,
367
+ os.path.join(stage_target, "project", "target"),
368
+ )
369
+
370
+
371
+ def _first_child_cypress_log(stage_target):
372
+ for source_root in _child_output_source_roots(stage_target):
373
+ candidate = os.path.join(source_root, "logs", "integration", "cypress_run.log")
374
+ if os.path.isfile(candidate):
375
+ return candidate
376
+ return None
377
+
378
+
379
+ def _copy_maybe_redacted_file(src, dst, *, follow_symlinks=True):
380
+ if os.path.splitext(src)[1].lower() in _REDACTED_COPY_EXTENSIONS:
381
+ _copy_redacted_text_file(src, dst)
382
+ return dst
383
+ return shutil.copy2(src, dst, follow_symlinks=follow_symlinks)
384
+
385
+
386
+ def _redact_text_file_inplace(path):
387
+ if os.path.islink(path) or not os.path.isfile(path):
388
+ return
389
+ try:
390
+ with open(path, encoding="utf-8", errors="replace") as source_file:
391
+ text = source_file.read()
392
+ except OSError:
393
+ return
394
+ redacted = _redact_secret_log_text(text)
395
+ if redacted == text:
396
+ return
397
+ fd, temporary_copy = tempfile.mkstemp(
398
+ prefix=f".{os.path.basename(path)}.",
399
+ suffix=".redact",
400
+ dir=tempfile.gettempdir(),
401
+ )
402
+ _register_redact_temp(temporary_copy)
403
+ try:
404
+ with os.fdopen(fd, "w", encoding="utf-8") as dest_file:
405
+ dest_file.write(redacted)
406
+ dest_file.flush()
407
+ os.fchmod(dest_file.fileno(), 0o600)
408
+ _replace_redacted_tempfile(temporary_copy, path)
409
+ except OSError:
410
+ _unlink_redact_temp(temporary_copy)
411
+
412
+
413
+ def _register_redact_temp(path):
414
+ _REDACT_TEMP_FILES.add(path)
415
+ if not getattr(_register_redact_temp, "_atexit_registered", False):
416
+ atexit.register(_cleanup_redact_temps)
417
+ _register_redact_temp._atexit_registered = True
418
+
419
+
420
+ def _unlink_redact_temp(path):
421
+ try:
422
+ os.unlink(path)
423
+ except OSError:
424
+ pass
425
+ _REDACT_TEMP_FILES.discard(path)
426
+
427
+
428
+ def _cleanup_redact_temps():
429
+ for path in list(_REDACT_TEMP_FILES):
430
+ _unlink_redact_temp(path)
431
+
432
+
433
+ def _same_device_staging_base(path):
434
+ dest = os.path.abspath(path)
435
+ dest_dev = os.stat(dest).st_dev
436
+ current = dest if os.path.isdir(dest) else os.path.dirname(dest)
437
+ last_same = current
438
+ outside_target = None
439
+ while True:
440
+ try:
441
+ if os.stat(current).st_dev != dest_dev:
442
+ break
443
+ except OSError:
444
+ break
445
+ last_same = current
446
+ if "target" not in os.path.abspath(current).split(os.sep):
447
+ outside_target = current
448
+ break
449
+ parent = os.path.dirname(current)
450
+ if parent == current:
451
+ break
452
+ current = parent
453
+ return outside_target or last_same
454
+
455
+
456
+ def _same_device_staging_dir(path):
457
+ staging = os.path.join(_same_device_staging_base(path), _REDACT_STAGING_DIRNAME)
458
+ os.makedirs(staging, mode=0o700, exist_ok=True)
459
+ try:
460
+ os.chmod(staging, 0o700)
461
+ except OSError:
462
+ pass
463
+ return staging
464
+
465
+
466
+ def _remove_empty_redact_staging_dir(staging):
467
+ try:
468
+ os.rmdir(staging)
469
+ except OSError:
470
+ pass
471
+
472
+
473
+ def _purge_redact_staging_dir(staging):
474
+ if os.path.basename(os.path.abspath(staging)) != _REDACT_STAGING_DIRNAME:
475
+ return
476
+ if os.path.islink(staging) or not os.path.isdir(staging):
477
+ return
478
+ for name in os.listdir(staging):
479
+ path = os.path.join(staging, name)
480
+ try:
481
+ if os.path.isdir(path) and not os.path.islink(path):
482
+ shutil.rmtree(path)
483
+ else:
484
+ os.unlink(path)
485
+ except OSError:
486
+ pass
487
+ _REDACT_TEMP_FILES.discard(path)
488
+ _remove_empty_redact_staging_dir(staging)
489
+
490
+
491
+ def _redact_staging_candidates(path):
492
+ candidates = []
493
+ try:
494
+ candidates.append(os.path.join(_same_device_staging_base(path), _REDACT_STAGING_DIRNAME))
495
+ except OSError:
496
+ pass
497
+ abs_path = os.path.abspath(path)
498
+ if os.path.isdir(abs_path):
499
+ candidates.append(os.path.join(abs_path, _REDACT_STAGING_DIRNAME))
500
+ return candidates
501
+
502
+
503
+ def _purge_known_redact_staging_dirs(*paths):
504
+ seen = set()
505
+ for path in paths:
506
+ if not path or not os.path.exists(path):
507
+ continue
508
+ for staging in _redact_staging_candidates(path):
509
+ if staging in seen:
510
+ continue
511
+ seen.add(staging)
512
+ _purge_redact_staging_dir(staging)
513
+
514
+
515
+ def _replace_redacted_tempfile(temporary_copy, path):
516
+ try:
517
+ os.replace(temporary_copy, path)
518
+ _REDACT_TEMP_FILES.discard(temporary_copy)
519
+ return
520
+ except OSError as error:
521
+ if getattr(error, "errno", None) != errno.EXDEV:
522
+ raise
523
+ staging = _same_device_staging_dir(path)
524
+ fd, local_copy = tempfile.mkstemp(
525
+ prefix=f".{os.path.basename(path)}.",
526
+ suffix=".redact",
527
+ dir=staging,
528
+ )
529
+ _register_redact_temp(local_copy)
530
+ try:
531
+ with os.fdopen(fd, "wb") as destination, open(temporary_copy, "rb") as source:
532
+ shutil.copyfileobj(source, destination)
533
+ destination.flush()
534
+ os.fchmod(destination.fileno(), 0o600)
535
+ os.replace(local_copy, path)
536
+ _REDACT_TEMP_FILES.discard(local_copy)
537
+ except OSError:
538
+ _unlink_redact_temp(local_copy)
539
+ raise
540
+ finally:
541
+ _unlink_redact_temp(temporary_copy)
542
+ _remove_empty_redact_staging_dir(staging)
543
+
544
+
545
+ def _redact_directory_text_files(root):
546
+ if not os.path.isdir(root):
547
+ return
548
+ _purge_known_redact_staging_dirs(root)
549
+ for dirpath, dirnames, filenames in os.walk(root):
550
+ if _REDACT_STAGING_DIRNAME in dirnames:
551
+ dirnames.remove(_REDACT_STAGING_DIRNAME)
552
+ for name in filenames:
553
+ if os.path.splitext(name)[1].lower() not in _REDACTED_COPY_EXTENSIONS:
554
+ continue
555
+ _redact_text_file_inplace(os.path.join(dirpath, name))
556
+
557
+
558
+ def _copy_redacted_text_file(src, dst):
559
+ parent = os.path.dirname(dst)
560
+ if parent:
561
+ os.makedirs(parent, exist_ok=True)
562
+ try:
563
+ with open(src, encoding="utf-8", errors="replace") as source_file:
564
+ text = _redact_secret_log_text(source_file.read())
565
+ except OSError:
566
+ shutil.copy2(src, dst)
567
+ return
568
+ with os.fdopen(os.open(dst, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), "w", encoding="utf-8") as dest_file:
569
+ dest_file.write(text)
158
570
 
159
571
 
160
572
  def _merge_parallel_verify_environment_outputs(project: Project, passes):
161
573
  parent_target = project.expand_path("$dir_target")
574
+ _purge_known_redact_staging_dirs(parent_target, project.basedir)
162
575
  parent_logs_directory = prepare_logs_directory(project)
163
576
  parent_destinations = {
164
577
  "logs": parent_logs_directory,
@@ -168,29 +581,36 @@ def _merge_parallel_verify_environment_outputs(project: Project, passes):
168
581
  has_parent_cypress_log = False
169
582
  for pass_config in passes:
170
583
  stage_target = os.path.join(parent_target, "verify_environment", pass_config["name"])
171
- for destination_kind, source_dirname in _CHILD_OUTPUT_SOURCES:
172
- source = os.path.join(stage_target, source_dirname, "integration")
173
- if not os.path.exists(source):
174
- continue
175
- destination = os.path.join(
176
- parent_destinations[destination_kind],
177
- "parallel",
178
- pass_config["name"],
179
- )
180
- shutil.copytree(source, destination, dirs_exist_ok=True)
181
- if destination_kind == "logs":
182
- cypress_log = os.path.join(source, "cypress_run.log")
183
- if os.path.exists(cypress_log):
184
- _copy_cypress_log(cypress_log, parent_cypress_log, has_parent_cypress_log)
185
- has_parent_cypress_log = True
584
+ for source_root in _child_output_source_roots(stage_target):
585
+ for destination_kind, source_dirname in _CHILD_OUTPUT_SOURCES:
586
+ source = os.path.join(source_root, source_dirname, "integration")
587
+ if not os.path.exists(source):
588
+ continue
589
+ _redact_directory_text_files(source)
590
+ destination = os.path.join(
591
+ parent_destinations[destination_kind],
592
+ "parallel",
593
+ pass_config["name"],
594
+ )
595
+ shutil.copytree(source, destination, dirs_exist_ok=True, copy_function=_copy_maybe_redacted_file)
596
+ cypress_log = _first_child_cypress_log(stage_target)
597
+ if cypress_log:
598
+ _copy_cypress_log(cypress_log, parent_cypress_log, has_parent_cypress_log)
599
+ has_parent_cypress_log = True
186
600
 
187
601
 
188
602
  def _copy_cypress_log(source, destination, append):
603
+ try:
604
+ with open(source, encoding="utf-8", errors="replace") as source_file:
605
+ text = _redact_secret_log_text(source_file.read())
606
+ except OSError:
607
+ return
608
+ if text and not text.endswith("\n"):
609
+ text += "\n"
189
610
  flags = os.O_WRONLY | os.O_CREAT | (os.O_APPEND if append else os.O_TRUNC)
190
- mode = "ab" if append else "wb"
191
- with open(source, "rb") as source_file:
192
- with os.fdopen(os.open(destination, flags, 0o600), mode) as destination_file:
193
- shutil.copyfileobj(source_file, destination_file)
611
+ mode = "a" if append else "w"
612
+ with os.fdopen(os.open(destination, flags, 0o600), mode, encoding="utf-8") as destination_file:
613
+ destination_file.write(text)
194
614
 
195
615
 
196
616
  def _verify_environment_subprocess_stage_dir(project: Project, pass_config):
@@ -231,7 +651,7 @@ def _link_parent_target_tree(project: Project, workdir):
231
651
  if not os.path.isdir(parent_target):
232
652
  return
233
653
  for name in os.listdir(parent_target):
234
- if name == "verify_environment":
654
+ if name in _CHILD_TARGET_LINK_SKIP:
235
655
  continue
236
656
  os.symlink(os.path.join(parent_target, name), os.path.join(child_target, name))
237
657
 
@@ -317,21 +737,27 @@ _PYBUILDER_CLI_MAIN = "import sys; from pybuilder.cli import main; raise SystemE
317
737
  def _isolated_plugin_python_command(plugin_python, args):
318
738
  pyb_script = os.path.join(os.path.dirname(plugin_python), "pyb")
319
739
  if os.path.isfile(pyb_script):
320
- return [plugin_python, pyb_script] + args
321
- return [plugin_python, "-c", _PYBUILDER_CLI_MAIN] + args
740
+ return [plugin_python, "-u", pyb_script] + args
741
+ return [plugin_python, "-u", "-c", _PYBUILDER_CLI_MAIN] + args
322
742
 
323
743
 
324
744
  def _uses_isolated_plugin_python(command):
325
745
  if len(command) < 2:
326
746
  return False
327
- if command[1] == "-c":
328
- return len(command) >= 3 and command[2] == _PYBUILDER_CLI_MAIN
329
- return os.path.basename(command[1]) == "pyb"
747
+ args = command[1:]
748
+ if args and args[0] == "-u":
749
+ args = args[1:]
750
+ if not args:
751
+ return False
752
+ if args[0] == "-c":
753
+ return len(args) >= 2 and args[1] == _PYBUILDER_CLI_MAIN
754
+ return os.path.basename(args[0]) == "pyb"
330
755
 
331
756
 
332
757
  def _verify_environment_subprocess_env(workdir, command, properties_file):
333
758
  env = os.environ.copy()
334
759
  env[SUBPROCESS_PROPERTIES_FILE_ENV] = properties_file
760
+ env["PYTHONUNBUFFERED"] = "1"
335
761
  env.pop("GITHUB_STEP_SUMMARY", None)
336
762
  env.pop("PYTHONHOME", None)
337
763
  env.pop("VIRTUAL_ENV", None)
@@ -354,15 +780,16 @@ def _start_verify_environment_subprocess(project: Project, logger: Logger, pass_
354
780
  )
355
781
  try:
356
782
  process = subprocess.Popen(command,
357
- stdout=log_handle,
783
+ stdout=subprocess.PIPE,
358
784
  stderr=subprocess.STDOUT,
359
785
  cwd=workdir,
360
786
  env=env,
361
787
  shell=False)
788
+ process._pybuilder_integration_log_handle = log_handle
789
+ _start_subprocess_output_tee(process, log_handle, logger, pass_config["name"])
362
790
  except Exception:
363
791
  log_handle.close()
364
792
  raise
365
- process._pybuilder_integration_log_handle = log_handle
366
793
  return process, log_file
367
794
 
368
795
 
@@ -465,15 +892,17 @@ def _run_tavern_tests_in_dist_dir(dist_directory, latest, logger, project, react
465
892
  logger.debug(f"Run tavern tests in directory: {tavern_test_path} ")
466
893
  logger.debug(f"Run tavern tests in directory files: {os.listdir(tavern_test_path)} ")
467
894
  if latest:
468
- for tavern_dir in os.listdir(tavern_test_path):
469
- tavern_test_direcotry = f"{tavern_test_path}/{tavern_dir}"
470
- if os.path.isdir(tavern_test_direcotry) and _should_run_latest(tavern_dir, project):
471
- logger.info(f"Running {tavern_test_direcotry}")
472
- _run_tavern_tests_in_dir(test_dir=tavern_test_direcotry,
895
+ suite_dirs = list(_iter_latest_tavern_suite_dirs(tavern_test_path, project))
896
+ if _get_bool_property(project, PARALLEL_TAVERN_SUITES, False) and len(suite_dirs) > 1:
897
+ _run_tavern_suites_parallel(suite_dirs, logger, project, reactor)
898
+ else:
899
+ for tavern_test_directory in suite_dirs:
900
+ logger.info(f"Running {tavern_test_directory}")
901
+ _run_tavern_tests_in_dir(test_dir=tavern_test_directory,
473
902
  logger=logger,
474
903
  project=project,
475
904
  reactor=reactor,
476
- role=os.path.basename(tavern_dir))
905
+ role=os.path.basename(tavern_test_directory))
477
906
  else:
478
907
  _run_tavern_tests_in_dir(test_dir=f"{tavern_test_path}",
479
908
  logger=logger,
@@ -483,6 +912,92 @@ def _run_tavern_tests_in_dist_dir(dist_directory, latest, logger, project, react
483
912
  logger.info(f"Ran Tavern tests: {total_time.get_millis()}")
484
913
 
485
914
 
915
+ def _iter_latest_tavern_suite_dirs(tavern_test_path, project):
916
+ for tavern_dir in sorted(os.listdir(tavern_test_path)):
917
+ tavern_test_directory = os.path.join(tavern_test_path, tavern_dir)
918
+ if os.path.isdir(tavern_test_directory) and _should_run_latest(tavern_dir, project):
919
+ yield tavern_test_directory
920
+
921
+
922
+ def _parallel_tavern_suite_workers(project, suite_count):
923
+ configured = project.get_property(PARALLEL_TAVERN_SUITES_WORKERS, None)
924
+ if configured is None or configured == "":
925
+ return max(1, min(_DEFAULT_PARALLEL_TAVERN_SUITE_WORKERS, suite_count))
926
+ try:
927
+ workers = int(configured)
928
+ except (TypeError, ValueError):
929
+ workers = _DEFAULT_PARALLEL_TAVERN_SUITE_WORKERS
930
+ return max(1, min(workers, suite_count))
931
+
932
+
933
+ def _run_tavern_suites_parallel(suite_dirs, logger, project, reactor):
934
+ prepare_reports_directory(project)
935
+ runnable = []
936
+ for tavern_test_directory in suite_dirs:
937
+ _, requested_marker, declared_markers, skip_non_matching = _tavern_marker_context(
938
+ tavern_test_directory, project
939
+ )
940
+ if _should_skip_nonmatching_tavern(requested_marker, declared_markers, skip_non_matching):
941
+ logger.info(
942
+ f"Skipping Tavern tests: artifact markers {sorted(declared_markers)} do not match {requested_marker}"
943
+ )
944
+ continue
945
+ runnable.append(tavern_test_directory)
946
+ if not runnable:
947
+ return
948
+ if len(runnable) == 1:
949
+ tavern_test_directory = runnable[0]
950
+ logger.info(f"Running {tavern_test_directory}")
951
+ _run_tavern_tests_in_dir(
952
+ test_dir=tavern_test_directory,
953
+ logger=logger,
954
+ project=project,
955
+ reactor=reactor,
956
+ role=os.path.basename(tavern_test_directory),
957
+ )
958
+ return
959
+
960
+ suite_sites = {}
961
+ for tavern_test_directory in runnable:
962
+ suite_sites[tavern_test_directory] = _install_tavern_suite_site(
963
+ tavern_test_directory, logger, project, reactor
964
+ )
965
+
966
+ workers = _parallel_tavern_suite_workers(project, len(runnable))
967
+ logger.info(f"Running {len(runnable)} tavern suites in parallel (workers={workers})")
968
+ failures = []
969
+
970
+ def run_suite(tavern_test_directory):
971
+ role = os.path.basename(tavern_test_directory)
972
+ logger.info(f"Running {tavern_test_directory}")
973
+ _run_tavern_tests_in_dir(
974
+ test_dir=tavern_test_directory,
975
+ logger=logger,
976
+ project=project,
977
+ reactor=reactor,
978
+ role=role,
979
+ isolated=True,
980
+ skip_install=True,
981
+ extra_pythonpath=suite_sites[tavern_test_directory],
982
+ )
983
+ return role
984
+
985
+ with ThreadPoolExecutor(max_workers=workers) as executor:
986
+ futures = {executor.submit(run_suite, suite_dir): suite_dir for suite_dir in runnable}
987
+ for future in as_completed(futures):
988
+ suite_dir = futures[future]
989
+ role = os.path.basename(suite_dir)
990
+ try:
991
+ future.result()
992
+ except Exception as error:
993
+ failures.append(f"{role}: {error}")
994
+
995
+ if failures:
996
+ raise BuildFailedException(
997
+ "Parallel tavern suites failed: " + "; ".join(sorted(failures))
998
+ )
999
+
1000
+
486
1001
  def verify_cypress(project: Project, logger: Logger, reactor: Reactor):
487
1002
  # Get directories with test and cypress executable
488
1003
  work_dir = project.expand_path(f"${CYPRESS_TEST_DIR}")
@@ -540,11 +1055,23 @@ def _run_cypress_tests_in_directory(work_dir, logger, project, reactor: Reactor)
540
1055
 
541
1056
 
542
1057
  def _write_cypress_summary(project, logger):
543
- """Parse cypress_run.log and write a portable Markdown summary artifact."""
1058
+ """Parse cypress_run.log and write a portable Markdown summary artifact.
1059
+
1060
+ No-op when Cypress did not run (missing or empty cypress_run.log), so
1061
+ verify_environment jobs without Cypress do not emit a false warning.
1062
+ Parallel child subprocesses also skip it; the parent writes one summary
1063
+ after merging child Cypress logs.
1064
+ """
544
1065
  log_path = os.path.join(prepare_logs_directory(project), "cypress_run.log")
1066
+ if os.environ.get(SUBPROCESS_PROPERTIES_FILE_ENV):
1067
+ logger.debug("Skipping Cypress summary in verify_environment subprocess")
1068
+ return None
1069
+ if not os.path.isfile(log_path) or os.path.getsize(log_path) == 0:
1070
+ logger.debug("Skipping Cypress summary: cypress_run.log not found")
1071
+ return None
545
1072
  summary_path = os.path.join(prepare_reports_directory(project), "cypress-summary.md")
546
1073
  try:
547
- output = _build_cypress_summary(log_path)
1074
+ output = _redact_secret_log_text(_build_cypress_summary(log_path))
548
1075
  with os.fdopen(
549
1076
  os.open(summary_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600),
550
1077
  "w",
@@ -654,7 +1181,101 @@ def verify_tavern(project: Project, logger: Logger, reactor: Reactor):
654
1181
  package_artifacts(project, test_dir, "tavern", project.get_property(ROLE))
655
1182
 
656
1183
 
657
- def _run_tavern_tests_in_dir(test_dir: str, logger: Logger, project: Project, reactor: Reactor, role=None):
1184
+ def _tavern_marker_context(test_dir, project):
1185
+ extra_args = [project.expand(prop) for prop in project.get_property(TAVERN_ADDITIONAL_ARGS, [])]
1186
+ requested_marker = _extract_tavern_marker_filter(extra_args)
1187
+ declared_markers = read_tavern_service_markers(test_dir)
1188
+ skip_non_matching = _get_bool_property(project, SKIP_NON_MATCHING_TAVERN_ARTIFACTS, False)
1189
+ return extra_args, requested_marker, declared_markers, skip_non_matching
1190
+
1191
+
1192
+ def _should_skip_nonmatching_tavern(requested_marker, declared_markers, skip_non_matching):
1193
+ return bool(skip_non_matching and requested_marker and declared_markers and requested_marker not in declared_markers)
1194
+
1195
+
1196
+ def _install_tavern_requirements(test_dir, logger, project, reactor):
1197
+ requirements_file = os.path.join(test_dir, "requirements.txt")
1198
+ if not os.path.exists(requirements_file):
1199
+ return
1200
+ dependency = RequirementsFile(requirements_file)
1201
+ with _TAVERN_INSTALL_LOCK:
1202
+ install_dependencies(logger, project, dependency, reactor.pybuilder_venv,
1203
+ f"{prepare_logs_directory(project)}/install_tavern_pip_dependencies.log")
1204
+
1205
+
1206
+ def _plugin_venv_executable_path(executable):
1207
+ if executable is None:
1208
+ return None
1209
+ if isinstance(executable, (list, tuple)):
1210
+ if not executable:
1211
+ return None
1212
+ return _plugin_venv_executable_path(executable[0])
1213
+ try:
1214
+ path = os.fspath(executable)
1215
+ except TypeError:
1216
+ return None
1217
+ if isinstance(path, bytes):
1218
+ path = os.fsdecode(path)
1219
+ return path
1220
+
1221
+
1222
+ def _plugin_venv_python(reactor):
1223
+ venv = getattr(reactor, "pybuilder_venv", None)
1224
+ path = _plugin_venv_executable_path(getattr(venv, "executable", None))
1225
+ if path and os.path.isfile(path) and os.access(path, os.X_OK):
1226
+ return path
1227
+ raise BuildFailedException("plugin venv python is missing or not executable")
1228
+
1229
+
1230
+ def _tavern_suite_site_directory(project, role):
1231
+ root = project.expand_path("$dir_target/integration/tavern-sites")
1232
+ directory_utility._ensure_directory_exists(root)
1233
+ site = os.path.join(root, role)
1234
+ if os.path.islink(site) or os.path.isfile(site):
1235
+ os.unlink(site)
1236
+ elif os.path.isdir(site):
1237
+ shutil.rmtree(site)
1238
+ os.makedirs(site, mode=0o700)
1239
+ return site
1240
+
1241
+
1242
+ def _isolated_suite_subprocess_env(extra_pythonpath=None):
1243
+ env = os.environ.copy()
1244
+ env.pop("GITHUB_STEP_SUMMARY", None)
1245
+ env.pop("PYTHONHOME", None)
1246
+ env.pop("VIRTUAL_ENV", None)
1247
+ env.pop("PYTHONPATH", None)
1248
+ if extra_pythonpath:
1249
+ env["PYTHONPATH"] = extra_pythonpath
1250
+ return env
1251
+
1252
+
1253
+ def _install_tavern_suite_site(test_dir, logger, project, reactor):
1254
+ requirements_file = os.path.join(test_dir, "requirements.txt")
1255
+ if not os.path.exists(requirements_file):
1256
+ return None
1257
+ role = os.path.basename(test_dir)
1258
+ site = _tavern_suite_site_directory(project, role)
1259
+ command = [_plugin_venv_python(reactor), "-m", "pip", "install", "-r", requirements_file, "-t", site]
1260
+ logger.info(f"Installing tavern requirements for {role} into {site}")
1261
+ process = subprocess.Popen(
1262
+ command,
1263
+ stdout=subprocess.PIPE,
1264
+ stderr=subprocess.STDOUT,
1265
+ cwd=test_dir,
1266
+ env=_isolated_suite_subprocess_env(),
1267
+ shell=False,
1268
+ )
1269
+ returncode = _stream_subprocess_output(process, logger, role)
1270
+ if returncode != 0:
1271
+ raise BuildFailedException(
1272
+ f"Failed to install tavern requirements for {role} into isolated site {site}"
1273
+ )
1274
+ return site
1275
+
1276
+
1277
+ def _run_tavern_tests_in_dir(test_dir: str, logger: Logger, project: Project, reactor: Reactor, role=None,
1278
+ isolated=False, skip_install=False, extra_pythonpath=None):
658
1279
  logger.info("Running tavern tests: {}".format(test_dir))
659
1280
  if not os.path.exists(test_dir):
660
1281
  logger.info("Skipping tavern run: no tests")
@@ -662,51 +1283,51 @@ def _run_tavern_tests_in_dir(test_dir: str, logger: Logger, project: Project, re
662
1283
  logger.info(f"Found {len(os.listdir(test_dir))} files in tavern test directory")
663
1284
  # todo is this unique enough for each run?
664
1285
  output_file, run_name = get_test_report_file(project, test_dir)
665
- extra_args = [project.expand(prop) for prop in project.get_property(TAVERN_ADDITIONAL_ARGS, [])]
666
- requested_marker = _extract_tavern_marker_filter(extra_args)
667
- declared_markers = read_tavern_service_markers(test_dir)
668
- skip_non_matching_artifacts = _get_bool_property(project, SKIP_NON_MATCHING_TAVERN_ARTIFACTS, False)
669
- if skip_non_matching_artifacts and requested_marker and declared_markers and requested_marker not in declared_markers:
1286
+ extra_args, requested_marker, declared_markers, skip_non_matching = _tavern_marker_context(test_dir, project)
1287
+ if _should_skip_nonmatching_tavern(requested_marker, declared_markers, skip_non_matching):
670
1288
  logger.info(
671
1289
  f"Skipping Tavern tests: artifact markers {sorted(declared_markers)} do not match {requested_marker}"
672
1290
  )
673
1291
  return True
674
- from sys import path as syspath
675
- syspath.insert(0, test_dir)
676
- # install any requirements that my exist
677
- requirements_file = os.path.join(test_dir, "requirements.txt")
678
- if os.path.exists(requirements_file):
679
- dependency = RequirementsFile(requirements_file)
680
- install_dependencies(logger, project, dependency, reactor.pybuilder_venv,
681
- f"{prepare_logs_directory(project)}/install_tavern_pip_dependencies.log")
1292
+ if not isolated:
1293
+ from sys import path as syspath
1294
+ syspath.insert(0, test_dir)
1295
+ if not skip_install:
1296
+ _install_tavern_requirements(test_dir, logger, project, reactor)
682
1297
  args = ["--junit-xml", f"{output_file}", test_dir] + extra_args
683
1298
  if project.get_property("verbose"):
684
1299
  args.append("-s")
685
1300
  args.append("-v")
686
- if _get_bool_property(project, RUN_PARALLEL, False):
1301
+ if not isolated and _get_bool_property(project, RUN_PARALLEL, False):
687
1302
  args.extend(['-n', 'auto'])
688
- os.environ['TARGET'] = project.get_property(INTEGRATION_TARGET_URL)
689
- os.environ['PUBLIC_TARGET'] = project.get_property(INTEGRATION_PUBLIC_TARGET_URL)
690
- os.environ[ENVIRONMENT] = project.get_property(ENVIRONMENT)
691
- logger.info(f"Running against target: {project.get_property(INTEGRATION_TARGET_URL)} and "
692
- f"public target: {project.get_property(INTEGRATION_PUBLIC_TARGET_URL)}")
693
- cache_wd = os.getcwd()
694
- try:
695
- os.chdir(test_dir)
696
- logger.debug(f"Running args: {args} ")
697
- ret = pytest.main(args)
698
- if ret == pytest.ExitCode.NO_TESTS_COLLECTED:
699
- fallback_args = _without_tavern_marker_filter(args)
700
- if fallback_args != args and (not skip_non_matching_artifacts or not declared_markers):
701
- logger.warn(
702
- "No Tavern tests matched the marker filter; rerunning the full suite"
703
- )
704
- logger.debug(f"Running fallback args: {fallback_args} ")
705
- ret = pytest.main(fallback_args)
706
- finally:
707
- os.chdir(cache_wd)
1303
+ target_url = project.get_property(INTEGRATION_TARGET_URL)
1304
+ public_target_url = project.get_property(INTEGRATION_PUBLIC_TARGET_URL)
1305
+ environment = project.get_property(ENVIRONMENT)
1306
+ pytest_env = _isolated_suite_subprocess_env(extra_pythonpath) if isolated else os.environ.copy()
1307
+ pytest_env['TARGET'] = target_url
1308
+ pytest_env['PUBLIC_TARGET'] = public_target_url
1309
+ pytest_env[ENVIRONMENT] = environment
1310
+ if not isolated:
1311
+ os.environ['TARGET'] = target_url
1312
+ os.environ['PUBLIC_TARGET'] = public_target_url
1313
+ os.environ[ENVIRONMENT] = environment
1314
+ logger.info(f"Running against target: {target_url} and "
1315
+ f"public target: {public_target_url}")
1316
+ logger.debug(f"Running args: {args} ")
1317
+ label = role or os.path.basename(test_dir)
1318
+ ret = _invoke_pytest(args, test_dir, pytest_env, logger, label, isolated, reactor)
1319
+ if _is_pytest_no_tests_collected(ret):
1320
+ fallback_args = _without_tavern_marker_filter(args)
1321
+ if fallback_args != args and (not skip_non_matching or not declared_markers):
1322
+ logger.warn(
1323
+ "No Tavern tests matched the marker filter; rerunning the full suite"
1324
+ )
1325
+ logger.debug(f"Running fallback args: {fallback_args} ")
1326
+ ret = _invoke_pytest(
1327
+ fallback_args, test_dir, pytest_env, logger, label, isolated, reactor
1328
+ )
708
1329
 
709
- if ret != 0:
1330
+ if _is_pytest_failure(ret):
710
1331
  if role:
711
1332
  roles = []
712
1333
  if project.get_property(CONSOLIDATE_TESTS, False):
@@ -723,6 +1344,54 @@ def _run_tavern_tests_in_dir(test_dir: str, logger: Logger, project: Project, re
723
1344
  return True
724
1345
 
725
1346
 
1347
+ def _is_pytest_no_tests_collected(ret):
1348
+ if ret is None:
1349
+ return False
1350
+ return int(ret) == int(pytest.ExitCode.NO_TESTS_COLLECTED)
1351
+
1352
+
1353
+ def _is_pytest_failure(ret):
1354
+ if ret is None:
1355
+ return True
1356
+ return int(ret) != 0
1357
+
1358
+
1359
+ def _invoke_pytest(args, test_dir, env, logger, label, isolated, reactor=None):
1360
+ if not isolated:
1361
+ cache_wd = os.getcwd()
1362
+ try:
1363
+ os.chdir(test_dir)
1364
+ return pytest.main(args)
1365
+ finally:
1366
+ os.chdir(cache_wd)
1367
+ command = [_plugin_venv_python(reactor), "-u", "-m", "pytest"] + args
1368
+ process = subprocess.Popen(
1369
+ command,
1370
+ stdout=subprocess.PIPE,
1371
+ stderr=subprocess.STDOUT,
1372
+ cwd=test_dir,
1373
+ env=env,
1374
+ shell=False,
1375
+ )
1376
+ return _stream_subprocess_output(process, logger, label)
1377
+
1378
+
1379
+ def _stream_subprocess_output(process, logger, label):
1380
+ stdout = process.stdout
1381
+ if stdout is None:
1382
+ return process.wait()
1383
+ try:
1384
+ while True:
1385
+ chunk = stdout.readline()
1386
+ if not chunk:
1387
+ break
1388
+ redacted = _redact_secret_log_line(_decode_subprocess_stdout_line(chunk))
1389
+ logger.info(f"[{label}] {redacted}")
1390
+ finally:
1391
+ stdout.close()
1392
+ return process.wait()
1393
+
1394
+
726
1395
  def _without_tavern_marker_filter(args):
727
1396
  marker_options = {"-m", "--markexpr"}
728
1397
  fallback_args = []
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pybuilder-integration
3
- Version: 107
3
+ Version: 109
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 = '107',
24
+ version = '109',
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,