From 87b5e3bdfb82398ed608f34e9d840d5cd3c0ce2d Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:43:48 +0000 Subject: [PATCH 1/2] Add adopt_tenant_id: pin a legacy project's historical tenant into project.json (#112) Co-Authored-By: Claude Sonnet 5.5 --- nw/__init__.py | 2 ++ nw/storage.py | 38 ++++++++++++++++++++++++++++ tests/test_tenant_id.py | 55 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+) 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..cef93da9 100644 --- a/nw/storage.py +++ b/nw/storage.py @@ -170,6 +170,43 @@ 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``. 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] + 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 +454,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..e7cdb316 100644 --- a/tests/test_tenant_id.py +++ b/tests/test_tenant_id.py @@ -102,3 +102,58 @@ 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 From a6586184e157906710685c048601c87fbb9d5731 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:45:51 +0000 Subject: [PATCH 2/2] adopt_tenant_id: refuse when the song audio is missing; skip self-keyed storages (review fixes, #112) Co-Authored-By: Claude Sonnet 5.5 --- nw/storage.py | 16 +++++++++++++++- tests/test_tenant_id.py | 24 ++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/nw/storage.py b/nw/storage.py index cef93da9..8a66a8c5 100644 --- a/nw/storage.py +++ b/nw/storage.py @@ -187,7 +187,9 @@ def adopt_tenant_id(project: Any, *, dry_run: bool = False) -> Optional[str]: no graph data. Returns the adopted id, the already-minted one, or ``None`` when ``project`` - has no ``project.json``. With ``dry_run`` nothing is written. + 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()) @@ -201,6 +203,18 @@ def adopt_tenant_id(project: Any, *, dry_run: bool = False) -> Optional[str]: 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} diff --git a/tests/test_tenant_id.py b/tests/test_tenant_id.py index e7cdb316..41681746 100644 --- a/tests/test_tenant_id.py +++ b/tests/test_tenant_id.py @@ -157,3 +157,27 @@ def test_adopt_makes_the_key_independent_of_root_and_title(tmp_path, pg): 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"}}