Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions python/src/notes_app/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,3 @@ def health() -> dict[str, str]:


app = create_app()


app = create_app()
10 changes: 10 additions & 0 deletions python/src/notes_app/models/dataset_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ class DatasetMetadata:
created: datetime
modified: datetime
tags: tuple[str, ...] = ()
status: str = ""
priority: int = 0
format: str = ""
path: str = ""
size_bytes: int = 0
Expand All @@ -37,6 +39,8 @@ def from_dataset(cls, dataset: Dataset) -> "DatasetMetadata":
created=dataset.created,
modified=dataset.modified,
tags=dataset.tags,
status=dataset.status,
priority=dataset.priority,
format=dataset.format,
path=cls._normalize_relative_path(dataset.path),
size_bytes=dataset.size_bytes,
Expand All @@ -54,6 +58,8 @@ def to_dataset(self) -> Dataset:
created=self.created,
modified=self.modified,
tags=self.tags,
status=self.status,
priority=self.priority,
format=self.format,
path=self._normalize_relative_path(self.path),
size_bytes=self.size_bytes,
Expand All @@ -72,6 +78,8 @@ def to_dict(self) -> dict[str, object]:
"created": self._to_iso(self.created),
"modified": self._to_iso(self.modified),
"tags": list(self.tags),
"status": self.status,
"priority": self.priority,
"format": self.format,
"path": self._normalize_relative_path(self.path),
"sizeBytes": self.size_bytes,
Expand Down Expand Up @@ -105,6 +113,8 @@ def from_dict(cls, data: dict[str, object]) -> "DatasetMetadata":
created=cls._from_iso(data.get("created")),
modified=cls._from_iso(data.get("modified"), fallback=cls._from_iso(data.get("created"))),
tags=cls._normalize_tags(data.get("tags", [])),
status=str(data.get("status") or ""),
priority=cls._to_int(data.get("priority"), default=0),
format=str(data.get("format") or ""),
path=cls._normalize_relative_path(str(data.get("path") or "")),
size_bytes=cls._to_int(data.get("sizeBytes"), default=0),
Expand Down
29 changes: 26 additions & 3 deletions python/src/notes_app/repositories/file_dataset_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import io
import json
import logging
import os
import tempfile
from dataclasses import replace
from datetime import datetime
from pathlib import Path
Expand Down Expand Up @@ -44,7 +46,7 @@ def save(self, item: Dataset) -> None:
"""Write (or overwrite) the sidecar for *item*."""
normalized = replace(item, path=DatasetMetadata._normalize_relative_path(item.path))
sidecar_path = self._sidecar_path_for(normalized)
sidecar_path.write_text(render_sidecar(normalized), encoding="utf-8")
self._write_text_atomic(sidecar_path, render_sidecar(normalized))

def list_all(self) -> Iterable[Dataset]:
results: list[Dataset] = []
Expand Down Expand Up @@ -130,7 +132,7 @@ def save_with_file(

# Write sidecar next to the data file
sidecar_path = self._datasets_dir / f"{relative_name}.dataset.yml"
sidecar_path.write_text(render_sidecar(updated), encoding="utf-8")
self._write_text_atomic(sidecar_path, render_sidecar(updated))

return updated

Expand Down Expand Up @@ -164,7 +166,7 @@ def save_profile(self, dataset: Dataset, profile: dict[str, object]) -> None:
raw_data = yaml.safe_load(sidecar_path.read_text(encoding="utf-8")) or {}
data = raw_data if isinstance(raw_data, dict) else {}
data["profile"] = profile
sidecar_path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
self._write_text_atomic(sidecar_path, yaml.safe_dump(data, sort_keys=False))
except (OSError, UnicodeDecodeError, yaml.YAMLError) as exc:
_LOG.warning("Unable to save profile for sidecar %s: %s", sidecar_path.name, exc)

Expand Down Expand Up @@ -193,6 +195,27 @@ def preview(self, dataset: Dataset, limit: int) -> dict[str, object]:
# Internal helpers
# ------------------------------------------------------------------

@staticmethod
def _write_text_atomic(path: Path, text: str, encoding: str = "utf-8") -> None:
"""Write *text* to *path* atomically using a temp file and ``os.replace``.

Ensures that readers never observe a partially-written sidecar file,
which would otherwise cause spurious 404 errors when a background
profiling thread rewrites the sidecar concurrently with a read request.
"""
dir_path = path.parent
fd, tmp_name = tempfile.mkstemp(dir=dir_path, suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding=encoding) as fh:
fh.write(text)
os.replace(tmp_name, path)
except Exception:
try:
os.unlink(tmp_name)
except OSError:
pass
raise

def _sidecar_path_for(self, dataset: Dataset) -> Path:
"""Return the sidecar path that corresponds to *dataset*."""
if dataset.path:
Expand Down