ithrl 0.1.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 (108) hide show
  1. ithrl/__init__.py +1 -0
  2. ithrl/__main__.py +6 -0
  3. ithrl/adapters/__init__.py +49 -0
  4. ithrl/adapters/_jsonl.py +266 -0
  5. ithrl/adapters/codex/__init__.py +5 -0
  6. ithrl/adapters/codex/context.py +204 -0
  7. ithrl/adapters/codex/decisions.py +224 -0
  8. ithrl/adapters/codex/discovery.py +60 -0
  9. ithrl/adapters/codex/parser.py +191 -0
  10. ithrl/adapters/contracts.py +106 -0
  11. ithrl/adapters/copilot/__init__.py +5 -0
  12. ithrl/adapters/copilot/context.py +182 -0
  13. ithrl/adapters/copilot/decisions.py +294 -0
  14. ithrl/adapters/copilot/discovery.py +28 -0
  15. ithrl/adapters/copilot/parser.py +283 -0
  16. ithrl/adapters/discovery.py +220 -0
  17. ithrl/adapters/errors.py +52 -0
  18. ithrl/adapters/registry.py +433 -0
  19. ithrl/behavior.py +138 -0
  20. ithrl/calibration.py +434 -0
  21. ithrl/case/__init__.py +33 -0
  22. ithrl/case/contracts.py +88 -0
  23. ithrl/case/evidence.py +169 -0
  24. ithrl/case/filesystem.py +340 -0
  25. ithrl/case/source.py +208 -0
  26. ithrl/case/source_context.py +119 -0
  27. ithrl/case/source_storage.py +234 -0
  28. ithrl/case/storage.py +377 -0
  29. ithrl/cli/__init__.py +968 -0
  30. ithrl/cli/extract.py +478 -0
  31. ithrl/cli/native.py +484 -0
  32. ithrl/cli/output.py +786 -0
  33. ithrl/cli/paths.py +24 -0
  34. ithrl/cli/sampled.py +485 -0
  35. ithrl/cli/sampled_output.py +472 -0
  36. ithrl/cli/sampled_reduction.py +1036 -0
  37. ithrl/cli/sampled_session.py +342 -0
  38. ithrl/cli/shrink.py +523 -0
  39. ithrl/context.py +465 -0
  40. ithrl/decisions.py +271 -0
  41. ithrl/examples/synthetic-trace.json +46 -0
  42. ithrl/execution/__init__.py +68 -0
  43. ithrl/execution/budget.py +146 -0
  44. ithrl/execution/contracts.py +426 -0
  45. ithrl/execution/preflight.py +346 -0
  46. ithrl/experiment.py +453 -0
  47. ithrl/extraction.py +206 -0
  48. ithrl/measurement.py +315 -0
  49. ithrl/observations.py +1148 -0
  50. ithrl/path_safety.py +100 -0
  51. ithrl/reduce/__init__.py +53 -0
  52. ithrl/reduce/contracts.py +508 -0
  53. ithrl/reduce/engine.py +901 -0
  54. ithrl/reduce/search.py +178 -0
  55. ithrl/results/__init__.py +49 -0
  56. ithrl/results/models.py +294 -0
  57. ithrl/results/storage.py +485 -0
  58. ithrl/results/summary.py +420 -0
  59. ithrl/results/validation.py +611 -0
  60. ithrl/run_state.py +1135 -0
  61. ithrl/runner/__init__.py +44 -0
  62. ithrl/runner/codex/__init__.py +17 -0
  63. ithrl/runner/codex/capability.py +708 -0
  64. ithrl/runner/codex/configuration.py +529 -0
  65. ithrl/runner/codex/isolation.py +396 -0
  66. ithrl/runner/codex/materialize.py +358 -0
  67. ithrl/runner/codex/policy.py +67 -0
  68. ithrl/runner/codex/protocol.py +649 -0
  69. ithrl/runner/codex/protocol_contracts.py +171 -0
  70. ithrl/runner/codex/runner.py +533 -0
  71. ithrl/runner/codex/runtime_configuration.py +264 -0
  72. ithrl/runner/codex/schema.py +527 -0
  73. ithrl/runner/codex/transcript.py +130 -0
  74. ithrl/runner/codex/worker.py +175 -0
  75. ithrl/runner/contracts.py +419 -0
  76. ithrl/runner/copilot/__init__.py +18 -0
  77. ithrl/runner/copilot/capability.py +825 -0
  78. ithrl/runner/copilot/configuration.py +201 -0
  79. ithrl/runner/copilot/isolation.py +374 -0
  80. ithrl/runner/copilot/materialize.py +484 -0
  81. ithrl/runner/copilot/policy.py +110 -0
  82. ithrl/runner/copilot/protocol/__init__.py +18 -0
  83. ithrl/runner/copilot/protocol/contracts.py +74 -0
  84. ithrl/runner/copilot/protocol/decoding.py +93 -0
  85. ithrl/runner/copilot/protocol/parser.py +418 -0
  86. ithrl/runner/copilot/protocol/state.py +130 -0
  87. ithrl/runner/copilot/runner.py +425 -0
  88. ithrl/runner/copilot/worker.py +351 -0
  89. ithrl/runner/evidence.py +345 -0
  90. ithrl/runner/fake.py +107 -0
  91. ithrl/runner/registry.py +90 -0
  92. ithrl/runner/runtime/__init__.py +50 -0
  93. ithrl/runner/runtime/contracts.py +157 -0
  94. ithrl/runner/runtime/output.py +184 -0
  95. ithrl/runner/runtime/process.py +954 -0
  96. ithrl/runner/runtime/protection.py +562 -0
  97. ithrl/runner/runtime/state.py +282 -0
  98. ithrl/runner/validation.py +376 -0
  99. ithrl/sampling.py +412 -0
  100. ithrl/statistics.py +276 -0
  101. ithrl/synthetic.py +511 -0
  102. ithrl/timestamps.py +26 -0
  103. ithrl/workflow.py +570 -0
  104. ithrl-0.1.0.dist-info/METADATA +118 -0
  105. ithrl-0.1.0.dist-info/RECORD +108 -0
  106. ithrl-0.1.0.dist-info/WHEEL +4 -0
  107. ithrl-0.1.0.dist-info/entry_points.txt +2 -0
  108. ithrl-0.1.0.dist-info/licenses/LICENSE +201 -0
