Skip to content
Merged
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
2 changes: 2 additions & 0 deletions nw/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
FolderStorage,
MappingStorage,
ProjectStorage,
adopt_tenant_id,
as_project_storage,
register_project_storage,
)
Expand Down Expand Up @@ -276,6 +277,7 @@
"ProjectStorage",
"FolderStorage",
"MappingStorage",
"adopt_tenant_id",
"as_project_storage",
"register_project_storage",
"StoredUnproducedOutput",
Expand Down
52 changes: 52 additions & 0 deletions nw/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,57 @@ def tenant_id_of(root: str | Path, spec: Optional[Mapping[str, Any]]) -> str:
return asset_id_of(root, spec)


def adopt_tenant_id(project: Any, *, dry_run: bool = False) -> Optional[str]:
"""Pin a legacy project's historical tenant into ``project.json`` (nw#112).

A project without a minted ``tenant_id`` is keyed on root + title (or the
song's hash), so a folder removed and recreated by hand at the same path
and title would inherit the old Postgres graph. This writes the key the
project *currently resolves to* as its ``tenant_id``: the tenant, and so
the data it already has, is unchanged, but the key stops depending on
root + title.

One-shot and idempotent, run by the host or a migration command (nw never
calls it): a project that already has a ``tenant_id`` is left alone.
Run it while the project is intact, since the song-hash form reads the
audio file. It only adds a key to ``project.json``; it moves and deletes
no graph data.

Returns the adopted id, the already-minted one, or ``None`` when ``project``
has no ``project.json`` or keeps its own keying (no ``tenant_id``). Raises
``FileNotFoundError`` when the song audio its tenant hashes is missing.
With ``dry_run`` nothing is written.

>>> import tempfile, json, pathlib
>>> root = pathlib.Path(tempfile.mkdtemp())
>>> _ = (root / "project.json").write_text(json.dumps({"title": "t"}))
>>> adopt_tenant_id(root) == adopt_tenant_id(root)
True
"""
storage = as_project_storage(project)
doc = _project_doc(storage.docs)
if doc is None:
return None
if isinstance(doc.get(TENANT_KEY), str) and doc[TENANT_KEY]:
return doc[TENANT_KEY]
if not hasattr(storage, "tenant_id"):
return None # a storage that owns its own keying (e.g. MappingStorage)
song = doc.get("song")
if isinstance(song, dict) and song.get("audio_path"):
sp = Path(song["audio_path"])
if not (sp if sp.is_absolute() else Path(storage.root) / sp).exists():
raise FileNotFoundError(
f"cannot adopt a tenant id: the project's song audio "
f"({song['audio_path']!r}) is missing, so its historical tenant "
"(the song hash) cannot be computed; adopting now would pin the "
"wrong one. Restore the file and run again."
)
adopted = storage.tenant_id
if not dry_run:
storage.docs[PROJECT_FILE_NAME] = {**doc, TENANT_KEY: adopted}
return adopted


def _project_doc(docs: Mapping[str, Any]) -> Optional[Mapping[str, Any]]:
try:
return docs[PROJECT_FILE_NAME]
Expand Down Expand Up @@ -417,6 +468,7 @@ def as_project_storage(project: Any) -> ProjectStorage:
"PROJECT_FILE_NAME",
"ProjectLike",
"ProjectStorage",
"adopt_tenant_id",
"as_project_storage",
"asset_id_of",
"tenant_id_of",
Expand Down
79 changes: 79 additions & 0 deletions tests/test_tenant_id.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,82 @@ def _shot_body():
from nw.bodies import ShotBodyV1

return ShotBodyV1(shot_id="s01")


# --- adopting a legacy project's historical tenant (nw#112) -----------------


def _legacy(tmp_path, name="old"):
root = tmp_path / name
root.mkdir()
(root / "project.json").write_text(
json.dumps({"schema_version": 1, "title": "old"})
)
return root


def test_adopt_pins_the_historical_tenant_without_changing_it(tmp_path):
root = _legacy(tmp_path)
before = project_tenant_id(root)
assert nw.adopt_tenant_id(root) == before
assert json.loads((root / "project.json").read_text())[TENANT_KEY] == before
assert project_tenant_id(root) == before


def test_adopt_is_idempotent_and_leaves_a_minted_tenant_alone(tmp_path):
root = _legacy(tmp_path)
first = nw.adopt_tenant_id(root)
assert nw.adopt_tenant_id(root) == first
minted = nw.Project.init(tmp_path / "new", title="t")
mid = minted.storage.tenant_id
assert nw.adopt_tenant_id(minted) == mid
assert minted.storage.tenant_id == mid


def test_adopt_dry_run_writes_nothing(tmp_path):
root = _legacy(tmp_path)
assert nw.adopt_tenant_id(root, dry_run=True) == project_asset_id(root)
assert TENANT_KEY not in json.loads((root / "project.json").read_text())


def test_adopt_makes_the_key_independent_of_root_and_title(tmp_path, pg):
root = _legacy(tmp_path)
old = project_tenant_id(root)
nw.adopt_tenant_id(root)
# Retitled / moved: the adopted project keeps its tenant (and data).
spec = json.loads((root / "project.json").read_text())
spec["title"] = "renamed"
(root / "project.json").write_text(json.dumps(spec))
assert project_tenant_id(root) == old
moved = tmp_path / "moved"
shutil.copytree(root, moved)
assert project_tenant_id(moved) == old


def test_adopt_returns_none_for_a_folder_that_is_not_a_project(tmp_path):
(tmp_path / "empty").mkdir()
assert nw.adopt_tenant_id(tmp_path / "empty") is None


def test_adopt_refuses_when_the_song_audio_is_missing(tmp_path):
root = tmp_path / "song"
(root / "song").mkdir(parents=True)
audio = root / "song" / "a.wav"
audio.write_bytes(b"RIFFdata")
(root / "project.json").write_text(
json.dumps({"title": "t", "song": {"audio_path": "song/a.wav"}})
)
hashed = project_tenant_id(root)
audio.rename(root / "song" / "gone.wav")
with pytest.raises(FileNotFoundError):
nw.adopt_tenant_id(root)
assert TENANT_KEY not in json.loads((root / "project.json").read_text())
(root / "song" / "gone.wav").rename(audio)
assert nw.adopt_tenant_id(root) == hashed


def test_adopt_skips_a_storage_that_keys_itself(tmp_path):
docs = {"project.json": {"title": "t"}}
storage = nw.MappingStorage(root=tmp_path, graph={}, docs=docs)
assert nw.adopt_tenant_id(storage) is None
assert docs == {"project.json": {"title": "t"}}
Loading