sdks/python: mirror Elydora-Python-SDK 2.0.2
Rebuilt from the standalone 54e65eb tree (durable transactions, Kiro IDE adapter, hook retry with a bounded chain lock, version 2.0.2) with the Open-Source API trims re-applied: no token rotation, token issuance, or agent integration updates, and the mirror's registration contract and OpenCode plugin tests kept. Claude-Session: https://claude.ai/code/session_01QoUjdc7HGKTsgj5GhG8HvF
This commit is contained in:
1 parent
b00932d047
commit
1656a94811
56 files changed
+12203
-588
No files matched your search
@@ -2,65 +2,67 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
import tempfile
|
||||
from typing import List, Optional, Sequence, cast
|
||||
from uuid import uuid4
|
||||
|
||||
from ._managed_files import (
|
||||
FileSnapshot,
|
||||
MAX_SOURCE_BYTES,
|
||||
ensure_physical_directory,
|
||||
physical_file_exists,
|
||||
read_physical_file,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FileChange:
|
||||
file_path: str
|
||||
label: str
|
||||
original: Optional[str]
|
||||
next_source: Optional[str]
|
||||
mode: int
|
||||
original_mode: Optional[int]
|
||||
original_device: Optional[int]
|
||||
original_inode: Optional[int]
|
||||
maximum_bytes: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FilePrecondition:
|
||||
file_path: str
|
||||
label: str
|
||||
original: Optional[FileSnapshot]
|
||||
maximum_bytes: int = MAX_SOURCE_BYTES
|
||||
|
||||
|
||||
@dataclass
|
||||
class _StagedChange:
|
||||
change: FileChange
|
||||
temporary_path: Optional[str]
|
||||
rollback_path: Optional[str]
|
||||
committed_snapshot: Optional[FileSnapshot] = None
|
||||
committed: bool = False
|
||||
from ._opaque_cleanup import cleanup_opaque_removal
|
||||
from ._opaque_removal import (
|
||||
OpaqueRemovalChange,
|
||||
StagedOpaqueRemoval,
|
||||
assert_staged_opaque,
|
||||
commit_opaque_removal,
|
||||
prepare_opaque_rollback,
|
||||
rollback_opaque_removal,
|
||||
stage_opaque_removal,
|
||||
)
|
||||
from ._pinned_directory import PinnedDirectory
|
||||
from ._transaction_commit import commit_change, prepare_change, rollback_change
|
||||
from ._transaction_journal import (
|
||||
FileFingerprint,
|
||||
JournalRecord,
|
||||
TransactionJournal,
|
||||
active_journal_path,
|
||||
clear_journal,
|
||||
write_journal,
|
||||
)
|
||||
from ._transaction_lock import serialized_transactions
|
||||
from ._transaction_restart import (
|
||||
recover_pending_locked,
|
||||
recover_pending_transactions as _recover_pending_transactions,
|
||||
)
|
||||
from ._transaction_staging import (
|
||||
assert_transaction_preconditions,
|
||||
cleanup_created_directories,
|
||||
cleanup_staged,
|
||||
close_directories,
|
||||
directory_for,
|
||||
pin_directories,
|
||||
reserve_change,
|
||||
same_snapshot,
|
||||
stage_change,
|
||||
)
|
||||
from ._transaction_types import (
|
||||
DirectoryMap,
|
||||
DirectoryPrecondition,
|
||||
FileChange,
|
||||
FilePrecondition,
|
||||
StagedChange,
|
||||
)
|
||||
|
||||
|
||||
_EXPECTED_SNAPSHOT_UNSET = object()
|
||||
|
||||
|
||||
def _same_snapshot(
|
||||
current: Optional[FileSnapshot],
|
||||
expected: Optional[FileSnapshot],
|
||||
) -> bool:
|
||||
if current is None or expected is None:
|
||||
return current is expected
|
||||
return (
|
||||
current.contents == expected.contents
|
||||
and current.device == expected.device
|
||||
and current.inode == expected.inode
|
||||
)
|
||||
def recover_pending_transactions() -> None:
|
||||
"""Recover a journal left by a terminated transaction process."""
|
||||
_recover_pending_transactions()
|
||||
|
||||
|
||||
def read_optional(
|
||||
@@ -80,24 +82,22 @@ def source_change(
|
||||
mode: int,
|
||||
maximum_bytes: int = MAX_SOURCE_BYTES,
|
||||
expected_snapshot: object = _EXPECTED_SNAPSHOT_UNSET,
|
||||
enforce_mode: bool = False,
|
||||
) -> Optional[FileChange]:
|
||||
if original == next_source:
|
||||
return None
|
||||
if (
|
||||
next_source is not None
|
||||
and len(next_source.encode("utf-8")) > maximum_bytes
|
||||
):
|
||||
raise ValueError(
|
||||
f"{label} exceeds {maximum_bytes} bytes: {file_path}"
|
||||
)
|
||||
if next_source is not None and len(next_source.encode("utf-8")) > maximum_bytes:
|
||||
raise ValueError(f"{label} exceeds {maximum_bytes} bytes: {file_path}")
|
||||
snapshot = read_physical_file(file_path, label, maximum_bytes)
|
||||
current = None if snapshot is None else snapshot.contents
|
||||
if current != original:
|
||||
raise OSError(f"{label} changed before staging: {file_path}")
|
||||
if expected_snapshot is not _EXPECTED_SNAPSHOT_UNSET:
|
||||
expected = cast(Optional[FileSnapshot], expected_snapshot)
|
||||
if not _same_snapshot(snapshot, expected):
|
||||
if not same_snapshot(snapshot, expected):
|
||||
raise OSError(f"{label} changed before staging: {file_path}")
|
||||
if original == next_source and (
|
||||
not enforce_mode or snapshot is None or snapshot.mode == mode
|
||||
):
|
||||
return None
|
||||
return FileChange(
|
||||
file_path,
|
||||
label,
|
||||
@@ -117,6 +117,7 @@ def file_change(
|
||||
next_source: Optional[str],
|
||||
mode: int,
|
||||
maximum_bytes: int = MAX_SOURCE_BYTES,
|
||||
enforce_mode: bool = False,
|
||||
) -> Optional[FileChange]:
|
||||
snapshot = read_physical_file(file_path, label, maximum_bytes)
|
||||
return source_change(
|
||||
@@ -126,275 +127,358 @@ def file_change(
|
||||
next_source,
|
||||
mode,
|
||||
maximum_bytes,
|
||||
enforce_mode=enforce_mode,
|
||||
)
|
||||
|
||||
|
||||
def _remove_optional(file_path: Optional[str]) -> None:
|
||||
if not file_path:
|
||||
return
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
def _replace_physical(
|
||||
source_path: str,
|
||||
destination_path: str,
|
||||
directory: PinnedDirectory,
|
||||
) -> None:
|
||||
del source_path, destination_path, directory
|
||||
|
||||
|
||||
def _cleanup_paths(paths: Sequence[Optional[str]]) -> List[str]:
|
||||
def _restore_physical(
|
||||
source_path: str,
|
||||
destination_path: str,
|
||||
directory: PinnedDirectory,
|
||||
) -> None:
|
||||
del source_path, destination_path, directory
|
||||
|
||||
|
||||
def _capture_physical(
|
||||
source_path: str,
|
||||
destination_path: str,
|
||||
directory: PinnedDirectory,
|
||||
) -> None:
|
||||
del source_path, destination_path, directory
|
||||
|
||||
|
||||
def _after_replace_physical(
|
||||
source_path: str,
|
||||
destination_path: str,
|
||||
directory: PinnedDirectory,
|
||||
) -> None:
|
||||
del source_path, destination_path, directory
|
||||
|
||||
|
||||
def _after_journal_commit() -> None:
|
||||
"""Fault-injection boundary after the committed marker is durable."""
|
||||
|
||||
|
||||
def _commit(staged: StagedChange) -> None:
|
||||
commit_change(
|
||||
staged,
|
||||
_replace_physical,
|
||||
_capture_physical,
|
||||
_after_replace_physical,
|
||||
)
|
||||
|
||||
|
||||
def _rollback(staged: StagedChange) -> None:
|
||||
rollback_change(staged, _restore_physical, _capture_physical)
|
||||
|
||||
|
||||
def _recover(
|
||||
staged: Sequence[StagedChange],
|
||||
opaque_staged: Sequence[StagedOpaqueRemoval],
|
||||
directories: DirectoryMap,
|
||||
directory_preconditions: Sequence[DirectoryPrecondition],
|
||||
) -> List[str]:
|
||||
errors = []
|
||||
for path in paths:
|
||||
for staged_item in reversed(staged):
|
||||
try:
|
||||
_remove_optional(path)
|
||||
_rollback(staged_item)
|
||||
except Exception as error:
|
||||
errors.append(str(error))
|
||||
for opaque_item in reversed(opaque_staged):
|
||||
try:
|
||||
rollback_opaque_removal(opaque_item)
|
||||
except Exception as error:
|
||||
errors.append(str(error))
|
||||
for staged_item in staged:
|
||||
try:
|
||||
cleanup_staged(staged_item)
|
||||
except OSError as error:
|
||||
errors.append(str(error))
|
||||
for opaque_item in opaque_staged:
|
||||
try:
|
||||
cleanup_opaque_removal(opaque_item)
|
||||
except OSError as error:
|
||||
errors.append(str(error))
|
||||
errors.extend(
|
||||
cleanup_created_directories(directory_preconditions, directories)
|
||||
)
|
||||
errors.extend(close_directories(directories))
|
||||
return errors
|
||||
|
||||
|
||||
def _write_staged(
|
||||
directory: str,
|
||||
basename: str,
|
||||
suffix: str,
|
||||
content: str,
|
||||
mode: int,
|
||||
def _finish_committed(
|
||||
staged: Sequence[StagedChange],
|
||||
opaque_staged: Sequence[StagedOpaqueRemoval],
|
||||
directories: DirectoryMap,
|
||||
) -> List[str]:
|
||||
errors = []
|
||||
for staged_item in staged:
|
||||
try:
|
||||
cleanup_staged(staged_item)
|
||||
except OSError as error:
|
||||
errors.append(str(error))
|
||||
for opaque_item in opaque_staged:
|
||||
try:
|
||||
cleanup_opaque_removal(opaque_item)
|
||||
except OSError as error:
|
||||
errors.append(str(error))
|
||||
errors.extend(close_directories(directories))
|
||||
if errors:
|
||||
return errors
|
||||
try:
|
||||
clear_journal()
|
||||
except OSError as error:
|
||||
errors.append(str(error))
|
||||
return errors
|
||||
|
||||
|
||||
def _text_journal_record(staged: StagedChange) -> JournalRecord:
|
||||
original = None
|
||||
if staged.change.original is not None:
|
||||
if (
|
||||
staged.change.original_device is None
|
||||
or staged.change.original_inode is None
|
||||
or staged.change.original_mode is None
|
||||
):
|
||||
raise OSError(f"Missing original identity for {staged.change.label}")
|
||||
original = FileFingerprint.from_text(
|
||||
FileSnapshot(
|
||||
staged.change.original,
|
||||
staged.change.original_device,
|
||||
staged.change.original_inode,
|
||||
staged.change.original_mode,
|
||||
)
|
||||
)
|
||||
next_value = (
|
||||
None
|
||||
if staged.temporary_snapshot is None
|
||||
else FileFingerprint.from_text(staged.temporary_snapshot)
|
||||
)
|
||||
temporary_identity = (
|
||||
None
|
||||
if staged.temporary_reservation is None
|
||||
else FileFingerprint.from_text(staged.temporary_reservation)
|
||||
)
|
||||
rollback = (
|
||||
None
|
||||
if staged.rollback_reservation is None
|
||||
else FileFingerprint.from_text(staged.rollback_reservation)
|
||||
)
|
||||
return JournalRecord(
|
||||
"text",
|
||||
staged.change.label,
|
||||
os.path.abspath(staged.change.file_path),
|
||||
None if staged.temporary_path is None else os.path.abspath(staged.temporary_path),
|
||||
None if staged.rollback_path is None else os.path.abspath(staged.rollback_path),
|
||||
staged.directory.path,
|
||||
staged.directory.snapshot,
|
||||
original,
|
||||
next_value,
|
||||
temporary_identity,
|
||||
rollback,
|
||||
)
|
||||
|
||||
|
||||
def _opaque_journal_record(staged: StagedOpaqueRemoval) -> JournalRecord:
|
||||
original = (
|
||||
None
|
||||
if staged.change.original is None
|
||||
else FileFingerprint.from_opaque(staged.change.original)
|
||||
)
|
||||
rollback = (
|
||||
None
|
||||
if staged.rollback_reservation is None
|
||||
else FileFingerprint.from_text(staged.rollback_reservation)
|
||||
)
|
||||
return JournalRecord(
|
||||
"opaque",
|
||||
staged.change.label,
|
||||
os.path.abspath(staged.change.file_path),
|
||||
None,
|
||||
None if staged.rollback_path is None else os.path.abspath(staged.rollback_path),
|
||||
staged.directory.path,
|
||||
staged.directory.snapshot,
|
||||
original,
|
||||
None,
|
||||
None,
|
||||
rollback,
|
||||
)
|
||||
|
||||
|
||||
def _write_changes_locked(
|
||||
changes: Sequence[FileChange],
|
||||
label: str,
|
||||
) -> str:
|
||||
descriptor = -1
|
||||
temporary_path = ""
|
||||
try:
|
||||
descriptor, temporary_path = tempfile.mkstemp(
|
||||
prefix=f".{basename}.",
|
||||
suffix=suffix,
|
||||
dir=directory,
|
||||
text=True,
|
||||
)
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as file:
|
||||
descriptor = -1
|
||||
file.write(content)
|
||||
file.flush()
|
||||
os.fsync(file.fileno())
|
||||
os.chmod(temporary_path, mode)
|
||||
return temporary_path
|
||||
except Exception as error:
|
||||
failures = []
|
||||
if descriptor >= 0:
|
||||
try:
|
||||
os.close(descriptor)
|
||||
except OSError as close_error:
|
||||
failures.append(f"close failed: {close_error}")
|
||||
failures.extend(_cleanup_paths((temporary_path,)))
|
||||
suffix = f"; cleanup failed: {'; '.join(failures)}" if failures else ""
|
||||
raise OSError(f"Stage {label}: {error}{suffix}") from error
|
||||
|
||||
|
||||
def _assert_unchanged(change: FileChange) -> None:
|
||||
snapshot = read_physical_file(
|
||||
change.file_path,
|
||||
change.label,
|
||||
change.maximum_bytes,
|
||||
)
|
||||
current = None if snapshot is None else snapshot.contents
|
||||
identity_changed = snapshot is not None and (
|
||||
snapshot.device != change.original_device
|
||||
or snapshot.inode != change.original_inode
|
||||
)
|
||||
if current != change.original or identity_changed:
|
||||
raise OSError(f"{change.label} changed during installation: {change.file_path}")
|
||||
|
||||
|
||||
def _assert_preconditions(
|
||||
preconditions: Sequence[FilePrecondition],
|
||||
operation: str,
|
||||
preconditions: Sequence[FilePrecondition] = (),
|
||||
directory_preconditions: Sequence[DirectoryPrecondition] = (),
|
||||
opaque_removals: Sequence[OpaqueRemovalChange] = (),
|
||||
) -> None:
|
||||
for condition in preconditions:
|
||||
current = read_physical_file(
|
||||
condition.file_path,
|
||||
condition.label,
|
||||
condition.maximum_bytes,
|
||||
)
|
||||
if not _same_snapshot(current, condition.original):
|
||||
raise OSError(
|
||||
f"{condition.label} changed during {operation}: "
|
||||
f"{condition.file_path}"
|
||||
)
|
||||
|
||||
|
||||
def _stage(change: FileChange) -> _StagedChange:
|
||||
_assert_unchanged(change)
|
||||
directory = os.path.dirname(change.file_path)
|
||||
ensure_physical_directory(directory, f"{change.label} directory")
|
||||
basename = os.path.basename(change.file_path)
|
||||
temporary_path = None
|
||||
rollback_path = None
|
||||
filtered = [
|
||||
change
|
||||
for change in changes
|
||||
if change.original != change.next_source
|
||||
or change.original_mode != change.mode
|
||||
]
|
||||
directories: DirectoryMap = {}
|
||||
staged: List[StagedChange] = []
|
||||
opaque_staged: List[StagedOpaqueRemoval] = []
|
||||
journal: Optional[TransactionJournal] = None
|
||||
committed_marker_durable = False
|
||||
try:
|
||||
if change.next_source is not None:
|
||||
temporary_path = _write_staged(
|
||||
directory,
|
||||
basename,
|
||||
".tmp",
|
||||
change.next_source,
|
||||
change.mode,
|
||||
change.label,
|
||||
)
|
||||
if change.original is not None and change.next_source is not None:
|
||||
rollback_path = _write_staged(
|
||||
directory,
|
||||
basename,
|
||||
".rollback",
|
||||
change.original,
|
||||
change.original_mode or change.mode,
|
||||
f"{change.label} rollback",
|
||||
)
|
||||
elif change.original is not None:
|
||||
rollback_path = os.path.join(
|
||||
directory,
|
||||
f".{basename}.{uuid4().hex}.rollback",
|
||||
)
|
||||
if os.path.lexists(rollback_path):
|
||||
raise FileExistsError(f"Rollback path already exists: {rollback_path}")
|
||||
return _StagedChange(change, temporary_path, rollback_path)
|
||||
except Exception as error:
|
||||
failures = _cleanup_paths((temporary_path, rollback_path))
|
||||
suffix = f"; cleanup failed: {'; '.join(failures)}" if failures else ""
|
||||
raise OSError(f"Stage {change.label}: {error}{suffix}") from error
|
||||
|
||||
|
||||
def _commit(staged: _StagedChange) -> None:
|
||||
_assert_unchanged(staged.change)
|
||||
if staged.change.next_source is None:
|
||||
if staged.rollback_path is None:
|
||||
raise OSError(f"Missing rollback path for {staged.change.label}")
|
||||
os.replace(staged.change.file_path, staged.rollback_path)
|
||||
else:
|
||||
if staged.temporary_path is None:
|
||||
raise OSError(f"Missing staged file for {staged.change.label}")
|
||||
os.replace(staged.temporary_path, staged.change.file_path)
|
||||
staged.committed = True
|
||||
current = read_physical_file(
|
||||
staged.change.file_path,
|
||||
staged.change.label,
|
||||
staged.change.maximum_bytes,
|
||||
)
|
||||
if staged.change.next_source is None:
|
||||
if current is not None:
|
||||
raise OSError(
|
||||
f"{staged.change.label} remained after removal: "
|
||||
f"{staged.change.file_path}"
|
||||
)
|
||||
return
|
||||
if current is None or current.contents != staged.change.next_source:
|
||||
raise OSError(
|
||||
f"{staged.change.label} changed immediately after commit: "
|
||||
f"{staged.change.file_path}"
|
||||
assert_transaction_preconditions(preconditions, directory_preconditions, label)
|
||||
if not filtered and not opaque_removals:
|
||||
return
|
||||
paths = [
|
||||
os.path.normcase(os.path.abspath(change.file_path)) for change in filtered
|
||||
]
|
||||
paths.extend(
|
||||
os.path.normcase(os.path.abspath(change.file_path))
|
||||
for change in opaque_removals
|
||||
)
|
||||
staged.committed_snapshot = current
|
||||
|
||||
|
||||
def _assert_committed_unchanged(staged: _StagedChange) -> None:
|
||||
current = read_physical_file(
|
||||
staged.change.file_path,
|
||||
staged.change.label,
|
||||
staged.change.maximum_bytes,
|
||||
)
|
||||
if staged.change.next_source is None:
|
||||
if current is not None:
|
||||
raise OSError(
|
||||
f"{staged.change.label} changed during transaction recovery: "
|
||||
f"{staged.change.file_path}"
|
||||
)
|
||||
return
|
||||
committed = staged.committed_snapshot
|
||||
if current is None or committed is None or (
|
||||
current.contents != committed.contents
|
||||
or current.device != committed.device
|
||||
or current.inode != committed.inode
|
||||
):
|
||||
raise OSError(
|
||||
f"{staged.change.label} changed during transaction recovery: "
|
||||
f"{staged.change.file_path}"
|
||||
)
|
||||
|
||||
|
||||
def _preserve_rollback(staged: _StagedChange, error: Exception) -> OSError:
|
||||
if staged.rollback_path is None:
|
||||
return error if isinstance(error, OSError) else OSError(str(error))
|
||||
rollback_path = staged.rollback_path
|
||||
staged.rollback_path = None
|
||||
return OSError(
|
||||
f"{error}; original content preserved at {rollback_path}"
|
||||
)
|
||||
|
||||
|
||||
def _rollback(staged: _StagedChange) -> None:
|
||||
if not staged.committed:
|
||||
return
|
||||
try:
|
||||
_assert_committed_unchanged(staged)
|
||||
except Exception as error:
|
||||
raise _preserve_rollback(staged, error) from error
|
||||
try:
|
||||
if staged.change.next_source is None or staged.change.original is not None:
|
||||
if staged.rollback_path is None:
|
||||
raise OSError(
|
||||
f"Missing rollback data for {staged.change.label}"
|
||||
if len(paths) != len(set(paths)):
|
||||
raise ValueError(f"{label} contains duplicate file targets")
|
||||
directories = pin_directories(
|
||||
filtered,
|
||||
preconditions,
|
||||
directory_preconditions,
|
||||
[
|
||||
(
|
||||
os.path.dirname(change.file_path),
|
||||
f"{change.label} directory",
|
||||
True,
|
||||
)
|
||||
os.replace(staged.rollback_path, staged.change.file_path)
|
||||
staged.rollback_path = None
|
||||
else:
|
||||
_remove_optional(staged.change.file_path)
|
||||
for change in opaque_removals
|
||||
],
|
||||
)
|
||||
for opaque_change in opaque_removals:
|
||||
opaque_staged.append(
|
||||
stage_opaque_removal(
|
||||
opaque_change,
|
||||
directory_for(opaque_change.file_path, directories),
|
||||
label,
|
||||
)
|
||||
)
|
||||
for file_change_item in filtered:
|
||||
staged.append(
|
||||
reserve_change(
|
||||
file_change_item,
|
||||
preconditions,
|
||||
directory_preconditions,
|
||||
label,
|
||||
directories,
|
||||
)
|
||||
)
|
||||
records = [
|
||||
*(_opaque_journal_record(item) for item in opaque_staged),
|
||||
*(_text_journal_record(item) for item in staged),
|
||||
]
|
||||
journal = TransactionJournal(uuid4().hex, label, "preparing", records)
|
||||
write_journal(journal)
|
||||
for staged_item in staged:
|
||||
stage_change(staged_item)
|
||||
records = [
|
||||
*(_opaque_journal_record(item) for item in opaque_staged),
|
||||
*(_text_journal_record(item) for item in staged),
|
||||
]
|
||||
journal = TransactionJournal(
|
||||
journal.transaction_id,
|
||||
journal.label,
|
||||
journal.state,
|
||||
records,
|
||||
)
|
||||
write_journal(journal)
|
||||
assert_transaction_preconditions(
|
||||
preconditions, directory_preconditions, label, directories
|
||||
)
|
||||
for opaque_item in opaque_staged:
|
||||
assert_staged_opaque(opaque_item, label)
|
||||
for opaque_item in opaque_staged:
|
||||
prepare_opaque_rollback(opaque_item)
|
||||
for staged_item in staged:
|
||||
prepare_change(staged_item, _capture_physical)
|
||||
journal = journal.with_state("committing")
|
||||
write_journal(journal)
|
||||
for opaque_item in opaque_staged:
|
||||
commit_opaque_removal(opaque_item)
|
||||
for staged_item in staged:
|
||||
_commit(staged_item)
|
||||
assert_transaction_preconditions(
|
||||
preconditions, directory_preconditions, label, directories
|
||||
)
|
||||
for opaque_item in opaque_staged:
|
||||
assert_staged_opaque(opaque_item, label)
|
||||
committed_journal = journal.with_state("committed")
|
||||
write_journal(committed_journal)
|
||||
journal = committed_journal
|
||||
committed_marker_durable = True
|
||||
_after_journal_commit()
|
||||
except Exception as error:
|
||||
raise _preserve_rollback(staged, error) from error
|
||||
if committed_marker_durable:
|
||||
cleanup_errors = _finish_committed(
|
||||
staged,
|
||||
opaque_staged,
|
||||
directories,
|
||||
)
|
||||
suffix = (
|
||||
f"; cleanup pending in {active_journal_path()}: "
|
||||
f"{'; '.join(cleanup_errors)}"
|
||||
if cleanup_errors
|
||||
else ""
|
||||
)
|
||||
raise OSError(
|
||||
f"{label} committed before post-commit failure: {error}{suffix}"
|
||||
) from error
|
||||
recovery_errors = _recover(
|
||||
staged,
|
||||
opaque_staged,
|
||||
directories,
|
||||
directory_preconditions,
|
||||
)
|
||||
suffix = (
|
||||
f"; recovery failed: {'; '.join(recovery_errors)}"
|
||||
if recovery_errors
|
||||
else ""
|
||||
)
|
||||
if journal is not None and not recovery_errors:
|
||||
try:
|
||||
clear_journal()
|
||||
except OSError as journal_error:
|
||||
suffix += f"; journal cleanup failed: {journal_error}"
|
||||
elif journal is not None:
|
||||
suffix += f"; recovery journal preserved at {active_journal_path()}"
|
||||
raise OSError(f"{label}: {error}{suffix}") from error
|
||||
|
||||
|
||||
def _cleanup(staged: _StagedChange) -> None:
|
||||
_remove_optional(staged.temporary_path)
|
||||
_remove_optional(staged.rollback_path)
|
||||
cleanup_errors = _finish_committed(staged, opaque_staged, directories)
|
||||
if cleanup_errors:
|
||||
raise OSError(
|
||||
f"{label} committed; cleanup pending in {active_journal_path()}: "
|
||||
f"{'; '.join(cleanup_errors)}"
|
||||
)
|
||||
|
||||
|
||||
def write_changes(
|
||||
changes: Sequence[FileChange],
|
||||
label: str,
|
||||
preconditions: Sequence[FilePrecondition] = (),
|
||||
directory_preconditions: Sequence[DirectoryPrecondition] = (),
|
||||
opaque_removals: Sequence[OpaqueRemovalChange] = (),
|
||||
) -> None:
|
||||
filtered = [change for change in changes if change.original != change.next_source]
|
||||
if not filtered:
|
||||
return
|
||||
paths = [os.path.normcase(os.path.abspath(change.file_path)) for change in filtered]
|
||||
if len(paths) != len(set(paths)):
|
||||
raise ValueError(f"{label} contains duplicate file targets")
|
||||
staged: List[_StagedChange] = []
|
||||
try:
|
||||
_assert_preconditions(preconditions, label)
|
||||
for change in filtered:
|
||||
staged.append(_stage(change))
|
||||
_assert_preconditions(preconditions, label)
|
||||
for item in staged:
|
||||
_assert_preconditions(preconditions, label)
|
||||
_commit(item)
|
||||
_assert_preconditions(preconditions, label)
|
||||
except Exception as error:
|
||||
recovery_errors = []
|
||||
for item in reversed(staged):
|
||||
try:
|
||||
_rollback(item)
|
||||
except Exception as rollback_error:
|
||||
recovery_errors.append(str(rollback_error))
|
||||
for item in staged:
|
||||
try:
|
||||
_cleanup(item)
|
||||
except OSError as cleanup_error:
|
||||
recovery_errors.append(str(cleanup_error))
|
||||
suffix = (
|
||||
f"; recovery failed: {'; '.join(recovery_errors)}"
|
||||
if recovery_errors
|
||||
else ""
|
||||
with serialized_transactions():
|
||||
recover_pending_locked()
|
||||
_write_changes_locked(
|
||||
changes,
|
||||
label,
|
||||
preconditions,
|
||||
directory_preconditions,
|
||||
opaque_removals,
|
||||
)
|
||||
raise OSError(f"{label}: {error}{suffix}") from error
|
||||
cleanup_errors = []
|
||||
for item in staged:
|
||||
try:
|
||||
_cleanup(item)
|
||||
except OSError as error:
|
||||
cleanup_errors.append(str(error))
|
||||
if cleanup_errors:
|
||||
raise OSError(f"{label} cleanup failed: {'; '.join(cleanup_errors)}")
|
||||
|
||||
|
||||
def regular_file_exists(file_path: str, label: str) -> bool:
|
||||
|
||||
Reference in new issue
Block a user