ithrl/case/evidence.py ADDED
@@ -0,0 +1,169 @@
1
+ """Loaded case evidence and deterministic identity."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ from collections.abc import Iterable
7
+ from typing import Any
8
+
9
+ from ithrl.case.contracts import CaseFormatError, CaseManifest
10
+ from ithrl.case.source import SourceArtifact, prepare_source_artifacts
11
+ from ithrl.case.source_context import validate_source_references
12
+ from ithrl.context import (
13
+ ContextGraph,
14
+ ContextNode,
15
+ JsonObject,
16
+ content_digest,
17
+ validate_json_limits,
18
+ )
19
+ from ithrl.decisions import DecisionEvidence
20
+
21
+
22
+ def case_digest(
23
+ manifest: CaseManifest,
24
+ request: JsonObject | None,
25
+ nodes: Iterable[ContextNode],
26
+ source_artifacts: Iterable[SourceArtifact] = (),
27
+ decision: DecisionEvidence | None = None,
28
+ ) -> str:
29
+ """Return a deterministic identity for immutable case evidence."""
30
+
31
+ sources = prepare_source_artifacts(source_artifacts)
32
+ payload: dict[str, Any] = {
33
+ "manifest": manifest.model_dump(mode="json", exclude_none=True),
34
+ "context": [node.model_dump(mode="json") for node in nodes],
35
+ }
36
+ if request is not None:
37
+ payload["request"] = request
38
+ if sources:
39
+ payload["source"] = [artifact.identity.model_dump(mode="json") for artifact in sources]
40
+ if decision is not None:
41
+ payload["decision"] = decision.model_dump(mode="json")
42
+ return content_digest(payload)
43
+
44
+
45
+ class CaseEvidence:
46
+ """Loaded immutable evidence with defensive request copies."""
47
+
48
+ __slots__ = (
49
+ "_context",
50
+ "_decision",
51
+ "_default_experiment",
52
+ "_digest",
53
+ "_manifest",
54
+ "_request",
55
+ "_source_artifacts",
56
+ )
57
+
58
+ def __init__(
59
+ self,
60
+ *,
61
+ manifest: CaseManifest,
62
+ request: JsonObject | None,
63
+ context: ContextGraph,
64
+ default_experiment: str,
65
+ source_artifacts: Iterable[SourceArtifact] = (),
66
+ decision: DecisionEvidence | None = None,
67
+ ) -> None:
68
+ sources = prepare_source_artifacts(source_artifacts)
69
+ validate_case_inputs(
70
+ manifest=manifest,
71
+ request=request,
72
+ context=context,
73
+ source_artifacts=sources,
74
+ decision=decision,
75
+ )
76
+ self._manifest = manifest
77
+ self._request = copy.deepcopy(request) if request is not None else None
78
+ self._context = context
79
+ self._decision = decision
80
+ self._default_experiment = default_experiment
81
+ self._source_artifacts = sources
82
+ self._digest = case_digest(manifest, request, context.nodes, sources, decision)
83
+
84
+ @property
85
+ def manifest(self) -> CaseManifest:
86
+ return self._manifest
87
+
88
+ @property
89
+ def request(self) -> JsonObject:
90
+ if self._request is None:
91
+ raise CaseFormatError("case does not contain an exact provider request")
92
+ return copy.deepcopy(self._request)
93
+
94
+ @property
95
+ def has_request(self) -> bool:
96
+ return self._request is not None
97
+
98
+ @property
99
+ def decision(self) -> DecisionEvidence | None:
100
+ return self._decision
101
+
102
+ @property
103
+ def context(self) -> ContextGraph:
104
+ return self._context
105
+
106
+ @property
107
+ def default_experiment(self) -> str:
108
+ return self._default_experiment
109
+
110
+ @property
111
+ def source_artifacts(self) -> tuple[SourceArtifact, ...]:
112
+ return self._source_artifacts
113
+
114
+ @property
115
+ def digest(self) -> str:
116
+ return self._digest
117
+
118
+ def materialize(self, remove_ids: Iterable[str] = ()) -> JsonObject:
119
+ return self._context.materialize(self.request, remove_ids)
120
+
121
+
122
+ def validate_case_inputs(
123
+ *,
124
+ manifest: CaseManifest,
125
+ request: JsonObject | None,
126
+ context: ContextGraph,
127
+ source_artifacts: tuple[SourceArtifact, ...],
128
+ decision: DecisionEvidence | None,
129
+ ) -> None:
130
+ """Validate relationships between the immutable case inputs."""
131
+
132
+ _validate_capture_shape(manifest, request, decision)
133
+ if request is not None:
134
+ validate_json_limits(request)
135
+ context.validate_request(request)
136
+ elif any(node.path is not None for node in context.nodes):
137
+ raise CaseFormatError("request-backed context requires request.json")
138
+ if request is None and decision is None:
139
+ raise CaseFormatError("case evidence requires request.json or decision.json")
140
+ validate_source_references(context, decision, source_artifacts)
141
+
142
+
143
+ def _validate_capture_shape(
144
+ manifest: CaseManifest,
145
+ request: JsonObject | None,
146
+ decision: DecisionEvidence | None,
147
+ ) -> None:
148
+ capture_type = manifest.capture.request
149
+ if (request is not None) != (capture_type == "provider-request"):
150
+ raise CaseFormatError("capture classification does not match request.json")
151
+ if capture_type == "logical-session":
152
+ if decision is None:
153
+ raise CaseFormatError("logical-session capture requires decision.json")
154
+ if not all((manifest.harness, manifest.harness_version, manifest.source_schema)):
155
+ raise CaseFormatError("logical-session capture requires harness source metadata")
156
+ if manifest.source_schema != manifest.provenance.trace_schema:
157
+ raise CaseFormatError("logical-session source schema does not match provenance")
158
+ if manifest.capture.completeness != decision.completeness:
159
+ raise CaseFormatError("logical-session completeness does not match decision evidence")
160
+ if manifest.provenance.inference_call_id is not None:
161
+ raise CaseFormatError(
162
+ "logical-session capture cannot identify a provider inference call"
163
+ )
164
+ if decision is None:
165
+ return
166
+ if decision.selector.harness != manifest.harness:
167
+ raise CaseFormatError("decision harness does not match case metadata")
168
+ if decision.selector.source != manifest.provenance.source:
169
+ raise CaseFormatError("decision source does not match case provenance")
@@ -0,0 +1,340 @@
1
+ """Safe filesystem primitives for private case workspaces."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import os
7
+ import stat
8
+ import tempfile
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ from ithrl.case.contracts import (
13
+ CaseError,
14
+ CaseExistsError,
15
+ CaseFormatError,
16
+ UnsafeCasePathError,
17
+ )
18
+ from ithrl.path_safety import UnsafePathError, validate_directory_chain
19
+
20
+ type FileSignature = tuple[int, int, int, int, int]
21
+ type FileIdentity = tuple[int, int]
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class PublishedFile:
26
+ """Identity of a case file created during one publication operation."""
27
+
28
+ path: Path
29
+ signature: FileSignature
30
+
31
+
32
+ def validate_target_path(path: Path) -> Path:
33
+ _validate_new_target(path)
34
+ parent = path.parent if path.parent != Path() else Path()
35
+ _validate_directory_control(parent)
36
+ reject_symlink_components(parent)
37
+ if not parent.is_dir():
38
+ raise UnsafeCasePathError(f"case parent directory does not exist: {parent}")
39
+ return path
40
+
41
+
42
+ def prepare_target_path(path: Path) -> Path:
43
+ """Create missing private parents after validating a new case path."""
44
+
45
+ _validate_new_target(path)
46
+ parent = path.parent if path.parent != Path() else Path()
47
+ _validate_directory_control(parent)
48
+ reject_symlink_components(parent)
49
+ _create_missing_private_directories(parent)
50
+ return validate_target_path(path)
51
+
52
+
53
+ def prepare_private_directory(path: Path) -> Path:
54
+ """Create a private directory and any missing private parents."""
55
+
56
+ _validate_directory_control(path)
57
+ reject_symlink_components(path)
58
+ _create_missing_private_directories(path)
59
+ if not path.is_dir():
60
+ raise UnsafeCasePathError("private data path is not a directory")
61
+ set_private_mode(path, 0o700)
62
+ _validate_directory_control(path)
63
+ return path
64
+
65
+
66
+ def _create_missing_private_directories(path: Path) -> None:
67
+ missing: list[Path] = []
68
+ current = path
69
+ while not current.exists():
70
+ if os.path.lexists(current):
71
+ raise UnsafeCasePathError(f"case parent is unavailable: {current}")
72
+ missing.append(current)
73
+ ancestor = current.parent
74
+ if ancestor == current:
75
+ raise UnsafeCasePathError(f"case parent directory does not exist: {path}")
76
+ current = ancestor
77
+ if not current.is_dir():
78
+ raise UnsafeCasePathError(f"case parent directory does not exist: {current}")
79
+ for directory in reversed(missing):
80
+ create_private_directory(directory)
81
+
82
+
83
+ def _validate_new_target(path: Path) -> None:
84
+ if ".." in path.parts:
85
+ raise UnsafeCasePathError("case path cannot contain '..'")
86
+ if os.path.lexists(path):
87
+ raise CaseExistsError(f"case path already exists: {path}")
88
+
89
+
90
+ def validate_existing_root(path: Path) -> Path:
91
+ if ".." in path.parts:
92
+ raise UnsafeCasePathError("case path cannot contain '..'")
93
+ _validate_directory_control(path)
94
+ reject_symlink_components(path)
95
+ try:
96
+ metadata = path.stat()
97
+ except OSError as error:
98
+ raise UnsafeCasePathError(f"case directory is unavailable: {path}") from error
99
+ if not stat.S_ISDIR(metadata.st_mode):
100
+ raise UnsafeCasePathError(f"case path is not a directory: {path}")
101
+ if os.name == "posix" and stat.S_IMODE(metadata.st_mode) != 0o700:
102
+ raise UnsafeCasePathError("case directory must have mode 0700")
103
+ return path
104
+
105
+
106
+ def reject_symlink_components(path: Path) -> None:
107
+ current = Path(path.anchor) if path.is_absolute() else Path()
108
+ parts = path.parts[1:] if path.is_absolute() else path.parts
109
+ for part in parts:
110
+ current /= part
111
+ if os.path.lexists(current) and current.is_symlink():
112
+ raise UnsafeCasePathError(f"case path contains a symlink: {current}")
113
+
114
+
115
+ def _validate_directory_control(path: Path) -> None:
116
+ try:
117
+ validate_directory_chain(path)
118
+ except UnsafePathError as error:
119
+ raise UnsafeCasePathError(str(error)) from error
120
+
121
+
122
+ def create_private_directory(path: Path) -> Path:
123
+ try:
124
+ path.mkdir(mode=0o700)
125
+ except FileExistsError as error:
126
+ raise CaseExistsError("refusing to reuse a case directory") from error
127
+ except OSError as error:
128
+ raise CaseError("cannot create a case directory") from error
129
+ set_private_mode(path, 0o700)
130
+ return path
131
+
132
+
133
+ def require_private_directory(path: Path, *, label: str) -> None:
134
+ reject_symlink_components(path.parent)
135
+ try:
136
+ metadata = path.lstat()
137
+ except OSError as error:
138
+ raise CaseFormatError(f"required case directory is unavailable: {label}") from error
139
+ if stat.S_ISLNK(metadata.st_mode):
140
+ raise UnsafeCasePathError(f"case entry must not be a symbolic link: {label}")
141
+ if not stat.S_ISDIR(metadata.st_mode):
142
+ raise UnsafeCasePathError(f"case entry must be a directory: {label}")
143
+ if os.name == "posix" and stat.S_IMODE(metadata.st_mode) != 0o700:
144
+ raise UnsafeCasePathError(f"case directory must have mode 0700: {label}")
145
+
146
+
147
+ def read_safe_text(path: Path, *, max_bytes: int, label: str | None = None) -> str:
148
+ file_label = label or path.name
149
+ content = read_safe_bytes(path, max_bytes=max_bytes, label=file_label)
150
+ try:
151
+ return content.decode("utf-8")
152
+ except UnicodeError as error:
153
+ raise CaseFormatError(f"cannot read case file: {file_label}") from error
154
+
155
+
156
+ def read_safe_bytes(path: Path, *, max_bytes: int, label: str) -> bytes:
157
+ initial = _validate_file_before_open(path, max_bytes=max_bytes, label=label)
158
+ descriptor = _open_read_only(path, label=label)
159
+ try:
160
+ opened = os.fstat(descriptor)
161
+ _validate_opened_file(initial, opened, label=label)
162
+ read_descriptor = descriptor
163
+ descriptor = -1
164
+ content, finished = _read_descriptor(
165
+ read_descriptor,
166
+ expected_size=opened.st_size,
167
+ max_bytes=max_bytes,
168
+ label=label,
169
+ )
170
+ finally:
171
+ if descriptor >= 0:
172
+ os.close(descriptor)
173
+ if len(content) > max_bytes:
174
+ raise CaseFormatError(f"{label} exceeds the {max_bytes}-byte limit")
175
+ current = lstat_regular_file(path, label=label)
176
+ if file_signature(opened) != file_signature(finished) or file_signature(
177
+ opened
178
+ ) != file_signature(current):
179
+ raise UnsafeCasePathError(f"case file changed while reading: {label}")
180
+ return content
181
+
182
+
183
+ def _validate_file_before_open(path: Path, *, max_bytes: int, label: str) -> os.stat_result:
184
+ reject_symlink_components(path.parent)
185
+ metadata = lstat_regular_file(path, label=label)
186
+ if metadata.st_size > max_bytes:
187
+ raise CaseFormatError(f"{label} exceeds the {max_bytes}-byte limit")
188
+ return metadata
189
+
190
+
191
+ def _open_read_only(path: Path, *, label: str) -> int:
192
+ flags = os.O_RDONLY
193
+ if hasattr(os, "O_CLOEXEC"):
194
+ flags |= os.O_CLOEXEC
195
+ if hasattr(os, "O_BINARY"):
196
+ flags |= os.O_BINARY
197
+ if hasattr(os, "O_NOFOLLOW"):
198
+ flags |= os.O_NOFOLLOW
199
+ if hasattr(os, "O_NONBLOCK"):
200
+ flags |= os.O_NONBLOCK
201
+ try:
202
+ return os.open(path, flags)
203
+ except OSError as error:
204
+ raise CaseFormatError(f"cannot read case file: {label}") from error
205
+
206
+
207
+ def _validate_opened_file(expected: os.stat_result, opened: os.stat_result, *, label: str) -> None:
208
+ if not stat.S_ISREG(opened.st_mode):
209
+ raise UnsafeCasePathError(f"case file changed while opening: {label}")
210
+ if file_signature(expected) != file_signature(opened):
211
+ raise UnsafeCasePathError(f"case file changed while opening: {label}")
212
+ if os.name == "posix" and stat.S_IMODE(opened.st_mode) != 0o600:
213
+ raise UnsafeCasePathError(f"case file must have mode 0600: {label}")
214
+
215
+
216
+ def _read_descriptor(
217
+ descriptor: int, *, expected_size: int, max_bytes: int, label: str
218
+ ) -> tuple[bytes, os.stat_result]:
219
+ try:
220
+ handle = os.fdopen(descriptor, "rb")
221
+ except OSError as error:
222
+ with contextlib.suppress(OSError):
223
+ os.close(descriptor)
224
+ raise CaseFormatError(f"cannot read case file: {label}") from error
225
+ try:
226
+ with handle:
227
+ content = handle.read(min(expected_size, max_bytes) + 1)
228
+ return content, os.fstat(handle.fileno())
229
+ except OSError as error:
230
+ raise CaseFormatError(f"cannot read case file: {label}") from error
231
+
232
+
233
+ def lstat_regular_file(path: Path, *, label: str) -> os.stat_result:
234
+ try:
235
+ metadata = path.lstat()
236
+ except OSError as error:
237
+ raise CaseFormatError(f"required case file is unavailable: {label}") from error
238
+ if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode):
239
+ raise UnsafeCasePathError(f"case entry must be a regular file: {label}")
240
+ return metadata
241
+
242
+
243
+ def file_signature(metadata: os.stat_result) -> FileSignature:
244
+ return (
245
+ metadata.st_dev,
246
+ metadata.st_ino,
247
+ metadata.st_size,
248
+ metadata.st_mtime_ns,
249
+ metadata.st_ctime_ns,
250
+ )
251
+
252
+
253
+ def atomic_write(path: Path, content: str) -> PublishedFile:
254
+ return atomic_write_bytes(path, content.encode("utf-8"))
255
+
256
+
257
+ def atomic_write_bytes(path: Path, content: bytes) -> PublishedFile:
258
+ try:
259
+ descriptor, temporary_name = tempfile.mkstemp(
260
+ prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
261
+ )
262
+ except OSError as error:
263
+ raise CaseError("cannot create temporary case file") from error
264
+ temporary = Path(temporary_name)
265
+ temporary_identity = file_identity(os.fstat(descriptor))
266
+ try:
267
+ _write_descriptor(descriptor, content)
268
+ set_private_mode(temporary, 0o600)
269
+ _link_without_overwrite(temporary, path)
270
+ _unlink_if_identity_matches(temporary, temporary_identity)
271
+ signature = _published_signature(path, temporary_identity)
272
+ return PublishedFile(path=path, signature=signature)
273
+ except CaseError:
274
+ raise
275
+ except OSError as error:
276
+ raise CaseError("cannot write case file") from error
277
+ finally:
278
+ _unlink_if_identity_matches(temporary, temporary_identity)
279
+
280
+
281
+ def _write_descriptor(descriptor: int, content: bytes) -> None:
282
+ try:
283
+ handle = os.fdopen(descriptor, "wb")
284
+ except OSError:
285
+ os.close(descriptor)
286
+ raise
287
+ with handle:
288
+ handle.write(content)
289
+ handle.flush()
290
+ os.fsync(handle.fileno())
291
+
292
+
293
+ def _link_without_overwrite(temporary: Path, destination: Path) -> None:
294
+ try:
295
+ os.link(temporary, destination, follow_symlinks=False)
296
+ except FileExistsError as error:
297
+ raise CaseExistsError(f"refusing to overwrite case entry: {destination.name}") from error
298
+ except OSError as error:
299
+ raise CaseError(f"cannot create case file: {destination.name}") from error
300
+
301
+
302
+ def remove_published_file(published: PublishedFile) -> None:
303
+ """Remove a published file only when its identity is unchanged."""
304
+
305
+ try:
306
+ metadata = published.path.lstat()
307
+ except OSError:
308
+ return
309
+ if stat.S_ISREG(metadata.st_mode) and file_signature(metadata) == published.signature:
310
+ published.path.unlink()
311
+
312
+
313
+ def _published_signature(path: Path, expected: FileIdentity) -> FileSignature:
314
+ try:
315
+ metadata = path.lstat()
316
+ except OSError as error:
317
+ _unlink_if_identity_matches(path, expected)
318
+ raise CaseError("published case file is unavailable") from error
319
+ if not stat.S_ISREG(metadata.st_mode) or file_identity(metadata) != expected:
320
+ raise CaseError("published case file changed unexpectedly")
321
+ return file_signature(metadata)
322
+
323
+
324
+ def _unlink_if_identity_matches(path: Path, identity: FileIdentity) -> None:
325
+ try:
326
+ metadata = path.lstat()
327
+ except OSError:
328
+ return
329
+ if file_identity(metadata) == identity:
330
+ with contextlib.suppress(OSError):
331
+ path.unlink()
332
+
333
+
334
+ def set_private_mode(path: Path, mode: int) -> None:
335
+ if os.name == "posix":
336
+ path.chmod(mode)
337
+
338
+
339
+ def file_identity(metadata: os.stat_result) -> FileIdentity:
340
+ return metadata.st_dev, metadata.st_ino