Skip to content

Commit 84c5406

Browse files
committed
fix: make module code hash content-only for portable .module archives
calculate_build_folder_hash baked the absolute build-folder path into the hash, so a required module's code hash changed when the project moved to another directory or machine. Consuming a .module archive elsewhere then reported a spurious 'code changes detected in required module <x>' and forced a re-render. Hash the code content only (relative paths + file contents). This also drops the hash_as_folder workaround from the previous commit, since a content-only hash is already stable across scratch extraction, in-place folders, and locations. Note: existing archives/metadata must be regenerated (stored hashes include the old path), and existing in-place multi-module projects will see a one-time 'required module code changed' on the next render.
1 parent 16141f1 commit 84c5406

3 files changed

Lines changed: 25 additions & 13 deletions

File tree

plain_modules.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -312,13 +312,10 @@ def get_module_non_functional_source_hash(self) -> str:
312312
return plain_spec.get_hash_value([stripped] + self.resources_list)
313313

314314
def get_module_code_hash(self) -> str:
315-
# Read content from the resolved (possibly scratch) folder, but pin the hashed folder
316-
# identity to the module's logical location so an archived module read from scratch hashes
317-
# identically to the same module rendered in place.
318-
logical_build_folder = os.path.join(self._default_module_folder, MODULE_CODE_SUBFOLDER)
319-
return ImplementationCodeHelpers.calculate_build_folder_hash(
320-
self.module_build_folder, hash_as_folder=logical_build_folder
321-
)
315+
# Content-only hash (see calculate_build_folder_hash): reading from the resolved (possibly
316+
# scratch) folder yields the same hash as the in-place folder and the same hash across
317+
# locations, so archived modules stay portable.
318+
return ImplementationCodeHelpers.calculate_build_folder_hash(self.module_build_folder)
322319

323320
def has_required_modules_code_changed(
324321
self,

render_machine/implementation_code_helpers.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,13 @@
77

88
class ImplementationCodeHelpers:
99
@staticmethod
10-
def calculate_build_folder_hash(build_folder: str, hash_as_folder: str | None = None) -> str:
11-
# ``hash_as_folder`` lets the caller pin the folder identity baked into the hash to the
12-
# module's logical location even when content is read from elsewhere (e.g. a scratch
13-
# extraction of a "<module>.module" archive), so the hash is stable across storage forms.
10+
def calculate_build_folder_hash(build_folder: str) -> str:
11+
# Hash the code content only (relative paths + file contents), NOT the build folder's
12+
# absolute path, so the hash is stable across directories and machines. This is required for
13+
# distributable "<module>.module" archives: a module's code hash must match regardless of
14+
# where plain_modules/ lives, or consuming an archive elsewhere falsely reports a code change.
1415
_, existing_files_content = ImplementationCodeHelpers.fetch_existing_files(build_folder)
15-
folder_key = hash_as_folder if hash_as_folder is not None else build_folder
16-
return plain_spec.hash_text(f"folder={folder_key}|{json.dumps(existing_files_content)}")
16+
return plain_spec.hash_text(json.dumps(existing_files_content))
1717

1818
@staticmethod
1919
def fetch_existing_files(build_folder: str):

tests/test_plain_modules.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,21 @@ def test_render_status_parity_folder_vs_archive(fixtures_dir, tmp_build_folder):
539539
module.cleanup_scratch()
540540

541541

542+
def test_code_hash_is_location_independent(fixtures_dir):
543+
"""The code hash depends only on content, not the build folder's absolute path, so it is stable
544+
across directories/machines. This is what makes .module archives distributable: consuming the
545+
same code at a different location must not report a spurious 'required module code changed'."""
546+
with tempfile.TemporaryDirectory() as build_a, tempfile.TemporaryDirectory() as build_b:
547+
mod_a = PlainModule("pr_solo.plain", build_a, [fixtures_dir])
548+
mod_b = PlainModule("pr_solo.plain", build_b, [fixtures_dir])
549+
for mod in (mod_a, mod_b):
550+
os.makedirs(mod.module_build_folder, exist_ok=True)
551+
(Path(mod.module_build_folder) / "main.py").write_text("print('hi')\n")
552+
553+
assert mod_a.module_build_folder != mod_b.module_build_folder
554+
assert mod_a.get_module_code_hash() == mod_b.get_module_code_hash()
555+
556+
542557
def test_materialize_rejects_non_zip(solo_module):
543558
with open(solo_module.module_archive_path, "w") as f:
544559
f.write("not a zip")

0 commit comments

Comments
 (0)