diff --git a/nw/__init__.py b/nw/__init__.py index 72e03fca..4669f2c5 100644 --- a/nw/__init__.py +++ b/nw/__init__.py @@ -89,6 +89,7 @@ FolderStorage, MappingStorage, ProjectStorage, + adopt_tenant_id, as_project_storage, register_project_storage, ) @@ -276,6 +277,7 @@ "ProjectStorage", "FolderStorage", "MappingStorage", + "adopt_tenant_id", "as_project_storage", "register_project_storage", "StoredUnproducedOutput", diff --git a/nw/storage.py b/nw/storage.py index e85bc704..8a66a8c5 100644 --- a/nw/storage.py +++ b/nw/storage.py @@ -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] @@ -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", diff --git a/tests/test_tenant_id.py b/tests/test_tenant_id.py index facdd1be..41681746 100644 --- a/tests/test_tenant_id.py +++ b/tests/test_tenant_id.py @@ -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"}}