openmodus 0.0.1__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.
- modus/__init__.py +13 -0
- modus/__main__.py +6 -0
- modus/_bundled/schemas/module-meta.schema.yaml +86 -0
- modus/activation_guidance.py +18 -0
- modus/artifacts_io.py +1066 -0
- modus/catalog.py +361 -0
- modus/cli.py +1785 -0
- modus/commands/__init__.py +17 -0
- modus/commands/context_cmd.py +50 -0
- modus/commands/help.py +131 -0
- modus/commands/init_project.py +503 -0
- modus/commands/install_state.py +212 -0
- modus/commands/maintenance_scope.py +69 -0
- modus/commands/permission_errors.py +26 -0
- modus/commands/reporting.py +113 -0
- modus/commands/self_update.py +577 -0
- modus/commands/status.py +52 -0
- modus/commands/uninstall.py +359 -0
- modus/commands/uninstall_report.py +183 -0
- modus/commands/uninstall_self.py +373 -0
- modus/commands/update.py +526 -0
- modus/commands/update_report.py +162 -0
- modus/commands/verify_anchors.py +230 -0
- modus/config.py +492 -0
- modus/git_hygiene.py +466 -0
- modus/hooks/__init__.py +21 -0
- modus/hooks/bootstrap.py +232 -0
- modus/hooks/context.py +29 -0
- modus/hooks/contracts.py +111 -0
- modus/hooks/environment.py +86 -0
- modus/hooks/protocol.py +156 -0
- modus/hooks/runtime.py +136 -0
- modus/init_analysis/README.md +72 -0
- modus/init_analysis/__init__.py +35 -0
- modus/init_analysis/api.py +360 -0
- modus/init_analysis/authoring_projection.py +192 -0
- modus/init_analysis/build_context.py +1326 -0
- modus/init_analysis/bundle.py +1092 -0
- modus/init_analysis/capability_evidence.py +585 -0
- modus/init_analysis/catalog.py +448 -0
- modus/init_analysis/compiler/__init__.py +1 -0
- modus/init_analysis/compiler/cache.py +63 -0
- modus/init_analysis/compiler/cpp.py +536 -0
- modus/init_analysis/compiler/go.py +158 -0
- modus/init_analysis/compiler/java.py +112 -0
- modus/init_analysis/compiler/merge.py +211 -0
- modus/init_analysis/compiler/models.py +88 -0
- modus/init_analysis/compiler/process.py +259 -0
- modus/init_analysis/compiler/python.py +430 -0
- modus/init_analysis/compiler/typescript.py +73 -0
- modus/init_analysis/contracts.py +283 -0
- modus/init_analysis/engine.py +230 -0
- modus/init_analysis/entry_contracts.py +155 -0
- modus/init_analysis/evidence_graph.py +133 -0
- modus/init_analysis/evidence_records.py +211 -0
- modus/init_analysis/extraction_cache.py +309 -0
- modus/init_analysis/fingerprint.py +38 -0
- modus/init_analysis/framework_facts.py +472 -0
- modus/init_analysis/frontend.py +1523 -0
- modus/init_analysis/generic.py +77 -0
- modus/init_analysis/ids.py +35 -0
- modus/init_analysis/input_snapshot.py +675 -0
- modus/init_analysis/language-registry.json +550 -0
- modus/init_analysis/language_capabilities.py +78 -0
- modus/init_analysis/native_binding.py +734 -0
- modus/init_analysis/native_parser.py +905 -0
- modus/init_analysis/navigation.py +184 -0
- modus/init_analysis/pipeline.py +504 -0
- modus/init_analysis/projection.py +409 -0
- modus/init_analysis/provider_assets/ModusJavaAnalyzer.java +350 -0
- modus/init_analysis/provider_assets/modus_go_analyzer.go +367 -0
- modus/init_analysis/provider_assets/modus_typescript_analyzer.cjs +163 -0
- modus/init_analysis/reference_outcomes.py +318 -0
- modus/init_analysis/resources.py +78 -0
- modus/init_analysis/scan.py +95 -0
- modus/init_analysis/scope_candidates.py +372 -0
- modus/init_analysis/search_metadata.py +32 -0
- modus/init_analysis/semantic_contracts.py +119 -0
- modus/init_analysis/semantic_ir.py +582 -0
- modus/init_analysis/semantic_providers.py +188 -0
- modus/init_analysis/semantic_reconcile.py +491 -0
- modus/init_analysis/source_contracts.py +127 -0
- modus/init_analysis/source_coordinates.py +80 -0
- modus/init_analysis/source_filters.py +184 -0
- modus/init_analysis/source_inventory.py +251 -0
- modus/init_analysis/source_roles.py +99 -0
- modus/init_analysis/source_snapshot.py +65 -0
- modus/init_analysis/structural.py +119 -0
- modus/init_analysis/topology.py +2060 -0
- modus/init_analysis/value_summary.py +463 -0
- modus/init_analysis/view_quarantine.py +606 -0
- modus/knowledge_identity.py +200 -0
- modus/knowledge_reader.py +220 -0
- modus/knowledge_seeds.py +56 -0
- modus/knowledge_update.py +212 -0
- modus/knowledge_write_session.py +241 -0
- modus/manifest.py +210 -0
- modus/orchestrator/README.md +67 -0
- modus/orchestrator/__init__.py +14 -0
- modus/orchestrator/context_resolver.py +142 -0
- modus/orchestrator/evidence_search.py +473 -0
- modus/orchestrator/fallback.py +83 -0
- modus/orchestrator/knowledge_sync.py +349 -0
- modus/orchestrator/models.py +82 -0
- modus/orchestrator/module_meta.py +194 -0
- modus/orchestrator/path_scope.py +38 -0
- modus/orchestrator/preflight.py +125 -0
- modus/orchestrator/query_terms.py +124 -0
- modus/orchestrator/search_policy.py +161 -0
- modus/paths.py +279 -0
- modus/platforms/__init__.py +45 -0
- modus/platforms/base.py +299 -0
- modus/platforms/claude.py +98 -0
- modus/platforms/codebuddy.py +51 -0
- modus/platforms/codex.py +41 -0
- modus/platforms/cursor.py +45 -0
- modus/platforms/hooks.py +188 -0
- modus/platforms/models.py +166 -0
- modus/platforms/registry.py +88 -0
- modus/platforms/rendering.py +77 -0
- modus/platforms/sections.py +100 -0
- modus/precedence.py +47 -0
- modus/project_activation.py +99 -0
- modus/retrieval/README.md +64 -0
- modus/retrieval/__init__.py +9 -0
- modus/retrieval/budget.py +50 -0
- modus/retrieval/delivery.py +226 -0
- modus/retrieval/index.py +92 -0
- modus/retrieval/pipeline.py +718 -0
- modus/retrieval/query.py +189 -0
- modus/retrieval/routing.py +237 -0
- modus/retrieval/runtime.py +39 -0
- modus/retrieval/settings.py +75 -0
- modus/retrieval/stages/__init__.py +7 -0
- modus/retrieval/stages/discovery.py +91 -0
- modus/retrieval/stages/documents.py +694 -0
- modus/retrieval/stages/lexical.py +116 -0
- modus/retrieval/stages/ranking.py +179 -0
- modus/retrieval/stages/sections.py +64 -0
- modus/retrieval/stages/selection.py +221 -0
- modus/retrieval/stages/settings.py +7 -0
- modus/scaffold/README.md +68 -0
- modus/scaffold/__init__.py +36 -0
- modus/scaffold/catalog.py +170 -0
- modus/scaffold/project.py +361 -0
- modus/scaffold/templates/README.md +37 -0
- modus/scaffold/templates/commands/init.md +19 -0
- modus/scaffold/templates/commands/reinit.md +18 -0
- modus/scaffold/templates/commands/update-knowledge.md +19 -0
- modus/scaffold/templates/knowledge/constraint/global-standard.md +18 -0
- modus/scaffold/templates/knowledge/constraint/team-standard.md +28 -0
- modus/scaffold/templates/knowledge/handbook/SKILL.md +21 -0
- modus/scaffold/templates/skills/modus-init/SKILL.md +52 -0
- modus/scaffold/templates/skills/modus-init/references/architecture-overview.md +23 -0
- modus/scaffold/templates/skills/modus-init/references/business-skill.md +65 -0
- modus/scaffold/templates/skills/modus-init/references/code-skill.md +190 -0
- modus/scaffold/templates/skills/modus-init/references/constraint-extraction.md +86 -0
- modus/scaffold/templates/skills/modus-init/references/constraint-profiles/cpp.md +39 -0
- modus/scaffold/templates/skills/modus-init/references/constraint-profiles/go.md +39 -0
- modus/scaffold/templates/skills/modus-init/references/constraint-profiles/java.md +39 -0
- modus/scaffold/templates/skills/modus-init/references/constraint-profiles/php.md +39 -0
- modus/scaffold/templates/skills/modus-init/references/constraint-profiles/web.md +50 -0
- modus/scaffold/templates/skills/modus-init/references/global-standard.md +9 -0
- modus/scaffold/templates/skills/modus-init/references/language-adaptation.md +19 -0
- modus/scaffold/templates/skills/modus-init/references/scope-discovery.md +86 -0
- modus/scaffold/templates/skills/modus-init/references/team-standard.md +52 -0
- modus/scaffold/templates/skills/modus-init/references/workspace-init.md +67 -0
- modus/scaffold/templates/skills/modus-init/references/zh-style.md +12 -0
- modus/scaffold/templates/skills/modus-reinit/SKILL.md +22 -0
- modus/scaffold/templates/skills/modus-sync-knowledge/SKILL.md +28 -0
- modus/scaffold/templates/skills/modus-update-knowledge/SKILL.md +22 -0
- modus/scaffold/templates/skills/using-modus/SKILL.md +31 -0
- modus/scaffold/templates/skills/using-modus/references/constraint-usage.md +28 -0
- modus/scaffold/templates/skills/using-modus/references/framework-update.md +15 -0
- modus/scaffold/templates/skills/using-modus/references/knowledge-update-lifecycle.md +68 -0
- modus/scaffold/templates/skills/using-modus/references/router-details.md +42 -0
- modus/scaffold/writer.py +101 -0
- modus/schema_resources.py +56 -0
- modus/scope_models.py +504 -0
- modus/scope_resolver.py +109 -0
- modus/sensitive_literals.py +64 -0
- modus/skillmd.py +144 -0
- modus/source_listing.py +82 -0
- modus/surface.py +65 -0
- modus/text_tree.py +64 -0
- modus/versioning.py +61 -0
- modus/workspace.py +881 -0
- openmodus-0.0.1.dist-info/METADATA +251 -0
- openmodus-0.0.1.dist-info/RECORD +192 -0
- openmodus-0.0.1.dist-info/WHEEL +4 -0
- openmodus-0.0.1.dist-info/entry_points.txt +2 -0
- openmodus-0.0.1.dist-info/licenses/LICENSE +21 -0
modus/artifacts_io.py
ADDED
|
@@ -0,0 +1,1066 @@
|
|
|
1
|
+
"""Corruption-tolerant loading of Modus-generated artifacts.
|
|
2
|
+
|
|
3
|
+
Modus-generated artifacts (evidence bundles, configuration, domain metadata,
|
|
4
|
+
and disposable caches) are machine-written YAML/JSON. A hand-edit, merge accident, or
|
|
5
|
+
interrupted write can leave them malformed. The human review surface is friendly
|
|
6
|
+
Markdown/YAML plus commands, so a malformed artifact must surface as a readable,
|
|
7
|
+
actionable error -- never a raw parser traceback. Disposable caches instead
|
|
8
|
+
self-heal (``tolerant=True``): a corrupt cache is treated as absent and rebuilt.
|
|
9
|
+
|
|
10
|
+
Two load modes:
|
|
11
|
+
|
|
12
|
+
* strict (default): malformed content raises :class:`CorruptArtifactError`, whose
|
|
13
|
+
message names the file, the failure, and how to recover. CLI gate commands
|
|
14
|
+
turn this into a clean non-zero exit instead of a traceback.
|
|
15
|
+
* tolerant: malformed content returns ``default`` so a regeneration/rebuild path
|
|
16
|
+
can overwrite the bad file instead of crashing on it.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import secrets
|
|
24
|
+
import stat
|
|
25
|
+
import threading
|
|
26
|
+
from collections.abc import Iterable, Iterator
|
|
27
|
+
from contextlib import contextmanager
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any, Final
|
|
31
|
+
|
|
32
|
+
import yaml
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
_HELD_FILE_LOCK_DEPTHS = threading.local()
|
|
37
|
+
_OPEN_FILE_LOCKS: dict[int, object] = {}
|
|
38
|
+
_OPEN_FILE_LOCKS_GUARD = threading.Lock()
|
|
39
|
+
_FILE_LOCK_REGISTRY_PID = os.getpid()
|
|
40
|
+
_LOCK_BIND_ATTEMPTS: Final = 8
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _reset_file_lock_registry_after_pid_change() -> None:
|
|
44
|
+
"""Drop copied ownership and close inherited lock descriptors in a fork child."""
|
|
45
|
+
global _FILE_LOCK_REGISTRY_PID, _OPEN_FILE_LOCKS, _OPEN_FILE_LOCKS_GUARD
|
|
46
|
+
|
|
47
|
+
inherited = set(_OPEN_FILE_LOCKS.values())
|
|
48
|
+
for descriptor in tuple(_OPEN_FILE_LOCKS):
|
|
49
|
+
try:
|
|
50
|
+
os.close(descriptor)
|
|
51
|
+
except OSError:
|
|
52
|
+
pass
|
|
53
|
+
_OPEN_FILE_LOCKS = {}
|
|
54
|
+
_OPEN_FILE_LOCKS_GUARD = threading.Lock()
|
|
55
|
+
_HELD_FILE_LOCK_DEPTHS.depths = {}
|
|
56
|
+
_HELD_FILE_LOCK_DEPTHS.required_depths = {}
|
|
57
|
+
_HELD_FILE_LOCK_DEPTHS.inherited_tokens = inherited
|
|
58
|
+
_FILE_LOCK_REGISTRY_PID = os.getpid()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _ensure_file_lock_registry_pid() -> None:
|
|
62
|
+
if _FILE_LOCK_REGISTRY_PID != os.getpid():
|
|
63
|
+
_reset_file_lock_registry_after_pid_change()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
if hasattr(os, "register_at_fork"):
|
|
67
|
+
os.register_at_fork(after_in_child=_reset_file_lock_registry_after_pid_change)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class CorruptArtifactError(Exception):
|
|
71
|
+
"""A Modus-generated artifact exists but could not be parsed.
|
|
72
|
+
|
|
73
|
+
Carries enough structured context for the CLI to print a friendly, actionable
|
|
74
|
+
message (which file, what kind, how to recover).
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(self, path: str | Path, kind: str, detail: str, hint: str | None = None) -> None:
|
|
78
|
+
self.path = str(path)
|
|
79
|
+
self.kind = kind
|
|
80
|
+
self.detail = detail
|
|
81
|
+
self.hint = hint
|
|
82
|
+
message = f"{kind} is malformed: {self.path}\n -> {detail}"
|
|
83
|
+
if hint:
|
|
84
|
+
message += f"\n -> {hint}"
|
|
85
|
+
super().__init__(message)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class FileLockIntegrityError(ValueError):
|
|
89
|
+
"""A writer lock path could not be bound without following links."""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class RequiredFileLockUnavailable(RuntimeError):
|
|
93
|
+
"""A correctness-critical writer lock has no safe platform backend."""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class AtomicWriteIntegrityError(OSError):
|
|
97
|
+
"""An ``atomic_write_text`` target could not be safely bound or written.
|
|
98
|
+
|
|
99
|
+
Raised instead of silently writing through a symlink/special file, or
|
|
100
|
+
instead of completing a replace whose parent directory was swapped out
|
|
101
|
+
from under the write. A well-formed, non-adversarial target never raises
|
|
102
|
+
this -- only a malformed or actively-racing target does.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _detail(exc: Exception) -> str:
|
|
107
|
+
return " ".join(str(exc).split())[:300]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def safe_load_yaml(
|
|
111
|
+
path: str | Path,
|
|
112
|
+
*,
|
|
113
|
+
kind: str,
|
|
114
|
+
default: Any = None,
|
|
115
|
+
tolerant: bool = False,
|
|
116
|
+
hint: str | None = None,
|
|
117
|
+
) -> Any:
|
|
118
|
+
"""Load a YAML artifact, mapping malformed content to a friendly outcome.
|
|
119
|
+
|
|
120
|
+
Returns ``default`` when the file is missing or empty. On malformed YAML:
|
|
121
|
+
raise :class:`CorruptArtifactError` (strict) or return ``default`` (tolerant).
|
|
122
|
+
"""
|
|
123
|
+
p = Path(path)
|
|
124
|
+
if not p.is_file():
|
|
125
|
+
return default
|
|
126
|
+
try:
|
|
127
|
+
data = yaml.safe_load(p.read_text(encoding="utf-8"))
|
|
128
|
+
except yaml.YAMLError as exc:
|
|
129
|
+
if tolerant:
|
|
130
|
+
return default
|
|
131
|
+
raise CorruptArtifactError(p, kind, _detail(exc), hint) from exc
|
|
132
|
+
return default if data is None else data
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _lock_directory_flags() -> int:
|
|
136
|
+
no_follow = getattr(os, "O_NOFOLLOW", None)
|
|
137
|
+
directory = getattr(os, "O_DIRECTORY", None)
|
|
138
|
+
if no_follow is None or directory is None:
|
|
139
|
+
raise FileLockIntegrityError("safe writer locks require O_NOFOLLOW and O_DIRECTORY")
|
|
140
|
+
return os.O_RDONLY | no_follow | directory | getattr(os, "O_CLOEXEC", 0)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _absolute_lock_path(lock_path: str | Path) -> Path:
|
|
144
|
+
"""Return a lexical absolute path without resolving any filesystem link."""
|
|
145
|
+
return Path(os.path.abspath(os.fspath(lock_path)))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _assert_lock_parent_binding(parent: Path, descriptor: int) -> None:
|
|
149
|
+
"""Verify every named ancestor and the retained parent descriptor agree."""
|
|
150
|
+
anchor = Path(parent.anchor)
|
|
151
|
+
current = anchor
|
|
152
|
+
try:
|
|
153
|
+
info = os.stat(anchor, follow_symlinks=False)
|
|
154
|
+
if not stat.S_ISDIR(info.st_mode):
|
|
155
|
+
raise FileLockIntegrityError(f"writer lock anchor is not a directory: {anchor}")
|
|
156
|
+
for part in parent.parts[1:]:
|
|
157
|
+
current = current / part
|
|
158
|
+
info = os.stat(current, follow_symlinks=False)
|
|
159
|
+
if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode):
|
|
160
|
+
raise FileLockIntegrityError(
|
|
161
|
+
f"writer lock parent is not a real directory: {current}"
|
|
162
|
+
)
|
|
163
|
+
opened = os.fstat(descriptor)
|
|
164
|
+
except FileLockIntegrityError:
|
|
165
|
+
raise
|
|
166
|
+
except (OSError, TypeError, NotImplementedError) as exc:
|
|
167
|
+
raise FileLockIntegrityError(
|
|
168
|
+
f"writer lock parent is unsafe or unavailable: {current}: {exc}"
|
|
169
|
+
) from exc
|
|
170
|
+
if (opened.st_dev, opened.st_ino) != (info.st_dev, info.st_ino):
|
|
171
|
+
raise FileLockIntegrityError(f"writer lock parent path changed after opening: {parent}")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _open_lock_parent(parent: Path) -> int:
|
|
175
|
+
"""Create/open ``parent`` one component at a time without following links."""
|
|
176
|
+
flags = _lock_directory_flags()
|
|
177
|
+
descriptor: int | None = None
|
|
178
|
+
try:
|
|
179
|
+
descriptor = os.open(parent.anchor, flags)
|
|
180
|
+
for part in parent.parts[1:]:
|
|
181
|
+
if part in {"", ".", ".."}:
|
|
182
|
+
raise FileLockIntegrityError(f"writer lock parent is not canonical: {parent}")
|
|
183
|
+
try:
|
|
184
|
+
os.mkdir(part, mode=0o755, dir_fd=descriptor)
|
|
185
|
+
except FileExistsError:
|
|
186
|
+
pass
|
|
187
|
+
child = os.open(part, flags, dir_fd=descriptor)
|
|
188
|
+
os.close(descriptor)
|
|
189
|
+
descriptor = child
|
|
190
|
+
_assert_lock_parent_binding(parent, descriptor)
|
|
191
|
+
return descriptor
|
|
192
|
+
except FileLockIntegrityError:
|
|
193
|
+
if descriptor is not None:
|
|
194
|
+
os.close(descriptor)
|
|
195
|
+
raise
|
|
196
|
+
except (OSError, TypeError, NotImplementedError) as exc:
|
|
197
|
+
if descriptor is not None:
|
|
198
|
+
try:
|
|
199
|
+
os.close(descriptor)
|
|
200
|
+
except OSError:
|
|
201
|
+
pass
|
|
202
|
+
raise FileLockIntegrityError(
|
|
203
|
+
f"writer lock parent is unsafe or unavailable: {parent}: {exc}"
|
|
204
|
+
) from exc
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _assert_lock_file_binding(parent_descriptor: int, name: str, descriptor: int) -> None:
|
|
208
|
+
"""Require the lock name to remain bound to the opened regular-file inode."""
|
|
209
|
+
try:
|
|
210
|
+
opened = os.fstat(descriptor)
|
|
211
|
+
named = os.stat(name, dir_fd=parent_descriptor, follow_symlinks=False)
|
|
212
|
+
except (OSError, TypeError, NotImplementedError) as exc:
|
|
213
|
+
raise FileLockIntegrityError(
|
|
214
|
+
f"writer lock file is unsafe or unavailable: {name}: {exc}"
|
|
215
|
+
) from exc
|
|
216
|
+
if not stat.S_ISREG(opened.st_mode) or not stat.S_ISREG(named.st_mode):
|
|
217
|
+
raise FileLockIntegrityError(f"writer lock must be a regular file: {name}")
|
|
218
|
+
if (opened.st_dev, opened.st_ino) != (named.st_dev, named.st_ino):
|
|
219
|
+
raise FileLockIntegrityError(f"writer lock path changed after opening: {name}")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _open_lock_file(parent_descriptor: int, name: str) -> int:
|
|
223
|
+
no_follow = getattr(os, "O_NOFOLLOW", None)
|
|
224
|
+
non_block = getattr(os, "O_NONBLOCK", None)
|
|
225
|
+
if no_follow is None or non_block is None:
|
|
226
|
+
raise FileLockIntegrityError("safe writer locks require O_NOFOLLOW and O_NONBLOCK")
|
|
227
|
+
try:
|
|
228
|
+
descriptor = os.open(
|
|
229
|
+
name,
|
|
230
|
+
os.O_RDWR | os.O_CREAT | no_follow | non_block | getattr(os, "O_CLOEXEC", 0),
|
|
231
|
+
0o600,
|
|
232
|
+
dir_fd=parent_descriptor,
|
|
233
|
+
)
|
|
234
|
+
except (OSError, TypeError, NotImplementedError) as exc:
|
|
235
|
+
raise FileLockIntegrityError(
|
|
236
|
+
f"writer lock file is unsafe or unavailable: {name}: {exc}"
|
|
237
|
+
) from exc
|
|
238
|
+
try:
|
|
239
|
+
_assert_lock_file_binding(parent_descriptor, name, descriptor)
|
|
240
|
+
except Exception:
|
|
241
|
+
os.close(descriptor)
|
|
242
|
+
raise
|
|
243
|
+
return descriptor
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _register_open_file_lock(descriptor: int) -> object:
|
|
247
|
+
_ensure_file_lock_registry_pid()
|
|
248
|
+
token = object()
|
|
249
|
+
with _OPEN_FILE_LOCKS_GUARD:
|
|
250
|
+
_OPEN_FILE_LOCKS[descriptor] = token
|
|
251
|
+
return token
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _unregister_open_file_lock(descriptor: int, token: object) -> None:
|
|
255
|
+
_ensure_file_lock_registry_pid()
|
|
256
|
+
with _OPEN_FILE_LOCKS_GUARD:
|
|
257
|
+
if _OPEN_FILE_LOCKS.get(descriptor) is token:
|
|
258
|
+
_OPEN_FILE_LOCKS.pop(descriptor, None)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _file_lock_token_was_inherited(token: object | None) -> bool:
|
|
262
|
+
if token is None:
|
|
263
|
+
return False
|
|
264
|
+
_ensure_file_lock_registry_pid()
|
|
265
|
+
inherited: set[object] = getattr(_HELD_FILE_LOCK_DEPTHS, "inherited_tokens", set())
|
|
266
|
+
return token in inherited
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@dataclass(slots=True)
|
|
270
|
+
class _PosixLockHandle:
|
|
271
|
+
"""Own every descriptor and registry token for one POSIX writer lock."""
|
|
272
|
+
|
|
273
|
+
parent_fd: int | None = None
|
|
274
|
+
lock_fd: int | None = None
|
|
275
|
+
parent_token: object | None = None
|
|
276
|
+
lock_token: object | None = None
|
|
277
|
+
locked: bool = False
|
|
278
|
+
|
|
279
|
+
def close(self, fcntl_module: Any, *, preserve_inherited: bool) -> None:
|
|
280
|
+
"""Release registrations and descriptors in dependency order."""
|
|
281
|
+
|
|
282
|
+
try:
|
|
283
|
+
if self.lock_fd is not None:
|
|
284
|
+
inherited = preserve_inherited and _file_lock_token_was_inherited(self.lock_token)
|
|
285
|
+
if self.lock_token is not None:
|
|
286
|
+
_unregister_open_file_lock(self.lock_fd, self.lock_token)
|
|
287
|
+
if not inherited:
|
|
288
|
+
try:
|
|
289
|
+
if self.locked:
|
|
290
|
+
fcntl_module.flock(self.lock_fd, fcntl_module.LOCK_UN)
|
|
291
|
+
finally:
|
|
292
|
+
os.close(self.lock_fd)
|
|
293
|
+
finally:
|
|
294
|
+
if self.parent_fd is not None:
|
|
295
|
+
inherited = preserve_inherited and _file_lock_token_was_inherited(self.parent_token)
|
|
296
|
+
if self.parent_token is not None:
|
|
297
|
+
_unregister_open_file_lock(self.parent_fd, self.parent_token)
|
|
298
|
+
if not inherited:
|
|
299
|
+
os.close(self.parent_fd)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _acquire_posix_lock(path: Path, fcntl_module: Any) -> _PosixLockHandle:
|
|
303
|
+
"""Safely bind and acquire one logical lock, retrying concurrent first use."""
|
|
304
|
+
|
|
305
|
+
for attempt in range(_LOCK_BIND_ATTEMPTS):
|
|
306
|
+
handle = _PosixLockHandle()
|
|
307
|
+
try:
|
|
308
|
+
handle.parent_fd = _open_lock_parent(path.parent)
|
|
309
|
+
handle.parent_token = _register_open_file_lock(handle.parent_fd)
|
|
310
|
+
handle.lock_fd = _open_lock_file(handle.parent_fd, path.name)
|
|
311
|
+
handle.lock_token = _register_open_file_lock(handle.lock_fd)
|
|
312
|
+
fcntl_module.flock(handle.lock_fd, fcntl_module.LOCK_EX)
|
|
313
|
+
handle.locked = True
|
|
314
|
+
_assert_lock_parent_binding(path.parent, handle.parent_fd)
|
|
315
|
+
_assert_lock_file_binding(handle.parent_fd, path.name, handle.lock_fd)
|
|
316
|
+
return handle
|
|
317
|
+
except FileLockIntegrityError:
|
|
318
|
+
handle.close(fcntl_module, preserve_inherited=False)
|
|
319
|
+
if attempt == _LOCK_BIND_ATTEMPTS - 1:
|
|
320
|
+
raise
|
|
321
|
+
except BaseException:
|
|
322
|
+
handle.close(fcntl_module, preserve_inherited=False)
|
|
323
|
+
raise
|
|
324
|
+
raise AssertionError("unreachable lock acquisition state")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@contextmanager
|
|
328
|
+
def file_lock(lock_path: str | Path) -> Iterator[None]:
|
|
329
|
+
"""Advisory exclusive lock around a critical section (POSIX ``flock``).
|
|
330
|
+
|
|
331
|
+
Serializes concurrent writers (e.g. two initialization-analysis builds, or two
|
|
332
|
+
``promote`` runs feeding the same domain) so their read-modify-write never
|
|
333
|
+
interleaves. Degrades to a no-op where ``fcntl`` is unavailable (non-POSIX):
|
|
334
|
+
best-effort, never blocks the workflow. The lock file lives wherever the
|
|
335
|
+
caller points it -- by convention under the disposable cache, never inside an
|
|
336
|
+
output tree the lock protects.
|
|
337
|
+
"""
|
|
338
|
+
_ensure_file_lock_registry_pid()
|
|
339
|
+
try:
|
|
340
|
+
import fcntl
|
|
341
|
+
except Exception: # noqa: BLE001 - non-POSIX: best-effort, no locking
|
|
342
|
+
yield
|
|
343
|
+
return
|
|
344
|
+
|
|
345
|
+
# Directory creation and the first lock open are intentionally outside any
|
|
346
|
+
# pre-existing mutex. Concurrent first-use writers can replace a just-made
|
|
347
|
+
# path component between openat/fstat checks. Retry the complete safe bind;
|
|
348
|
+
# every attempt still uses O_NOFOLLOW and never relaxes the symlink policy.
|
|
349
|
+
path = _absolute_lock_path(lock_path)
|
|
350
|
+
handle = _acquire_posix_lock(path, fcntl)
|
|
351
|
+
try:
|
|
352
|
+
yield
|
|
353
|
+
assert handle.parent_fd is not None and handle.lock_fd is not None
|
|
354
|
+
_assert_lock_parent_binding(path.parent, handle.parent_fd)
|
|
355
|
+
_assert_lock_file_binding(handle.parent_fd, path.name, handle.lock_fd)
|
|
356
|
+
finally:
|
|
357
|
+
handle.close(fcntl, preserve_inherited=True)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _assert_portable_lock_binding(path: Path, descriptor: int) -> None:
|
|
361
|
+
"""Validate the regular-file binding used by the native Windows backend."""
|
|
362
|
+
current = Path(path.anchor)
|
|
363
|
+
try:
|
|
364
|
+
for part in path.parent.parts[1:]:
|
|
365
|
+
current = current / part
|
|
366
|
+
info = os.stat(current, follow_symlinks=False)
|
|
367
|
+
if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode):
|
|
368
|
+
raise FileLockIntegrityError(
|
|
369
|
+
f"writer lock parent is not a real directory: {current}"
|
|
370
|
+
)
|
|
371
|
+
opened = os.fstat(descriptor)
|
|
372
|
+
named = os.stat(path, follow_symlinks=False)
|
|
373
|
+
except FileLockIntegrityError:
|
|
374
|
+
raise
|
|
375
|
+
except (OSError, TypeError, NotImplementedError) as exc:
|
|
376
|
+
raise FileLockIntegrityError(
|
|
377
|
+
f"writer lock is unsafe or unavailable: {path}: {exc}"
|
|
378
|
+
) from exc
|
|
379
|
+
if stat.S_ISLNK(named.st_mode) or not stat.S_ISREG(named.st_mode):
|
|
380
|
+
raise FileLockIntegrityError(f"writer lock must be a regular file: {path}")
|
|
381
|
+
if not stat.S_ISREG(opened.st_mode) or (
|
|
382
|
+
opened.st_dev,
|
|
383
|
+
opened.st_ino,
|
|
384
|
+
) != (named.st_dev, named.st_ino):
|
|
385
|
+
raise FileLockIntegrityError(f"writer lock path changed after opening: {path}")
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
@contextmanager
|
|
389
|
+
def _windows_required_file_lock(lock_path: str | Path, msvcrt_module: Any) -> Iterator[None]:
|
|
390
|
+
"""Acquire a mandatory one-byte Windows file lock without shell tooling."""
|
|
391
|
+
path = _absolute_lock_path(lock_path)
|
|
392
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
393
|
+
flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOINHERIT", 0)
|
|
394
|
+
descriptor = os.open(path, flags, 0o600)
|
|
395
|
+
locked = False
|
|
396
|
+
try:
|
|
397
|
+
_assert_portable_lock_binding(path, descriptor)
|
|
398
|
+
if os.fstat(descriptor).st_size == 0:
|
|
399
|
+
os.write(descriptor, b"\0")
|
|
400
|
+
os.fsync(descriptor)
|
|
401
|
+
os.lseek(descriptor, 0, os.SEEK_SET)
|
|
402
|
+
msvcrt_module.locking(descriptor, msvcrt_module.LK_LOCK, 1)
|
|
403
|
+
locked = True
|
|
404
|
+
_assert_portable_lock_binding(path, descriptor)
|
|
405
|
+
yield
|
|
406
|
+
_assert_portable_lock_binding(path, descriptor)
|
|
407
|
+
finally:
|
|
408
|
+
if locked:
|
|
409
|
+
os.lseek(descriptor, 0, os.SEEK_SET)
|
|
410
|
+
msvcrt_module.locking(descriptor, msvcrt_module.LK_UNLCK, 1)
|
|
411
|
+
os.close(descriptor)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
@contextmanager
|
|
415
|
+
def required_file_lock(lock_path: str | Path) -> Iterator[None]:
|
|
416
|
+
"""Acquire a cross-platform writer lock or fail closed.
|
|
417
|
+
|
|
418
|
+
Use this for state transitions whose correctness depends on mutual exclusion
|
|
419
|
+
(for example knowledge and install writes). Read-only callers may continue to use
|
|
420
|
+
:func:`file_lock`'s best-effort behavior, but transactional writers must not
|
|
421
|
+
silently continue without a supported lock backend.
|
|
422
|
+
"""
|
|
423
|
+
try:
|
|
424
|
+
import fcntl # noqa: F401 - capability probe; file_lock owns the handle
|
|
425
|
+
except Exception: # noqa: BLE001 - probe the native Windows backend next
|
|
426
|
+
try:
|
|
427
|
+
import msvcrt
|
|
428
|
+
except Exception as exc: # noqa: BLE001 - fail closed by contract
|
|
429
|
+
raise RequiredFileLockUnavailable(
|
|
430
|
+
"required writer locks need either POSIX fcntl or Windows msvcrt"
|
|
431
|
+
) from exc
|
|
432
|
+
logger.debug("acquiring required Windows writer lock", extra={"path": str(lock_path)})
|
|
433
|
+
with _windows_required_file_lock(lock_path, msvcrt):
|
|
434
|
+
yield
|
|
435
|
+
return
|
|
436
|
+
|
|
437
|
+
logger.debug("acquiring required POSIX writer lock", extra={"path": str(lock_path)})
|
|
438
|
+
with file_lock(lock_path):
|
|
439
|
+
yield
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
@contextmanager
|
|
443
|
+
def required_reentrant_file_lock(lock_path: str | Path) -> Iterator[None]:
|
|
444
|
+
"""Acquire one correctness-critical lock per thread and canonical path."""
|
|
445
|
+
identity = _file_lock_identity(lock_path)
|
|
446
|
+
depths = _held_file_lock_depths()
|
|
447
|
+
required_depths = _held_required_file_lock_depths()
|
|
448
|
+
if required_depths.get(identity, 0):
|
|
449
|
+
depths[identity] += 1
|
|
450
|
+
required_depths[identity] += 1
|
|
451
|
+
try:
|
|
452
|
+
yield
|
|
453
|
+
finally:
|
|
454
|
+
depths[identity] -= 1
|
|
455
|
+
required_depths[identity] -= 1
|
|
456
|
+
return
|
|
457
|
+
|
|
458
|
+
if depths.get(identity, 0):
|
|
459
|
+
# An advisory lock is correctness-equivalent only on POSIX, where
|
|
460
|
+
# ``file_lock`` necessarily acquired flock. On Windows that same API is
|
|
461
|
+
# deliberately a no-op, so a nested transaction must still acquire
|
|
462
|
+
# the native required backend instead of mistaking thread depth for an
|
|
463
|
+
# operating-system lock.
|
|
464
|
+
try:
|
|
465
|
+
import fcntl # noqa: F401 - capability/ownership distinction
|
|
466
|
+
except Exception: # noqa: BLE001 - native required backend acquired below
|
|
467
|
+
pass
|
|
468
|
+
else:
|
|
469
|
+
depths[identity] += 1
|
|
470
|
+
required_depths[identity] = 1
|
|
471
|
+
try:
|
|
472
|
+
yield
|
|
473
|
+
finally:
|
|
474
|
+
depths[identity] -= 1
|
|
475
|
+
required_depths.pop(identity, None)
|
|
476
|
+
return
|
|
477
|
+
|
|
478
|
+
with required_file_lock(lock_path):
|
|
479
|
+
previous_depth = depths.get(identity, 0)
|
|
480
|
+
depths[identity] = previous_depth + 1
|
|
481
|
+
required_depths[identity] = 1
|
|
482
|
+
try:
|
|
483
|
+
yield
|
|
484
|
+
finally:
|
|
485
|
+
required_depths.pop(identity, None)
|
|
486
|
+
if previous_depth:
|
|
487
|
+
depths[identity] = previous_depth
|
|
488
|
+
else:
|
|
489
|
+
depths.pop(identity, None)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
@contextmanager
|
|
493
|
+
def reentrant_file_lock(lock_path: str | Path) -> Iterator[None]:
|
|
494
|
+
"""Acquire one advisory writer lock once per current thread and canonical path."""
|
|
495
|
+
identity = _file_lock_identity(lock_path)
|
|
496
|
+
depths = _held_file_lock_depths()
|
|
497
|
+
if depths.get(identity, 0):
|
|
498
|
+
depths[identity] += 1
|
|
499
|
+
try:
|
|
500
|
+
yield
|
|
501
|
+
finally:
|
|
502
|
+
depths[identity] -= 1
|
|
503
|
+
return
|
|
504
|
+
with file_lock(lock_path):
|
|
505
|
+
depths[identity] = 1
|
|
506
|
+
try:
|
|
507
|
+
yield
|
|
508
|
+
finally:
|
|
509
|
+
depths.pop(identity, None)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _file_lock_identity(lock_path: str | Path) -> str:
|
|
513
|
+
return os.path.normcase(os.fspath(_absolute_lock_path(lock_path)))
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _held_file_lock_depths() -> dict[str, int]:
|
|
517
|
+
_ensure_file_lock_registry_pid()
|
|
518
|
+
depths = getattr(_HELD_FILE_LOCK_DEPTHS, "depths", None)
|
|
519
|
+
if depths is None:
|
|
520
|
+
depths = {}
|
|
521
|
+
_HELD_FILE_LOCK_DEPTHS.depths = depths
|
|
522
|
+
return depths
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _held_required_file_lock_depths() -> dict[str, int]:
|
|
526
|
+
_ensure_file_lock_registry_pid()
|
|
527
|
+
depths = getattr(_HELD_FILE_LOCK_DEPTHS, "required_depths", None)
|
|
528
|
+
if depths is None:
|
|
529
|
+
depths = {}
|
|
530
|
+
_HELD_FILE_LOCK_DEPTHS.required_depths = depths
|
|
531
|
+
return depths
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _atomic_write_temp_name(name: str) -> str:
|
|
535
|
+
"""A per-attempt temp name: PID *and* a random nonce.
|
|
536
|
+
|
|
537
|
+
PID alone collides across a fork boundary or under PID reuse (two
|
|
538
|
+
concurrent writers can legitimately share a PID). The nonce makes every
|
|
539
|
+
attempt's temp name unique regardless of PID, so 64 same-PID writers (or
|
|
540
|
+
genuinely different processes) never contend for one temp path.
|
|
541
|
+
"""
|
|
542
|
+
return f".{name}.tmp-{os.getpid()}-{secrets.token_hex(8)}"
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _reject_unsafe_atomic_write_target(
|
|
546
|
+
parent_descriptor: int | None, name: str, path: Path
|
|
547
|
+
) -> None:
|
|
548
|
+
"""Fail closed if an existing target is a symlink or special file.
|
|
549
|
+
|
|
550
|
+
Checked via ``lstat`` (never following the final component), so this
|
|
551
|
+
guarantee holds even when ``O_NOFOLLOW`` is unavailable for the open calls
|
|
552
|
+
below -- the property does not depend on those flags.
|
|
553
|
+
"""
|
|
554
|
+
try:
|
|
555
|
+
info = (
|
|
556
|
+
os.stat(name, dir_fd=parent_descriptor, follow_symlinks=False)
|
|
557
|
+
if parent_descriptor is not None
|
|
558
|
+
else path.lstat()
|
|
559
|
+
)
|
|
560
|
+
except FileNotFoundError:
|
|
561
|
+
return
|
|
562
|
+
except (OSError, TypeError, NotImplementedError) as exc:
|
|
563
|
+
raise AtomicWriteIntegrityError(
|
|
564
|
+
f"atomic write target is unsafe or unavailable: {path}: {exc}"
|
|
565
|
+
) from exc
|
|
566
|
+
if _is_link_or_reparse(info) or not stat.S_ISREG(info.st_mode):
|
|
567
|
+
raise AtomicWriteIntegrityError(
|
|
568
|
+
f"atomic write target must be a regular file or absent, not a "
|
|
569
|
+
f"symlink/special file: {path}"
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _open_atomic_child(parent_fd: int, name: str, current: Path, flags: int) -> int:
|
|
574
|
+
"""Create/open and inode-bind one directory below an already safe parent."""
|
|
575
|
+
|
|
576
|
+
try:
|
|
577
|
+
os.mkdir(name, mode=0o755, dir_fd=parent_fd)
|
|
578
|
+
except FileExistsError:
|
|
579
|
+
pass
|
|
580
|
+
child = os.open(name, flags, dir_fd=parent_fd)
|
|
581
|
+
try:
|
|
582
|
+
named = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
|
|
583
|
+
opened = os.fstat(child)
|
|
584
|
+
if (
|
|
585
|
+
_is_link_or_reparse(named)
|
|
586
|
+
or not stat.S_ISDIR(named.st_mode)
|
|
587
|
+
or not stat.S_ISDIR(opened.st_mode)
|
|
588
|
+
or (named.st_dev, named.st_ino) != (opened.st_dev, opened.st_ino)
|
|
589
|
+
):
|
|
590
|
+
raise AtomicWriteIntegrityError(
|
|
591
|
+
f"atomic write parent is not a real directory: {current}"
|
|
592
|
+
)
|
|
593
|
+
return child
|
|
594
|
+
except BaseException:
|
|
595
|
+
os.close(child)
|
|
596
|
+
raise
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _open_atomic_write_parent(parent: Path) -> int:
|
|
600
|
+
"""Create/open every ``parent`` component without following filesystem links.
|
|
601
|
+
|
|
602
|
+
A single ``os.open(parent, O_NOFOLLOW | ...)`` call only rejects a symlink
|
|
603
|
+
at the *final* path component -- the kernel still resolves every
|
|
604
|
+
intermediate component through ordinary path resolution, so a symlinked
|
|
605
|
+
grandparent (or higher) would otherwise be silently followed. Opening
|
|
606
|
+
component-by-component with ``dir_fd`` closes that gap. Missing directories
|
|
607
|
+
are created relative to an already-bound parent, avoiding the unsafe
|
|
608
|
+
``Path.mkdir(parents=True)`` preflight that can traverse an ancestor link.
|
|
609
|
+
"""
|
|
610
|
+
open_flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0)
|
|
611
|
+
no_follow = getattr(os, "O_NOFOLLOW", None)
|
|
612
|
+
if no_follow is not None:
|
|
613
|
+
open_flags |= no_follow
|
|
614
|
+
if hasattr(os, "O_DIRECTORY"):
|
|
615
|
+
open_flags |= os.O_DIRECTORY
|
|
616
|
+
descriptor: int | None = None
|
|
617
|
+
transferred = False
|
|
618
|
+
try:
|
|
619
|
+
descriptor = os.open(parent.anchor, open_flags)
|
|
620
|
+
current = Path(parent.anchor)
|
|
621
|
+
for part in parent.parts[1:]:
|
|
622
|
+
if part in {"", ".", ".."}:
|
|
623
|
+
raise AtomicWriteIntegrityError(f"atomic write parent is not canonical: {parent}")
|
|
624
|
+
current = current / part
|
|
625
|
+
child = _open_atomic_child(descriptor, part, current, open_flags)
|
|
626
|
+
os.close(descriptor)
|
|
627
|
+
descriptor = child
|
|
628
|
+
_assert_atomic_write_parent_binding(parent, descriptor)
|
|
629
|
+
transferred = True
|
|
630
|
+
return descriptor
|
|
631
|
+
except AtomicWriteIntegrityError:
|
|
632
|
+
raise
|
|
633
|
+
except (OSError, TypeError, NotImplementedError) as exc:
|
|
634
|
+
raise AtomicWriteIntegrityError(
|
|
635
|
+
f"atomic write parent is unsafe or unavailable: {parent}: {exc}"
|
|
636
|
+
) from exc
|
|
637
|
+
finally:
|
|
638
|
+
if descriptor is not None and not transferred:
|
|
639
|
+
try:
|
|
640
|
+
os.close(descriptor)
|
|
641
|
+
except OSError:
|
|
642
|
+
pass
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _assert_atomic_write_parent_binding(parent: Path, descriptor: int) -> None:
|
|
646
|
+
"""Verify every named ancestor and the open parent descriptor agree.
|
|
647
|
+
|
|
648
|
+
Mirrors ``file_lock``'s ``_assert_lock_parent_binding``: walks the full
|
|
649
|
+
ancestor chain (not just the immediate parent) with an unresolved
|
|
650
|
+
``stat``, then compares the innermost component's identity against the
|
|
651
|
+
descriptor actually held open. A parent-directory swap anywhere in the
|
|
652
|
+
chain -- not only the last component -- is therefore detected.
|
|
653
|
+
"""
|
|
654
|
+
anchor = Path(parent.anchor)
|
|
655
|
+
current = anchor
|
|
656
|
+
try:
|
|
657
|
+
info = os.stat(anchor, follow_symlinks=False)
|
|
658
|
+
if not stat.S_ISDIR(info.st_mode):
|
|
659
|
+
raise AtomicWriteIntegrityError(f"atomic write anchor is not a directory: {anchor}")
|
|
660
|
+
for part in parent.parts[1:]:
|
|
661
|
+
current = current / part
|
|
662
|
+
info = os.stat(current, follow_symlinks=False)
|
|
663
|
+
if _is_link_or_reparse(info) or not stat.S_ISDIR(info.st_mode):
|
|
664
|
+
raise AtomicWriteIntegrityError(
|
|
665
|
+
f"atomic write parent is not a real directory: {current}"
|
|
666
|
+
)
|
|
667
|
+
opened = os.fstat(descriptor)
|
|
668
|
+
except AtomicWriteIntegrityError:
|
|
669
|
+
raise
|
|
670
|
+
except (OSError, TypeError, NotImplementedError) as exc:
|
|
671
|
+
raise AtomicWriteIntegrityError(
|
|
672
|
+
f"atomic write parent is unsafe or unavailable: {current}: {exc}"
|
|
673
|
+
) from exc
|
|
674
|
+
if (opened.st_dev, opened.st_ino) != (info.st_dev, info.st_ino):
|
|
675
|
+
raise AtomicWriteIntegrityError(f"atomic write parent path changed after opening: {parent}")
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _is_link_or_reparse(info: os.stat_result) -> bool:
|
|
679
|
+
"""Recognize POSIX symlinks and Windows reparse-point indirections."""
|
|
680
|
+
reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x0400)
|
|
681
|
+
attributes = getattr(info, "st_file_attributes", 0)
|
|
682
|
+
return stat.S_ISLNK(info.st_mode) or bool(attributes & reparse_flag)
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _portable_lstat(path: Path) -> os.stat_result:
|
|
686
|
+
"""Return unresolved metadata on both POSIX and native Windows Python."""
|
|
687
|
+
try:
|
|
688
|
+
return os.stat(path, follow_symlinks=False)
|
|
689
|
+
except (TypeError, NotImplementedError): # pragma: no cover - old Windows Python
|
|
690
|
+
return path.lstat()
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _portable_parent_snapshot(parent: Path) -> tuple[tuple[int, int], ...]:
|
|
694
|
+
"""Validate an atomic-write ancestor chain and return stable identities.
|
|
695
|
+
|
|
696
|
+
This is the strongest portable fallback available where ``openat`` and
|
|
697
|
+
``O_DIRECTORY`` are absent (notably native Windows). Reparse points are
|
|
698
|
+
rejected explicitly; callers compare the snapshot again immediately before
|
|
699
|
+
publication to catch ordinary directory replacement races.
|
|
700
|
+
"""
|
|
701
|
+
absolute = _absolute_lock_path(parent)
|
|
702
|
+
anchor = Path(absolute.anchor)
|
|
703
|
+
if not absolute.anchor:
|
|
704
|
+
raise AtomicWriteIntegrityError(f"atomic write parent must be an absolute path: {parent}")
|
|
705
|
+
current = anchor
|
|
706
|
+
identities: list[tuple[int, int]] = []
|
|
707
|
+
try:
|
|
708
|
+
info = _portable_lstat(current)
|
|
709
|
+
if _is_link_or_reparse(info) or not stat.S_ISDIR(info.st_mode):
|
|
710
|
+
raise AtomicWriteIntegrityError(
|
|
711
|
+
f"atomic write parent is not a real directory: {current}"
|
|
712
|
+
)
|
|
713
|
+
identities.append((info.st_dev, info.st_ino))
|
|
714
|
+
for part in absolute.parts[1:]:
|
|
715
|
+
if part in {"", ".", ".."}:
|
|
716
|
+
raise AtomicWriteIntegrityError(f"atomic write parent is not canonical: {absolute}")
|
|
717
|
+
current = current / part
|
|
718
|
+
info = _portable_lstat(current)
|
|
719
|
+
if _is_link_or_reparse(info) or not stat.S_ISDIR(info.st_mode):
|
|
720
|
+
raise AtomicWriteIntegrityError(
|
|
721
|
+
f"atomic write parent is not a real directory: {current}"
|
|
722
|
+
)
|
|
723
|
+
identities.append((info.st_dev, info.st_ino))
|
|
724
|
+
except (AtomicWriteIntegrityError, FileNotFoundError):
|
|
725
|
+
raise
|
|
726
|
+
except (OSError, TypeError, NotImplementedError) as exc:
|
|
727
|
+
raise AtomicWriteIntegrityError(
|
|
728
|
+
f"atomic write parent is unsafe or unavailable: {current}: {exc}"
|
|
729
|
+
) from exc
|
|
730
|
+
return tuple(identities)
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _assert_portable_atomic_parent(
|
|
734
|
+
parent: Path, expected: tuple[tuple[int, int], ...] | None = None
|
|
735
|
+
) -> tuple[tuple[int, int], ...]:
|
|
736
|
+
"""Validate portable ancestors and optionally compare their identities."""
|
|
737
|
+
try:
|
|
738
|
+
actual = _portable_parent_snapshot(parent)
|
|
739
|
+
except FileNotFoundError as exc:
|
|
740
|
+
raise AtomicWriteIntegrityError(
|
|
741
|
+
f"atomic write parent is unsafe or unavailable: {parent}: {exc}"
|
|
742
|
+
) from exc
|
|
743
|
+
if expected is not None and actual != expected:
|
|
744
|
+
raise AtomicWriteIntegrityError(
|
|
745
|
+
f"atomic write parent path changed during publication: {parent}"
|
|
746
|
+
)
|
|
747
|
+
return actual
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _ensure_portable_atomic_parent(parent: Path) -> tuple[tuple[int, int], ...]:
|
|
751
|
+
"""Create missing fallback directories without traversing a static link."""
|
|
752
|
+
absolute = _absolute_lock_path(parent)
|
|
753
|
+
anchor = Path(absolute.anchor)
|
|
754
|
+
if not absolute.anchor:
|
|
755
|
+
raise AtomicWriteIntegrityError(f"atomic write parent must be an absolute path: {parent}")
|
|
756
|
+
current = anchor
|
|
757
|
+
try:
|
|
758
|
+
anchor_info = _portable_lstat(anchor)
|
|
759
|
+
if _is_link_or_reparse(anchor_info) or not stat.S_ISDIR(anchor_info.st_mode):
|
|
760
|
+
raise AtomicWriteIntegrityError(
|
|
761
|
+
f"atomic write anchor is not a real directory: {anchor}"
|
|
762
|
+
)
|
|
763
|
+
for part in absolute.parts[1:]:
|
|
764
|
+
if part in {"", ".", ".."}:
|
|
765
|
+
raise AtomicWriteIntegrityError(f"atomic write parent is not canonical: {absolute}")
|
|
766
|
+
current = current / part
|
|
767
|
+
try:
|
|
768
|
+
info = _portable_lstat(current)
|
|
769
|
+
except FileNotFoundError:
|
|
770
|
+
# Re-check every existing ancestor immediately before the
|
|
771
|
+
# path-based mkdir. Native Windows has no dir_fd equivalent,
|
|
772
|
+
# so this is intentionally fail-closed against static links
|
|
773
|
+
# and best-effort against an active swap race.
|
|
774
|
+
_assert_portable_atomic_parent(current.parent)
|
|
775
|
+
try:
|
|
776
|
+
os.mkdir(current, mode=0o755)
|
|
777
|
+
except FileExistsError:
|
|
778
|
+
# A concurrent benign writer may have won directory
|
|
779
|
+
# creation; the unresolved metadata check below decides
|
|
780
|
+
# whether that winner is safe.
|
|
781
|
+
pass
|
|
782
|
+
info = _portable_lstat(current)
|
|
783
|
+
if _is_link_or_reparse(info) or not stat.S_ISDIR(info.st_mode):
|
|
784
|
+
raise AtomicWriteIntegrityError(
|
|
785
|
+
f"atomic write parent is not a real directory: {current}"
|
|
786
|
+
)
|
|
787
|
+
except AtomicWriteIntegrityError:
|
|
788
|
+
raise
|
|
789
|
+
except (OSError, TypeError, NotImplementedError) as exc:
|
|
790
|
+
raise AtomicWriteIntegrityError(
|
|
791
|
+
f"atomic write parent is unsafe or unavailable: {current}: {exc}"
|
|
792
|
+
) from exc
|
|
793
|
+
return _assert_portable_atomic_parent(absolute)
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def _atomic_write_posix(parent: Path, name: str, data: bytes | Iterable[bytes], path: Path) -> None:
|
|
797
|
+
"""Bind the parent directory, then create-fsync-rename-fsync under it.
|
|
798
|
+
|
|
799
|
+
The parent is opened one path component at a time (mirrors ``file_lock``'s
|
|
800
|
+
``_open_lock_parent``/``_assert_lock_parent_binding``), so a symlink
|
|
801
|
+
anywhere in the ancestor chain -- not just the immediate parent -- is
|
|
802
|
+
rejected; a single multi-component ``os.open(parent, O_NOFOLLOW)`` call
|
|
803
|
+
would only protect the final component. Before the final rename the whole
|
|
804
|
+
chain is re-checked against a fresh, unresolved ``stat``, so a parent-
|
|
805
|
+
directory swap anywhere in that chain between temp-file creation and
|
|
806
|
+
replace is detected and fails closed instead of silently renaming into the
|
|
807
|
+
wrong directory.
|
|
808
|
+
"""
|
|
809
|
+
no_follow = getattr(os, "O_NOFOLLOW", None)
|
|
810
|
+
parent_descriptor = _open_atomic_write_parent(parent)
|
|
811
|
+
try:
|
|
812
|
+
parent_stat = os.fstat(parent_descriptor)
|
|
813
|
+
if not stat.S_ISDIR(parent_stat.st_mode):
|
|
814
|
+
raise AtomicWriteIntegrityError(f"atomic write parent is not a directory: {parent}")
|
|
815
|
+
_reject_unsafe_atomic_write_target(parent_descriptor, name, path)
|
|
816
|
+
|
|
817
|
+
tmp_name = _atomic_write_temp_name(name)
|
|
818
|
+
create_flags = (
|
|
819
|
+
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0) | (no_follow or 0)
|
|
820
|
+
)
|
|
821
|
+
fd = os.open(tmp_name, create_flags, 0o600, dir_fd=parent_descriptor)
|
|
822
|
+
try:
|
|
823
|
+
with os.fdopen(fd, "wb") as handle:
|
|
824
|
+
for chunk in (data,) if isinstance(data, bytes) else data:
|
|
825
|
+
handle.write(chunk)
|
|
826
|
+
handle.flush()
|
|
827
|
+
os.fsync(handle.fileno())
|
|
828
|
+
|
|
829
|
+
_assert_atomic_write_parent_binding(parent, parent_descriptor)
|
|
830
|
+
_reject_unsafe_atomic_write_target(parent_descriptor, name, path)
|
|
831
|
+
|
|
832
|
+
if os.rename in os.supports_dir_fd:
|
|
833
|
+
os.rename(
|
|
834
|
+
tmp_name, name, src_dir_fd=parent_descriptor, dst_dir_fd=parent_descriptor
|
|
835
|
+
)
|
|
836
|
+
else: # pragma: no cover - exercised only on platforms without renameat
|
|
837
|
+
os.replace(parent / tmp_name, path)
|
|
838
|
+
except BaseException:
|
|
839
|
+
try:
|
|
840
|
+
if os.unlink in os.supports_dir_fd:
|
|
841
|
+
os.unlink(tmp_name, dir_fd=parent_descriptor)
|
|
842
|
+
else: # pragma: no cover - exercised only without unlinkat
|
|
843
|
+
(parent / tmp_name).unlink()
|
|
844
|
+
except OSError:
|
|
845
|
+
pass
|
|
846
|
+
raise
|
|
847
|
+
|
|
848
|
+
try:
|
|
849
|
+
os.fsync(parent_descriptor)
|
|
850
|
+
except OSError: # pragma: no cover - best-effort durability only
|
|
851
|
+
pass
|
|
852
|
+
|
|
853
|
+
# Post-write binding validation: confirm the name we just published
|
|
854
|
+
# still resolves to a real, regular file (never a symlink/special
|
|
855
|
+
# file) without following it. A concurrent writer may have already
|
|
856
|
+
# published its own complete content over ours by the time this
|
|
857
|
+
# check runs -- that is correct atomic-write interleaving, not
|
|
858
|
+
# corruption, so this only re-checks *shape*, never our own bytes.
|
|
859
|
+
verified = os.stat(name, dir_fd=parent_descriptor, follow_symlinks=False)
|
|
860
|
+
if not stat.S_ISREG(verified.st_mode):
|
|
861
|
+
raise AtomicWriteIntegrityError(
|
|
862
|
+
f"atomic write target failed post-write binding validation: {path}"
|
|
863
|
+
)
|
|
864
|
+
finally:
|
|
865
|
+
os.close(parent_descriptor)
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def _atomic_write_fallback(path: Path, data: bytes | Iterable[bytes]) -> None:
|
|
869
|
+
"""Atomic write fallback with portable ancestor/reparse-point checks.
|
|
870
|
+
|
|
871
|
+
Native Windows lacks the POSIX ``dir_fd`` publication primitive. It can
|
|
872
|
+
still reject every static ancestor reparse point and compare directory
|
|
873
|
+
identities before and after publication instead of blindly following a
|
|
874
|
+
linked parent.
|
|
875
|
+
"""
|
|
876
|
+
path = _absolute_lock_path(path)
|
|
877
|
+
parent_snapshot = _ensure_portable_atomic_parent(path.parent)
|
|
878
|
+
_reject_unsafe_atomic_write_target(None, path.name, path)
|
|
879
|
+
tmp = path.with_name(_atomic_write_temp_name(path.name))
|
|
880
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0)
|
|
881
|
+
fd = os.open(tmp, flags, 0o600)
|
|
882
|
+
try:
|
|
883
|
+
with os.fdopen(fd, "wb") as handle:
|
|
884
|
+
for chunk in (data,) if isinstance(data, bytes) else data:
|
|
885
|
+
handle.write(chunk)
|
|
886
|
+
handle.flush()
|
|
887
|
+
try:
|
|
888
|
+
os.fsync(handle.fileno())
|
|
889
|
+
except OSError: # pragma: no cover - best-effort durability only
|
|
890
|
+
pass
|
|
891
|
+
_assert_portable_atomic_parent(path.parent, parent_snapshot)
|
|
892
|
+
_reject_unsafe_atomic_write_target(None, path.name, path)
|
|
893
|
+
os.replace(tmp, path)
|
|
894
|
+
_assert_portable_atomic_parent(path.parent, parent_snapshot)
|
|
895
|
+
_reject_unsafe_atomic_write_target(None, path.name, path)
|
|
896
|
+
except BaseException:
|
|
897
|
+
try:
|
|
898
|
+
tmp.unlink()
|
|
899
|
+
except OSError:
|
|
900
|
+
pass
|
|
901
|
+
raise
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def atomic_write_text(path: str | Path, text: str, *, encoding: str = "utf-8") -> Path:
|
|
905
|
+
"""Write ``text`` to ``path`` atomically (temp file + fsync + rename + fsync).
|
|
906
|
+
|
|
907
|
+
A reader either sees the old complete file or the new complete file, never a
|
|
908
|
+
half-written one -- so a crashed/concurrent write can't leave a torn cache.
|
|
909
|
+
Hardened beyond plain temp+replace: the temp name carries a random nonce (not
|
|
910
|
+
just the writer's PID, which can collide across a fork boundary or PID
|
|
911
|
+
reuse), the temp file is fsynced before the rename and the parent directory
|
|
912
|
+
is fsynced after it (durability across a crash), a symlink or special file
|
|
913
|
+
already at the target is rejected rather than written through, and a parent-
|
|
914
|
+
directory swap between temp-file creation and the final rename is detected
|
|
915
|
+
and fails closed. None of this changes behavior for a well-formed,
|
|
916
|
+
non-adversarial write to a regular file or absent path.
|
|
917
|
+
"""
|
|
918
|
+
p = Path(path)
|
|
919
|
+
absolute = _absolute_lock_path(p)
|
|
920
|
+
data = text.encode(encoding)
|
|
921
|
+
if hasattr(os, "O_DIRECTORY") and hasattr(os, "fdopen"):
|
|
922
|
+
_atomic_write_posix(absolute.parent, absolute.name, data, absolute)
|
|
923
|
+
else: # pragma: no cover - exercised only on platforms without O_DIRECTORY
|
|
924
|
+
_atomic_write_fallback(absolute, data)
|
|
925
|
+
return p
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def atomic_write_chunks(path: str | Path, chunks: Iterable[str]) -> Path:
|
|
929
|
+
"""Stream a large UTF-8 export through the same bound atomic publication.
|
|
930
|
+
|
|
931
|
+
Iterator failure leaves the previous complete export untouched. The input
|
|
932
|
+
need not be copied into a second object or serialized into one giant string.
|
|
933
|
+
"""
|
|
934
|
+
p = Path(path)
|
|
935
|
+
absolute = _absolute_lock_path(p)
|
|
936
|
+
data = (chunk.encode("utf-8") for chunk in chunks)
|
|
937
|
+
if hasattr(os, "O_DIRECTORY") and hasattr(os, "fdopen"):
|
|
938
|
+
_atomic_write_posix(absolute.parent, absolute.name, data, absolute)
|
|
939
|
+
else:
|
|
940
|
+
_atomic_write_fallback(absolute, data)
|
|
941
|
+
return p
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def _atomic_create_posix(parent: Path, name: str, data: bytes, path: Path) -> None:
|
|
945
|
+
"""Publish one fully durable regular file without replacing an existing name."""
|
|
946
|
+
parent_descriptor = _open_atomic_write_parent(parent)
|
|
947
|
+
tmp_name = _atomic_write_temp_name(name)
|
|
948
|
+
temp_exists = False
|
|
949
|
+
try:
|
|
950
|
+
_reject_unsafe_atomic_write_target(parent_descriptor, name, path)
|
|
951
|
+
flags = (
|
|
952
|
+
os.O_WRONLY
|
|
953
|
+
| os.O_CREAT
|
|
954
|
+
| os.O_EXCL
|
|
955
|
+
| getattr(os, "O_CLOEXEC", 0)
|
|
956
|
+
| getattr(os, "O_NOFOLLOW", 0)
|
|
957
|
+
)
|
|
958
|
+
descriptor = os.open(tmp_name, flags, 0o600, dir_fd=parent_descriptor)
|
|
959
|
+
temp_exists = True
|
|
960
|
+
with os.fdopen(descriptor, "wb") as handle:
|
|
961
|
+
handle.write(data)
|
|
962
|
+
handle.flush()
|
|
963
|
+
os.fsync(handle.fileno())
|
|
964
|
+
|
|
965
|
+
_assert_atomic_write_parent_binding(parent, parent_descriptor)
|
|
966
|
+
# Hard-link publication is atomic and has no-clobber semantics on both
|
|
967
|
+
# POSIX and Windows filesystems. The state commit may therefore refer to
|
|
968
|
+
# either the complete immutable event or no event, never a torn event.
|
|
969
|
+
if os.link in os.supports_dir_fd:
|
|
970
|
+
os.link(
|
|
971
|
+
tmp_name,
|
|
972
|
+
name,
|
|
973
|
+
src_dir_fd=parent_descriptor,
|
|
974
|
+
dst_dir_fd=parent_descriptor,
|
|
975
|
+
)
|
|
976
|
+
else: # pragma: no cover - only platforms without linkat
|
|
977
|
+
os.link(parent / tmp_name, path)
|
|
978
|
+
|
|
979
|
+
if os.unlink in os.supports_dir_fd:
|
|
980
|
+
os.unlink(tmp_name, dir_fd=parent_descriptor)
|
|
981
|
+
else: # pragma: no cover - only platforms without unlinkat
|
|
982
|
+
(parent / tmp_name).unlink()
|
|
983
|
+
temp_exists = False
|
|
984
|
+
try:
|
|
985
|
+
os.fsync(parent_descriptor)
|
|
986
|
+
except OSError: # pragma: no cover - best-effort directory durability
|
|
987
|
+
pass
|
|
988
|
+
published = os.stat(name, dir_fd=parent_descriptor, follow_symlinks=False)
|
|
989
|
+
if not stat.S_ISREG(published.st_mode):
|
|
990
|
+
raise AtomicWriteIntegrityError(
|
|
991
|
+
f"atomic create target failed binding validation: {path}"
|
|
992
|
+
)
|
|
993
|
+
finally:
|
|
994
|
+
if temp_exists:
|
|
995
|
+
try:
|
|
996
|
+
if os.unlink in os.supports_dir_fd:
|
|
997
|
+
os.unlink(tmp_name, dir_fd=parent_descriptor)
|
|
998
|
+
else: # pragma: no cover - only platforms without unlinkat
|
|
999
|
+
(parent / tmp_name).unlink()
|
|
1000
|
+
except OSError:
|
|
1001
|
+
pass
|
|
1002
|
+
os.close(parent_descriptor)
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
def _atomic_create_fallback(path: Path, data: bytes) -> None:
|
|
1006
|
+
"""No-clobber publication with portable ancestor/reparse-point checks."""
|
|
1007
|
+
path = _absolute_lock_path(path)
|
|
1008
|
+
parent_snapshot = _ensure_portable_atomic_parent(path.parent)
|
|
1009
|
+
_reject_unsafe_atomic_write_target(None, path.name, path)
|
|
1010
|
+
tmp = path.with_name(_atomic_write_temp_name(path.name))
|
|
1011
|
+
descriptor = os.open(
|
|
1012
|
+
tmp,
|
|
1013
|
+
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0),
|
|
1014
|
+
0o600,
|
|
1015
|
+
)
|
|
1016
|
+
try:
|
|
1017
|
+
with os.fdopen(descriptor, "wb") as handle:
|
|
1018
|
+
handle.write(data)
|
|
1019
|
+
handle.flush()
|
|
1020
|
+
try:
|
|
1021
|
+
os.fsync(handle.fileno())
|
|
1022
|
+
except OSError: # pragma: no cover - best-effort durability
|
|
1023
|
+
pass
|
|
1024
|
+
_assert_portable_atomic_parent(path.parent, parent_snapshot)
|
|
1025
|
+
_reject_unsafe_atomic_write_target(None, path.name, path)
|
|
1026
|
+
os.link(tmp, path)
|
|
1027
|
+
_assert_portable_atomic_parent(path.parent, parent_snapshot)
|
|
1028
|
+
_reject_unsafe_atomic_write_target(None, path.name, path)
|
|
1029
|
+
finally:
|
|
1030
|
+
try:
|
|
1031
|
+
tmp.unlink()
|
|
1032
|
+
except OSError:
|
|
1033
|
+
pass
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def atomic_create_text(path: str | Path, text: str, *, encoding: str = "utf-8") -> Path:
|
|
1037
|
+
"""Atomically create an immutable text file and fail if it already exists."""
|
|
1038
|
+
target = Path(path)
|
|
1039
|
+
absolute = _absolute_lock_path(target)
|
|
1040
|
+
data = text.encode(encoding)
|
|
1041
|
+
if hasattr(os, "O_DIRECTORY") and hasattr(os, "fdopen"):
|
|
1042
|
+
_atomic_create_posix(
|
|
1043
|
+
absolute.parent,
|
|
1044
|
+
absolute.name,
|
|
1045
|
+
data,
|
|
1046
|
+
absolute,
|
|
1047
|
+
)
|
|
1048
|
+
else: # pragma: no cover - exercised on native Windows CI
|
|
1049
|
+
_atomic_create_fallback(absolute, data)
|
|
1050
|
+
return target
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
__all__ = [
|
|
1054
|
+
"AtomicWriteIntegrityError",
|
|
1055
|
+
"CorruptArtifactError",
|
|
1056
|
+
"FileLockIntegrityError",
|
|
1057
|
+
"RequiredFileLockUnavailable",
|
|
1058
|
+
"atomic_create_text",
|
|
1059
|
+
"atomic_write_chunks",
|
|
1060
|
+
"atomic_write_text",
|
|
1061
|
+
"file_lock",
|
|
1062
|
+
"reentrant_file_lock",
|
|
1063
|
+
"required_file_lock",
|
|
1064
|
+
"required_reentrant_file_lock",
|
|
1065
|
+
"safe_load_yaml",
|
|
1066
|
+
]
|