Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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():
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if the path doesn't exist it will just fail silently.

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})
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The four Unix setup.py files are byte-identical except for one string (the module name); the Windows one is a stub. Five files, one logical thing.

Duplication like this is fine in a code-only PR — a small surface, easy to revisit. This PR is infrastructure: wheel builds ship binaries to every consumer, and we're literally fixing a wheel-build bug right now. The next permission edge case will be fixed in one file and forgotten in the other three, and we'll ship asymmetric wheels.

Generate from python-embedded-cli-targets.mjs (already the source of truth), or extract a shared _build_helpers.py so each setup.py is a one-line shim. Worth doing in this PR — the duplication exists because of this change, and "we'll unify it later" is how five copies become eight.


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
Original file line number Diff line number Diff line change
@@ -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})
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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})
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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})
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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()
Original file line number Diff line number Diff line change
Expand Up @@ -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
14 changes: 5 additions & 9 deletions packages/sdk/langs/python/superdoc/embedded_cli.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from __future__ import annotations

import os
import platform
from importlib import resources
from pathlib import Path
Expand Down Expand Up @@ -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(
Expand All @@ -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
26 changes: 26 additions & 0 deletions packages/sdk/scripts/verify-python-companion-wheels.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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)`);
}

Expand Down
Loading