ciphergate-cli 0.7.0__py3-none-any.whl

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 (74) hide show
  1. ciphergate/__init__.py +95 -0
  2. ciphergate/__main__.py +6 -0
  3. ciphergate/cli.py +832 -0
  4. ciphergate/config.py +50 -0
  5. ciphergate/constants.py +274 -0
  6. ciphergate/detectors/__init__.py +5 -0
  7. ciphergate/detectors/base.py +53 -0
  8. ciphergate/detectors/custom_regex.py +37 -0
  9. ciphergate/detectors/dates.py +92 -0
  10. ciphergate/detectors/email.py +56 -0
  11. ciphergate/detectors/field_names.py +220 -0
  12. ciphergate/detectors/ip_address.py +85 -0
  13. ciphergate/detectors/network.py +42 -0
  14. ciphergate/detectors/payment_card.py +77 -0
  15. ciphergate/detectors/phone.py +67 -0
  16. ciphergate/detectors/presidio_ner.py +112 -0
  17. ciphergate/detectors/regex_safety.py +40 -0
  18. ciphergate/detectors/registry.py +194 -0
  19. ciphergate/detectors/secrets.py +130 -0
  20. ciphergate/errors.py +119 -0
  21. ciphergate/models/__init__.py +1 -0
  22. ciphergate/models/context.py +69 -0
  23. ciphergate/models/finding.py +108 -0
  24. ciphergate/models/policy.py +402 -0
  25. ciphergate/models/report.py +115 -0
  26. ciphergate/pipeline/__init__.py +1 -0
  27. ciphergate/pipeline/atomic_output.py +143 -0
  28. ciphergate/pipeline/batch.py +533 -0
  29. ciphergate/pipeline/inspector.py +282 -0
  30. ciphergate/pipeline/paths.py +98 -0
  31. ciphergate/pipeline/planner.py +192 -0
  32. ciphergate/pipeline/rescan.py +120 -0
  33. ciphergate/pipeline/sanitizer.py +392 -0
  34. ciphergate/pipeline/stdio.py +173 -0
  35. ciphergate/pipeline/streams.py +152 -0
  36. ciphergate/policy/__init__.py +26 -0
  37. ciphergate/policy/context.py +203 -0
  38. ciphergate/policy/engine.py +194 -0
  39. ciphergate/policy/loader.py +116 -0
  40. ciphergate/policy/precedence.py +149 -0
  41. ciphergate/policy/validator.py +632 -0
  42. ciphergate/py.typed +0 -0
  43. ciphergate/readers/__init__.py +113 -0
  44. ciphergate/readers/accesslog_reader.py +244 -0
  45. ciphergate/readers/base.py +131 -0
  46. ciphergate/readers/csv_reader.py +241 -0
  47. ciphergate/readers/json_reader.py +184 -0
  48. ciphergate/readers/jsonl_reader.py +97 -0
  49. ciphergate/readers/jsonlog_reader.py +178 -0
  50. ciphergate/readers/line_structure.py +125 -0
  51. ciphergate/readers/logfmt_reader.py +100 -0
  52. ciphergate/readers/stacktrace_reader.py +103 -0
  53. ciphergate/readers/syslog_reader.py +309 -0
  54. ciphergate/readers/text_reader.py +59 -0
  55. ciphergate/reporting/__init__.py +1 -0
  56. ciphergate/reporting/audit.py +85 -0
  57. ciphergate/reporting/safe_text.py +71 -0
  58. ciphergate/reporting/serializer.py +44 -0
  59. ciphergate/reporting/terminal.py +147 -0
  60. ciphergate/templates/starter_policy.yml +77 -0
  61. ciphergate/transforms/__init__.py +6 -0
  62. ciphergate/transforms/date_shift.py +79 -0
  63. ciphergate/transforms/generalize.py +107 -0
  64. ciphergate/transforms/keys.py +177 -0
  65. ciphergate/transforms/pseudonymize.py +113 -0
  66. ciphergate/transforms/redact.py +9 -0
  67. ciphergate/transforms/registry.py +201 -0
  68. ciphergate/transforms/replace.py +16 -0
  69. ciphergate_cli-0.7.0.dist-info/METADATA +531 -0
  70. ciphergate_cli-0.7.0.dist-info/RECORD +74 -0
  71. ciphergate_cli-0.7.0.dist-info/WHEEL +4 -0
  72. ciphergate_cli-0.7.0.dist-info/entry_points.txt +2 -0
  73. ciphergate_cli-0.7.0.dist-info/licenses/LICENSE +202 -0
  74. ciphergate_cli-0.7.0.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,533 @@
