diff --git a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-darwin-arm64/setup.py b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-darwin-arm64/setup.py new file mode 100644 index 0000000000..1308702d08 --- /dev/null +++ b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-darwin-arm64/setup.py @@ -0,0 +1,24 @@ +"""Build script that ensures the CLI binary is executable before wheel packaging.""" + +import os +import stat +from pathlib import Path + +from setuptools import setup +from setuptools.command.build_py import build_py + + +class BuildPyWithExecutableBinary(build_py): + """Custom build_py that ensures the CLI binary has execute permissions.""" + + def run(self): + super().run() + # After files are copied to build dir, chmod the binary + if self.build_lib: + binary_path = Path(self.build_lib) / 'superdoc_sdk_cli_darwin_arm64' / 'bin' / 'superdoc' + if binary_path.exists(): + current_mode = binary_path.stat().st_mode + binary_path.chmod(current_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + +setup(cmdclass={'build_py': BuildPyWithExecutableBinary}) diff --git a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-darwin-arm64/superdoc_sdk_cli_darwin_arm64/__init__.py b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-darwin-arm64/superdoc_sdk_cli_darwin_arm64/__init__.py index b6060045c2..8bc4e036f5 100644 --- a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-darwin-arm64/superdoc_sdk_cli_darwin_arm64/__init__.py +++ b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-darwin-arm64/superdoc_sdk_cli_darwin_arm64/__init__.py @@ -10,15 +10,15 @@ def get_binary_path() -> str: - """Return the absolute path to the bundled CLI binary, ensuring it is executable.""" + """Return the absolute path to the bundled CLI binary. + + The binary is made executable at build time via setup.py's build_py hook, + so no runtime chmod is needed. + """ binary = resources.files(__package__).joinpath('bin', _BINARY_NAME) path = str(binary) if not os.path.isfile(path): raise FileNotFoundError(f'CLI binary not found: {path}') - if os.name != 'nt': - mode = os.stat(path).st_mode - os.chmod(path, mode | 0o111) - return path diff --git a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-darwin-x64/setup.py b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-darwin-x64/setup.py new file mode 100644 index 0000000000..ddce55109e --- /dev/null +++ b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-darwin-x64/setup.py @@ -0,0 +1,24 @@ +"""Build script that ensures the CLI binary is executable before wheel packaging.""" + +import os +import stat +from pathlib import Path + +from setuptools import setup +from setuptools.command.build_py import build_py + + +class BuildPyWithExecutableBinary(build_py): + """Custom build_py that ensures the CLI binary has execute permissions.""" + + def run(self): + super().run() + # After files are copied to build dir, chmod the binary + if self.build_lib: + binary_path = Path(self.build_lib) / 'superdoc_sdk_cli_darwin_x64' / 'bin' / 'superdoc' + if binary_path.exists(): + current_mode = binary_path.stat().st_mode + binary_path.chmod(current_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + +setup(cmdclass={'build_py': BuildPyWithExecutableBinary}) diff --git a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-darwin-x64/superdoc_sdk_cli_darwin_x64/__init__.py b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-darwin-x64/superdoc_sdk_cli_darwin_x64/__init__.py index f87d157145..3c8b0662c3 100644 --- a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-darwin-x64/superdoc_sdk_cli_darwin_x64/__init__.py +++ b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-darwin-x64/superdoc_sdk_cli_darwin_x64/__init__.py @@ -10,15 +10,15 @@ def get_binary_path() -> str: - """Return the absolute path to the bundled CLI binary, ensuring it is executable.""" + """Return the absolute path to the bundled CLI binary. + + The binary is made executable at build time via setup.py's build_py hook, + so no runtime chmod is needed. + """ binary = resources.files(__package__).joinpath('bin', _BINARY_NAME) path = str(binary) if not os.path.isfile(path): raise FileNotFoundError(f'CLI binary not found: {path}') - if os.name != 'nt': - mode = os.stat(path).st_mode - os.chmod(path, mode | 0o111) - return path diff --git a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-linux-arm64/setup.py b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-linux-arm64/setup.py new file mode 100644 index 0000000000..9596127a93 --- /dev/null +++ b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-linux-arm64/setup.py @@ -0,0 +1,24 @@ +"""Build script that ensures the CLI binary is executable before wheel packaging.""" + +import os +import stat +from pathlib import Path + +from setuptools import setup +from setuptools.command.build_py import build_py + + +class BuildPyWithExecutableBinary(build_py): + """Custom build_py that ensures the CLI binary has execute permissions.""" + + def run(self): + super().run() + # After files are copied to build dir, chmod the binary + if self.build_lib: + binary_path = Path(self.build_lib) / 'superdoc_sdk_cli_linux_arm64' / 'bin' / 'superdoc' + if binary_path.exists(): + current_mode = binary_path.stat().st_mode + binary_path.chmod(current_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + +setup(cmdclass={'build_py': BuildPyWithExecutableBinary}) diff --git a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-linux-arm64/superdoc_sdk_cli_linux_arm64/__init__.py b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-linux-arm64/superdoc_sdk_cli_linux_arm64/__init__.py index 2450ec23ca..1e86bbc9e4 100644 --- a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-linux-arm64/superdoc_sdk_cli_linux_arm64/__init__.py +++ b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-linux-arm64/superdoc_sdk_cli_linux_arm64/__init__.py @@ -10,15 +10,15 @@ def get_binary_path() -> str: - """Return the absolute path to the bundled CLI binary, ensuring it is executable.""" + """Return the absolute path to the bundled CLI binary. + + The binary is made executable at build time via setup.py's build_py hook, + so no runtime chmod is needed. + """ binary = resources.files(__package__).joinpath('bin', _BINARY_NAME) path = str(binary) if not os.path.isfile(path): raise FileNotFoundError(f'CLI binary not found: {path}') - if os.name != 'nt': - mode = os.stat(path).st_mode - os.chmod(path, mode | 0o111) - return path diff --git a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-linux-x64/setup.py b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-linux-x64/setup.py new file mode 100644 index 0000000000..f62b529235 --- /dev/null +++ b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-linux-x64/setup.py @@ -0,0 +1,24 @@ +"""Build script that ensures the CLI binary is executable before wheel packaging.""" + +import os +import stat +from pathlib import Path + +from setuptools import setup +from setuptools.command.build_py import build_py + + +class BuildPyWithExecutableBinary(build_py): + """Custom build_py that ensures the CLI binary has execute permissions.""" + + def run(self): + super().run() + # After files are copied to build dir, chmod the binary + if self.build_lib: + binary_path = Path(self.build_lib) / 'superdoc_sdk_cli_linux_x64' / 'bin' / 'superdoc' + if binary_path.exists(): + current_mode = binary_path.stat().st_mode + binary_path.chmod(current_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + +setup(cmdclass={'build_py': BuildPyWithExecutableBinary}) diff --git a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-linux-x64/superdoc_sdk_cli_linux_x64/__init__.py b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-linux-x64/superdoc_sdk_cli_linux_x64/__init__.py index fc2b45c915..1f67e774e3 100644 --- a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-linux-x64/superdoc_sdk_cli_linux_x64/__init__.py +++ b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-linux-x64/superdoc_sdk_cli_linux_x64/__init__.py @@ -10,15 +10,15 @@ def get_binary_path() -> str: - """Return the absolute path to the bundled CLI binary, ensuring it is executable.""" + """Return the absolute path to the bundled CLI binary. + + The binary is made executable at build time via setup.py's build_py hook, + so no runtime chmod is needed. + """ binary = resources.files(__package__).joinpath('bin', _BINARY_NAME) path = str(binary) if not os.path.isfile(path): raise FileNotFoundError(f'CLI binary not found: {path}') - if os.name != 'nt': - mode = os.stat(path).st_mode - os.chmod(path, mode | 0o111) - return path diff --git a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-windows-x64/setup.py b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-windows-x64/setup.py new file mode 100644 index 0000000000..a5017f49a2 --- /dev/null +++ b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-windows-x64/setup.py @@ -0,0 +1,9 @@ +"""Build script for Windows platform package. + +Windows doesn't use Unix file permissions, so no chmod is needed. +This setup.py exists for consistency across all platform packages. +""" + +from setuptools import setup + +setup() diff --git a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-windows-x64/superdoc_sdk_cli_windows_x64/__init__.py b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-windows-x64/superdoc_sdk_cli_windows_x64/__init__.py index 215ee2529a..4879fa91c4 100644 --- a/packages/sdk/langs/python/platforms/superdoc-sdk-cli-windows-x64/superdoc_sdk_cli_windows_x64/__init__.py +++ b/packages/sdk/langs/python/platforms/superdoc-sdk-cli-windows-x64/superdoc_sdk_cli_windows_x64/__init__.py @@ -10,15 +10,15 @@ def get_binary_path() -> str: - """Return the absolute path to the bundled CLI binary, ensuring it is executable.""" + """Return the absolute path to the bundled CLI binary. + + On Unix, the binary is made executable at build time via setup.py's build_py + hook. On Windows, executability is determined by file extension (.exe). + """ binary = resources.files(__package__).joinpath('bin', _BINARY_NAME) path = str(binary) if not os.path.isfile(path): raise FileNotFoundError(f'CLI binary not found: {path}') - if os.name != 'nt': - mode = os.stat(path).st_mode - os.chmod(path, mode | 0o111) - return path diff --git a/packages/sdk/langs/python/superdoc/embedded_cli.py b/packages/sdk/langs/python/superdoc/embedded_cli.py index cd8e1c04fc..ea4755d863 100644 --- a/packages/sdk/langs/python/superdoc/embedded_cli.py +++ b/packages/sdk/langs/python/superdoc/embedded_cli.py @@ -1,6 +1,5 @@ from __future__ import annotations -import os import platform from importlib import resources from pathlib import Path @@ -78,6 +77,11 @@ def _resolve_from_vendor_fallback(target: str) -> Optional[str]: def resolve_embedded_cli_path() -> str: + """Resolve the path to the embedded CLI binary for the current platform. + + The binary is made executable at build time via each companion package's + setup.py build_py hook, so no runtime chmod is needed. + """ target = _resolve_target() if target is None: raise SuperDocError( @@ -102,12 +106,4 @@ def resolve_embedded_cli_path() -> str: details={'target': target}, ) - # Ensure binary is executable on unix - if os.name != 'nt': - try: - mode = os.stat(path).st_mode - os.chmod(path, mode | 0o111) - except Exception: - pass - return path diff --git a/packages/sdk/scripts/verify-python-companion-wheels.mjs b/packages/sdk/scripts/verify-python-companion-wheels.mjs index 5df920415b..5fc695451f 100644 --- a/packages/sdk/scripts/verify-python-companion-wheels.mjs +++ b/packages/sdk/scripts/verify-python-companion-wheels.mjs @@ -31,6 +31,22 @@ async function listWheelEntries(wheelPath) { return JSON.parse(stdout); } +async function getWheelEntryMode(wheelPath, entryName) { + // Zip files store Unix permissions in the high 16 bits of external_attr. + // external_attr >> 16 gives the Unix mode (e.g., 0o755 = 493 decimal). + const python = [ + 'import sys, zipfile', + 'z = zipfile.ZipFile(sys.argv[1])', + 'info = z.getinfo(sys.argv[2])', + 'print(info.external_attr >> 16)', + ].join('; '); + const { stdout } = await execFileAsync('python3', ['-c', python, wheelPath, entryName], { + cwd: REPO_ROOT, + env: process.env, + }); + return parseInt(stdout.trim(), 10); +} + async function readWheelMetadata(wheelPath) { const python = [ 'import sys, zipfile', @@ -202,6 +218,16 @@ async function verifySingleCompanion(wheelPath, target, errors) { errors.push(`${target.id}: expected exactly 1 binary in bin/, found ${binEntries.length}: ${binEntries.join(', ')}`); } + // Verify binary has execute permissions (Unix mode & 0o111 != 0) + // Skip for Windows — executability is determined by .exe extension, not file mode. + if (!target.id.startsWith('windows-') && binEntries.includes(expectedBinary)) { + const mode = await getWheelEntryMode(wheelPath, expectedBinary); + const hasExecuteBit = (mode & 0o111) !== 0; + if (!hasExecuteBit) { + errors.push(`${target.id}: binary missing execute permissions (mode=${mode.toString(8)})`); + } + } + console.log(` ${target.id} OK: ${path.basename(wheelPath)} (${(wheelStat.size / 1e6).toFixed(1)} MB)`); }