diff --git a/synapse/cli/build.py b/synapse/cli/build.py index 1e48540..076480e 100644 --- a/synapse/cli/build.py +++ b/synapse/cli/build.py @@ -3,6 +3,7 @@ import glob import json import os +import platform import re import shutil import subprocess @@ -41,7 +42,9 @@ def validate_manifest(manifest_path: str) -> dict[str, Any] | Literal[False]: def detect_arch() -> str: """Return an architecture tag suffix (``arm64`` or ``amd64``).""" - arch = subprocess.check_output(["uname", "-m"]).decode("utf-8").strip() + # platform.machine() reports e.g. "arm64" (macOS), "aarch64" (Linux), + # or "ARM64"/"AMD64" (Windows), so compare case-insensitively. + arch = platform.machine().lower() return "arm64" if arch in ("arm64", "aarch64") else "amd64" @@ -205,6 +208,27 @@ def build_docker_image( return tags +def locate_app_binary(app_dir: str, app_name: str) -> str | None: + """Return the path of the first file named *app_name* under *app_dir*. + + Pure-Python replacement for the previous + ``find -type f -name -not -path '*/.*'`` shell-out + (which does not exist on Windows): regular files only, exact name match, + hidden directories and hidden names excluded. Returns ``None`` when no + match is found. + """ + if app_name.startswith("."): + return None + + for root, dirnames, filenames in os.walk(app_dir): + dirnames[:] = [d for d in dirnames if not d.startswith(".")] + if app_name in filenames: + candidate = os.path.join(root, app_name) + if os.path.isfile(candidate): + return candidate + return None + + def build_app( app_dir: str, app_name: str, force_rebuild: bool = False, clean: bool = False ) -> bool: @@ -331,25 +355,8 @@ def build_app( ) try: - binary_found = subprocess.run( - [ - "find", - app_dir, - "-type", - "f", - "-name", - app_name, - "-not", - "-path", - "*/.*", - ], - capture_output=True, - text=True, - check=False, - ).stdout.strip() - - if binary_found: - located = binary_found.split("\n")[0] + located = locate_app_binary(app_dir, app_name) + if located: build_dir = os.path.dirname(binary_path) os.makedirs(build_dir, exist_ok=True) shutil.copy(located, binary_path) diff --git a/synapse/tests/cli/test_locate_app_binary.py b/synapse/tests/cli/test_locate_app_binary.py new file mode 100644 index 0000000..df925a5 --- /dev/null +++ b/synapse/tests/cli/test_locate_app_binary.py @@ -0,0 +1,116 @@ +"""Tests for ``locate_app_binary``, the pure-Python replacement for the +``find -type f -name -not -path '*/.*'`` shell-out. + +The behavioral tests below encode the old ``find`` invocation's semantics so +they run on every platform (including Windows, where ``find`` is a different +program entirely). ``test_parity_with_gnu_find`` additionally runs the real +``find`` command side by side on POSIX hosts to demonstrate identical +functionality against the original implementation. +""" + +from __future__ import annotations + +import os +import subprocess +import sys + +import pytest + +from synapse.cli.build import locate_app_binary + + +APP = "myapp" + + +def _touch(*parts): + path = os.path.join(*[str(p) for p in parts]) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as fh: + fh.write("bin") + return path + + +def test_finds_binary_in_nested_build_dir(tmp_path): + expected = _touch(tmp_path, "build", "aarch64", "release", APP) + assert locate_app_binary(str(tmp_path), APP) == expected + + +def test_returns_none_when_absent(tmp_path): + _touch(tmp_path, "build", "other-binary") + assert locate_app_binary(str(tmp_path), APP) is None + + +def test_hidden_directories_are_skipped(tmp_path): + # A match buried under a hidden dir (e.g. .git) must not win over a + # visible one -- and must not be returned at all. + _touch(tmp_path, ".git", "objects", APP) + visible = _touch(tmp_path, "build", APP) + assert locate_app_binary(str(tmp_path), APP) == visible + + +def test_match_only_under_hidden_directory_returns_none(tmp_path): + _touch(tmp_path, ".cache", APP) + assert locate_app_binary(str(tmp_path), APP) is None + + +def test_exact_name_match_only(tmp_path): + _touch(tmp_path, "build", APP + ".o") + _touch(tmp_path, "build", "lib" + APP) + assert locate_app_binary(str(tmp_path), APP) is None + + +def test_directory_with_matching_name_is_not_a_match(tmp_path): + # `find -type f` never matches directories. + os.makedirs(os.path.join(str(tmp_path), "build", APP)) + assert locate_app_binary(str(tmp_path), APP) is None + + +def test_hidden_app_name_returns_none(tmp_path): + # `-not -path '*/.*'` also excluded hidden *files*. + _touch(tmp_path, "build", ".hidden") + assert locate_app_binary(str(tmp_path), ".hidden") is None + + +def test_root_level_match_wins_over_nested(tmp_path): + # os.walk yields the walk root first, so a top-level match is returned + # before any nested one. (With multiple matches the old `find` contract + # was only ever "some match" -- its first output line -- and the caller + # copies whichever match is returned to the canonical location.) + root_match = _touch(tmp_path, APP) + _touch(tmp_path, "build", APP) + assert locate_app_binary(str(tmp_path), APP) == root_match + + +@pytest.mark.skipif( + sys.platform == "win32", reason="POSIX `find` is not available on Windows" +) +@pytest.mark.parametrize( + "layout", + [ + [], # empty tree + ["build/aarch64/" + APP], # the common case + [".git/" + APP], # only a hidden match + ["a/" + APP, "b/" + APP, APP], # multiple matches + ["build/" + APP + ".dbg", "docs/readme"], # near-misses only + ], +) +def test_parity_with_gnu_find(tmp_path, layout): + """The exact `find` argv that locate_app_binary replaced is run against the same tree to verify identical behavior.""" + for rel in layout: + _touch(tmp_path, *rel.split("/")) + + find_out = subprocess.run( + ["find", str(tmp_path), "-type", "f", "-name", APP, + "-not", "-path", "*/.*"], + capture_output=True, + text=True, + check=False, + ).stdout.strip() + find_matches = find_out.split("\n") if find_out else [] + + result = locate_app_binary(str(tmp_path), APP) + + if not find_matches: + assert result is None + else: + assert result in find_matches