1
+ """Directory (batch) processing: one run over every supported file below a directory.
2
+
3
+ Safety contract:
4
+
5
+ * Discovery never follows symlinks, junctions or other reparse points, and never reads special
6
+ files (devices, FIFOs, sockets); each skipped entry is reported with a reason. Subdirectories
7
+ are entered only with ``recursive``; nesting is bounded by ``MAX_BATCH_DEPTH`` and the number
8
+ of files by ``RuntimeSettings.max_batch_files`` (exceeding either fails the whole run).
9
+ * Files are processed in sorted relative-path order, each exactly as the single-file command
10
+ would process it. A failing file does not stop the others, so the report is complete.
11
+ * ``sanitize`` builds the whole output tree in a private staging directory next to the output
12
+ directory and publishes it with one rename only when **every** file was sanitized. Otherwise
13
+ the staging tree is deleted and no output directory appears. The output directory must not
14
+ exist yet and must not be inside the input directory.
15
+ * The batch exit code is that of the first failing file in processing order (0 if none).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import contextlib
21
+ import logging
22
+ import os
23
+ import shutil
24
+ import stat
25
+ import sys
26
+ import tempfile
27
+ from collections.abc import Callable
28
+ from dataclasses import dataclass, field
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ from ciphergate import __version__
33
+ from ciphergate.config import RuntimeSettings
34
+ from ciphergate.constants import DISCLAIMER, ExitCode, InputFormat
35
+ from ciphergate.errors import (
36
+ CipherGateError,
37
+ InputFormatError,
38
+ InputLimitError,
39
+ OutputWriteError,
40
+ UnsafePathError,
41
+ UsageError,
42
+ )
43
+ from ciphergate.models.context import ContextInput
44
+ from ciphergate.models.report import RunReport
45
+ from ciphergate.pipeline.sanitizer import (
46
+ SanitizeOptions,
47
+ inspect_file,
48
+ plan_file,
49
+ rescan_file,
50
+ sanitize_file,
51
+ )
52
+ from ciphergate.policy.validator import Policy
53
+ from ciphergate.readers import EXTENSION_FORMATS
54
+ from ciphergate.reporting.audit import new_run_id, utc_now
55
+
56
+ logger = logging.getLogger("ciphergate")
57
+
58
+ BATCH_REPORT_SCHEMA = "ciphergate.batch-report/v1"
59
+ MAX_BATCH_DEPTH = 32
60
+ _REPARSE_POINT = 0x400 # FILE_ATTRIBUTE_REPARSE_POINT (symlinks, junctions, mount points)
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class Discovery:
65
+ root: Path
66
+ files: list[Path] # relative to root, sorted
67
+ skipped: list[tuple[Path, str]] # (relative path, reason code)
68
+
69
+
70
+ @dataclass
71
+ class FileResult:
72
+ relative: Path
73
+ display: str
74
+ exit_code: int
75
+ report: RunReport | None = None
76
+ error_code: str | None = None
77
+ error_message: str | None = None
78
+
79
+ def to_dict(self) -> dict[str, Any]:
80
+ entry: dict[str, Any] = {"path": self.display, "exit_code": self.exit_code}
81
+ if self.report is not None:
82
+ entry["status"] = str(self.report.status)
83
+ entry["report"] = self.report.to_dict()
84
+ else:
85
+ entry["status"] = "error"
86
+ entry["error"] = {"code": self.error_code, "message": self.error_message}
87
+ return entry
88
+
89
+
90
+ @dataclass
91
+ class BatchReport:
92
+ command: str
93
+ input_display: str
94
+ recursive: bool
95
+ files: list[FileResult]
96
+ skipped: list[dict[str, str]]
97
+ output: dict[str, Any] = field(default_factory=dict)
98
+ warnings: list[str] = field(default_factory=list)
99
+ run_id: str = field(default_factory=new_run_id)
100
+ timestamp: str = field(default_factory=utc_now)
101
+
102
+ @property
103
+ def exit_code(self) -> int:
104
+ for result in self.files:
105
+ if result.exit_code != 0:
106
+ return result.exit_code
107
+ return 0
108
+
109
+ @property
110
+ def status(self) -> str:
111
+ return "completed" if self.exit_code == 0 else "failed"
112
+
113
+ def summary(self) -> dict[str, Any]:
114
+ by_status: dict[str, int] = {}
115
+ findings = blocked = review = 0
116
+ for result in self.files:
117
+ key = str(result.report.status) if result.report is not None else "error"
118
+ by_status[key] = by_status.get(key, 0) + 1
119
+ if result.report is not None:
120
+ findings += int(result.report.summary.get("findings_total", 0))
121
+ blocked += int(result.report.summary.get("blocked", 0))
122
+ review += int(result.report.summary.get("review_required", 0))
123
+ return {
124
+ "files_total": len(self.files),
125
+ "files_ok": sum(1 for r in self.files if r.exit_code == 0),
126
+ "files_failed": sum(1 for r in self.files if r.exit_code != 0),
127
+ "files_skipped": len(self.skipped),
128
+ "by_status": dict(sorted(by_status.items())),
129
+ "findings_total": findings,
130
+ "blocked": blocked,
131
+ "review_required": review,
132
+ }
133
+
134
+ def to_dict(self) -> dict[str, Any]:
135
+ first = next((r.report for r in self.files if r.report is not None), None)
136
+ return {
137
+ "schema": BATCH_REPORT_SCHEMA,
138
+ "command": self.command,
139
+ "status": self.status,
140
+ "exit_code": self.exit_code,
141
+ "run_id": self.run_id,
142
+ "timestamp": self.timestamp,
143
+ "ciphergate_version": __version__,
144
+ "input": {"path": self.input_display, "recursive": self.recursive},
145
+ "policy": first.policy if first is not None else None,
146
+ "context": first.context if first is not None else None,
147
+ "summary": self.summary(),
148
+ "files": [r.to_dict() for r in self.files],
149
+ "skipped": self.skipped,
150
+ "output": self.output,
151
+ "warnings": self.warnings,
152
+ "disclaimer": DISCLAIMER,
153
+ }
154
+
155
+
156
+ # --------------------------------------------------------------------------- discovery
157
+
158
+
159
+ def _is_link(entry: os.DirEntry[str]) -> bool:
160
+ if entry.is_symlink():
161
+ return True
162
+ is_junction = getattr(entry, "is_junction", None)
163
+ if is_junction is not None and is_junction():
164
+ return True
165
+ if sys.platform == "win32":
166
+ attrs = getattr(entry.stat(follow_symlinks=False), "st_file_attributes", 0)
167
+ return bool(attrs & _REPARSE_POINT)
168
+ return False
169
+
170
+
171
+ def discover(root: Path, *, recursive: bool, fmt: InputFormat | None, max_files: int) -> Discovery:
172
+ """List the files a batch run will process. Raises on limits; never follows links."""
173
+ try:
174
+ st = os.lstat(root)
175
+ except OSError:
176
+ raise UnsafePathError(
177
+ "input directory cannot be accessed", code="input_unreadable"
178
+ ) from None
179
+ if stat.S_ISLNK(st.st_mode) or not stat.S_ISDIR(st.st_mode):
180
+ raise UnsafePathError(
181
+ "batch input must be a real directory, not a symlink", code="input_not_directory"
182
+ )
183
+ files: list[Path] = []
184
+ skipped: list[tuple[Path, str]] = []
185
+
186
+ def walk(rel_dir: Path, depth: int) -> None:
187
+ try:
188
+ with os.scandir(root / rel_dir) as it:
189
+ entries = sorted(it, key=lambda e: e.name)
190
+ except OSError:
191
+ skipped.append((rel_dir, "directory_unreadable"))
192
+ return
193
+ for entry in entries:
194
+ rel = rel_dir / entry.name
195
+ try:
196
+ if _is_link(entry):
197
+ skipped.append((rel, "symlink_not_followed"))
198
+ elif entry.is_dir(follow_symlinks=False):
199
+ if not recursive:
200
+ skipped.append((rel, "directory_not_recursed"))
201
+ elif depth >= MAX_BATCH_DEPTH:
202
+ raise InputLimitError(
203
+ f"directory nesting exceeds {MAX_BATCH_DEPTH} levels",
204
+ code="batch_depth_exceeded",
205
+ )
206
+ else:
207
+ walk(rel, depth + 1)
208
+ elif not entry.is_file(follow_symlinks=False):
209
+ skipped.append((rel, "not_regular_file"))
210
+ elif fmt is None and rel.suffix.lower() not in EXTENSION_FORMATS:
211
+ skipped.append((rel, "unsupported_extension"))
212
+ else:
213
+ files.append(rel)
214
+ if len(files) > max_files:
215
+ raise InputLimitError(
216
+ f"directory has more than max_batch_files ({max_files}) files",
217
+ code="batch_file_limit_exceeded",
218
+ )
219
+ except OSError:
220
+ skipped.append((rel, "entry_unreadable"))
221
+
222
+ walk(Path(), 0)
223
+ return Discovery(root, sorted(files, key=lambda p: p.as_posix()), skipped)
224
+
225
+
226
+ def display_relative(rel: Path, index: int, root: Path, mode: str) -> str:
227
+ if mode == "redacted":
228
+ return f"<redacted:{index:04d}>{rel.suffix}"
229
+ if mode == "full":
230
+ return str(root / rel)
231
+ return rel.as_posix()
232
+
233
+
234
+ def _skipped(discovery: Discovery, mode: str) -> list[dict[str, str]]:
235
+ return [
236
+ {"path": display_relative(rel, i, discovery.root, mode), "reason": reason}
237
+ for i, (rel, reason) in enumerate(discovery.skipped, start=1)
238
+ ]
239
+
240
+
241
+ def _still_regular(path: Path) -> bool:
242
+ """Re-check just before use: the entry must still be a regular file, not a link."""
243
+ try:
244
+ st = os.lstat(path)
245
+ except OSError:
246
+ return False
247
+ return stat.S_ISREG(st.st_mode) and not getattr(st, "st_file_attributes", 0) & _REPARSE_POINT
248
+
249
+
250
+ def _run_each(
251
+ discovery: Discovery,
252
+ mode: str,
253
+ run: Callable[[Path, Path], RunReport],
254
+ ) -> list[FileResult]:
255
+ results: list[FileResult] = []
256
+ for index, rel in enumerate(discovery.files, start=1):
257
+ shown = display_relative(rel, index, discovery.root, mode)
258
+ path = discovery.root / rel
259
+ if not _still_regular(path):
260
+ results.append(
261
+ FileResult(
262
+ rel,
263
+ shown,
264
+ int(ExitCode.OUTPUT_ERROR),
265
+ error_code="input_changed_type",
266
+ error_message="entry is no longer a regular file; not processed",
267
+ )
268
+ )
269
+ continue
270
+ try:
271
+ report = run(path, rel)
272
+ except CipherGateError as exc:
273
+ results.append(FileResult(rel, shown, int(exc.exit_code), None, exc.code, exc.message))
274
+ continue
275
+ report.input["path"] = shown
276
+ results.append(FileResult(rel, shown, report.exit_code, report))
277
+ return results
278
+
279
+
280
+ def _require_files(discovery: Discovery) -> None:
281
+ if not discovery.files:
282
+ raise InputFormatError(
283
+ "no supported input files found in the directory (see --format, --recursive)",
284
+ code="batch_empty",
285
+ )
286
+
287
+
288
+ # --------------------------------------------------------------------------- operations
289
+
290
+
291
+ def analyze_directory(
292
+ command: str,
293
+ root: str | Path,
294
+ policy: Policy,
295
+ context: ContextInput | None,
296
+ *,
297
+ recursive: bool,
298
+ runtime: RuntimeSettings,
299
+ fmt: InputFormat | None = None,
300
+ strict: bool | None = None,
301
+ ) -> BatchReport:
302
+ """Batch ``inspect`` / ``plan`` / ``rescan``. Read-only, like the single-file commands."""
303
+ root = Path(root)
304
+ discovery = discover(root, recursive=recursive, fmt=fmt, max_files=runtime.max_batch_files)
305
+ _require_files(discovery)
306
+
307
+ def run(path: Path, rel: Path) -> RunReport:
308
+ if command == "inspect":
309
+ return inspect_file(path, policy, context, runtime=runtime, fmt=fmt)
310
+ if command == "plan":
311
+ return plan_file(path, policy, context, runtime=runtime, fmt=fmt)
312
+ return rescan_file(path, policy, context, strict=strict, runtime=runtime, fmt=fmt)
313
+
314
+ mode = runtime.path_display
315
+ return BatchReport(
316
+ command=command,
317
+ input_display=display_relative(Path(root.name), 0, root.parent, mode)
318
+ if mode != "redacted"
319
+ else "<redacted>",
320
+ recursive=recursive,
321
+ files=_run_each(discovery, mode, run),
322
+ skipped=_skipped(discovery, mode),
323
+ )
324
+
325
+
326
+ def inspect_directory(
327
+ root: str | Path,
328
+ policy: Policy,
329
+ context: ContextInput | None = None,
330
+ *,
331
+ recursive: bool = False,
332
+ runtime: RuntimeSettings | None = None,
333
+ fmt: InputFormat | None = None,
334
+ ) -> BatchReport:
335
+ """Inspect every supported file below ``root``. Never writes any data file."""
336
+ return analyze_directory(
337
+ "inspect", root, policy, context, recursive=recursive,
338
+ runtime=runtime or RuntimeSettings(), fmt=fmt,
339
+ ) # fmt: skip
340
+
341
+
342
+ def plan_directory(
343
+ root: str | Path,
344
+ policy: Policy,
345
+ context: ContextInput | None = None,
346
+ *,
347
+ recursive: bool = False,
348
+ runtime: RuntimeSettings | None = None,
349
+ fmt: InputFormat | None = None,
350
+ ) -> BatchReport:
351
+ """Preview sanitize decisions for every supported file below ``root``."""
352
+ return analyze_directory(
353
+ "plan", root, policy, context, recursive=recursive,
354
+ runtime=runtime or RuntimeSettings(), fmt=fmt,
355
+ ) # fmt: skip
356
+
357
+
358
+ def rescan_directory(
359
+ root: str | Path,
360
+ policy: Policy,
361
+ context: ContextInput | None = None,
362
+ *,
363
+ recursive: bool = False,
364
+ strict: bool | None = None,
365
+ runtime: RuntimeSettings | None = None,
366
+ fmt: InputFormat | None = None,
367
+ ) -> BatchReport:
368
+ """Report residual findings in every supported file below ``root``. Read-only."""
369
+ return analyze_directory(
370
+ "rescan", root, policy, context, recursive=recursive,
371
+ runtime=runtime or RuntimeSettings(), fmt=fmt, strict=strict,
372
+ ) # fmt: skip
373
+
374
+
375
+ def is_inside(path: Path, directory: Path) -> bool:
376
+ """True if ``path`` resolves to ``directory`` or somewhere below it.
377
+
378
+ Compares file identity, not just spelling: on case-insensitive filesystems (macOS APFS,
379
+ Windows) ``IN/out`` is inside ``in`` even though ``normcase`` is a no-op on POSIX.
380
+ """
381
+ real_dir = os.path.normcase(os.path.realpath(directory))
382
+ real = os.path.normcase(os.path.realpath(path))
383
+ if real == real_dir or real.startswith(real_dir.rstrip(os.sep) + os.sep):
384
+ return True
385
+ try:
386
+ dir_stat = os.stat(directory)
387
+ except OSError:
388
+ return False
389
+ current = Path(os.path.realpath(path))
390
+ for candidate in (current, *current.parents):
391
+ try:
392
+ if os.path.samestat(os.stat(candidate), dir_stat):
393
+ return True
394
+ except OSError:
395
+ continue # not created yet
396
+ return False
397
+
398
+
399
+ def validate_output_directory(root: Path, output: Path) -> None:
400
+ if os.path.lexists(output):
401
+ raise OutputWriteError(
402
+ "output directory already exists; choose a new path (--force does not replace "
403
+ "directories)",
404
+ code="output_exists",
405
+ )
406
+ if is_inside(output, root):
407
+ raise UnsafePathError(
408
+ "output directory must not be inside the input directory",
409
+ code="output_inside_input",
410
+ )
411
+ parent = output.parent if str(output.parent) else Path(".")
412
+ if parent.exists() and not parent.is_dir():
413
+ raise UnsafePathError("output parent is not a directory", code="output_parent_invalid")
414
+
415
+
416
+ def sanitize_directory(
417
+ root: str | Path,
418
+ output: str | Path,
419
+ policy: Policy,
420
+ context: ContextInput | None = None,
421
+ options: SanitizeOptions | None = None,
422
+ *,
423
+ recursive: bool = False,
424
+ ) -> BatchReport:
425
+ """Sanitize every file into a new OUTPUT directory, published only if all files pass."""
426
+ root, output = Path(root), Path(output)
427
+ options = options or SanitizeOptions()
428
+ if options.force:
429
+ raise UsageError(
430
+ "force cannot replace an output directory; remove it or choose a new path",
431
+ code="force_unsupported_for_directory",
432
+ )
433
+ runtime = options.runtime or RuntimeSettings()
434
+ validate_output_directory(root, output)
435
+ discovery = discover(
436
+ root, recursive=recursive, fmt=options.format, max_files=runtime.max_batch_files
437
+ )
438
+ _require_files(discovery)
439
+ mode = runtime.path_display
440
+ shown_root = (
441
+ "<redacted>"
442
+ if mode == "redacted"
443
+ else display_relative(Path(root.name), 0, root.parent, mode)
444
+ )
445
+ shown_out = (
446
+ "<redacted>"
447
+ if mode == "redacted"
448
+ else display_relative(Path(output.name), 0, output.parent, mode)
449
+ )
450
+ file_options = SanitizeOptions(
451
+ apply=options.apply,
452
+ force=False,
453
+ keys=options.keys,
454
+ format=options.format,
455
+ runtime=runtime,
456
+ )
457
+
458
+ if not options.apply:
459
+ # Dry run: plan every file; the output paths are never created.
460
+ results = _run_each(
461
+ discovery,
462
+ mode,
463
+ lambda path, rel: sanitize_file(path, output / rel, policy, context, file_options),
464
+ )
465
+ for result in results:
466
+ if result.report is not None:
467
+ result.report.output["path"] = f"{shown_out}/{result.display}"
468
+ return BatchReport(
469
+ "sanitize",
470
+ shown_root,
471
+ recursive,
472
+ results,
473
+ _skipped(discovery, mode),
474
+ output={"written": False, "path": shown_out, "withheld_reason": "apply_not_set"},
475
+ )
476
+
477
+ parent = output.parent if str(output.parent) else Path(".")
478
+ try:
479
+ parent.mkdir(parents=True, exist_ok=True, mode=0o700)
480
+ staging = Path(tempfile.mkdtemp(prefix=".ciphergate-batch-", dir=parent))
481
+ except OSError:
482
+ raise OutputWriteError(
483
+ "cannot create a staging directory next to the output", code="output_temp_failed"
484
+ ) from None
485
+ published = False
486
+ try:
487
+ with contextlib.suppress(OSError):
488
+ os.chmod(staging, 0o700)
489
+ results = _run_each(
490
+ discovery,
491
+ mode,
492
+ lambda path, rel: sanitize_file(path, staging / rel, policy, context, file_options),
493
+ )
494
+ report = BatchReport("sanitize", shown_root, recursive, results, _skipped(discovery, mode))
495
+ for result in results:
496
+ if result.report is not None:
497
+ result.report.output["path"] = f"{shown_out}/{result.display}"
498
+ if result.report.output.get("written"):
499
+ result.report.output["written"] = False
500
+ result.report.output["staged"] = True
501
+ if report.exit_code != 0:
502
+ report.output = {
503
+ "written": False,
504
+ "path": shown_out,
505
+ "withheld_reason": "file_failed",
506
+ }
507
+ return report
508
+ if os.path.lexists(output):
509
+ raise OutputWriteError(
510
+ "output directory appeared during processing; nothing was published",
511
+ code="output_exists",
512
+ )
513
+ try:
514
+ os.rename(staging, output)
515
+ except OSError:
516
+ raise OutputWriteError(
517
+ "cannot publish the output directory", code="output_publish_failed"
518
+ ) from None
519
+ published = True
520
+ for result in results:
521
+ if result.report is not None:
522
+ result.report.output.pop("staged", None)
523
+ result.report.output["written"] = True
524
+ report.output = {"written": True, "path": shown_out}
525
+ return report
526
+ finally:
527
+ if not published:
528
+ shutil.rmtree(staging, ignore_errors=True)
529
+ if staging.exists():
530
+ logger.warning(
531
+ "could not remove the staging directory %s; delete it manually",
532
+ staging.name,
533
+ )