-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathsetup.py
More file actions
200 lines (170 loc) · 6.69 KB
/
Copy pathsetup.py
File metadata and controls
200 lines (170 loc) · 6.69 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
"""
py2app build configuration for Sabbel.
Usage:
uv run --extra build python setup.py py2app
"""
import os
import glob
import re
import sys
# modulegraph's AST visitor can hit Python's default recursion limit on
# deeply nested expressions found in packages like numpy/mlx.
sys.setrecursionlimit(10_000)
APP = ["sabbel/__main__.py"]
def _read_version():
"""Read __version__ from sabbel/__init__.py without importing it.
The plist requires a numeric "X.Y.Z" string; "dev" is not valid, so
fall back to "0.0.0" for unreleased builds.
"""
init = os.path.join(os.path.dirname(__file__), "sabbel", "__init__.py")
with open(init) as f:
match = re.search(r'^__version__\s*=\s*"([^"]+)"', f.read(), re.M)
version = match.group(1) if match else "0.0.0"
return "0.0.0" if version == "dev" else version
_BUNDLE_VERSION = _read_version()
# ---------------------------------------------------------------------------
# Locate native libraries that py2app won't discover automatically
# ---------------------------------------------------------------------------
def _find_in_venv(pattern):
"""Find a file matching *pattern* inside the active venv's site-packages."""
import site
for sp in site.getsitepackages():
matches = glob.glob(os.path.join(sp, pattern))
if matches:
return matches[0]
# Fallback: walk from the venv root
venv = os.environ.get("VIRTUAL_ENV", os.path.join(os.path.dirname(__file__), ".venv"))
for root, _dirs, files in os.walk(venv):
for f in files:
if glob.fnmatch.fnmatch(f, os.path.basename(pattern)):
candidate = os.path.join(root, f)
if glob.fnmatch.fnmatch(candidate, f"*{pattern}"):
return candidate
return None
libmlx = _find_in_venv("mlx/lib/libmlx.dylib")
metallib = _find_in_venv("mlx/lib/mlx.metallib")
libportaudio = _find_in_venv("_sounddevice_data/portaudio-binaries/libportaudio.dylib")
def _relpath(p):
"""Convert an absolute path to a path relative to setup.py's directory."""
if p is None:
return None
return os.path.relpath(p, os.path.dirname(os.path.abspath(__file__)))
# frameworks are copied into Contents/Frameworks/ — only include actual
# Mach-O dylibs. mlx.metallib is a Metal shader archive (not Mach-O),
# so it must be copied separately (see Makefile post-build step).
frameworks = [_relpath(p) for p in (libmlx, libportaudio) if p]
# ---------------------------------------------------------------------------
# Parakeet engine dependencies
# ---------------------------------------------------------------------------
PARAKEET_PACKAGES = [
"parakeet_mlx",
"librosa", # parakeet_mlx.audio builds mel filterbanks with it
"dacite", # parakeet_mlx.utils.from_dict
"soundfile", # librosa I/O backend, ships a native libsndfile
"soxr", # librosa resampler, native
"lazy_loader", # librosa's submodule loading
"pooch",
"joblib",
"sklearn",
"scipy",
"numba",
"llvmlite",
"msgpack",
"audioread",
"decorator",
"narwhals",
"threadpoolctl",
"platformdirs",
]
def _installed_packages(names):
"""Keep only packages importable in this environment.
The list above is deliberately generous — librosa's dependency set differs
across versions, and naming a package py2app cannot find is a hard build
failure. Bundle contents therefore depend on what the build venv holds;
CI builds a fresh venv from the lock, so it is deterministic there.
"""
import importlib.util
keep = []
for name in names:
try:
if importlib.util.find_spec(name) is not None:
keep.append(name)
except Exception:
continue
return keep
# ---------------------------------------------------------------------------
# py2app options
# ---------------------------------------------------------------------------
OPTIONS = {
"iconfile": "icons/Sabbel.icns",
"packages": [
"sabbel",
"rumps",
"pynput",
"sounddevice",
"_sounddevice_data",
"numpy",
# mlx is a namespace package (no __init__.py) with a native .so
# core — py2app's modulegraph/imp.find_module cannot locate it.
# We omit it from packages and rely on the module graph to
# discover individual mlx modules through import analysis.
# Native libs (libmlx.dylib, mlx.metallib) are in frameworks.
] + _installed_packages(PARAKEET_PACKAGES),
"includes": [
"pynput.keyboard._darwin",
"pynput.mouse._darwin",
"pynput._util.darwin",
"AppKit",
"Foundation",
"Cocoa",
"Quartz",
"AVFoundation",
"HIServices",
"CoreFoundation",
"objc",
],
"frameworks": frameworks,
"plist": {
"CFBundleDisplayName": "Sabbel",
"CFBundleIdentifier": "com.sabbel.app",
"CFBundleName": "Sabbel",
"CFBundleShortVersionString": _BUNDLE_VERSION,
"CFBundleVersion": _BUNDLE_VERSION,
"LSMinimumSystemVersion": "14.0",
"LSUIElement": True,
# Belt-and-braces for the ASCII-locale bug; the actual fix is the
# setlocale call in sabbel/__main__.py. py2app's stub restores the
# launch-time LC_CTYPE *after* Py_Initialize(), and LaunchServices
# supplies none, so open() defaults to ASCII no matter what this
# variable says.
"LSEnvironment": {"PYTHONUTF8": "1"},
"NSMicrophoneUsageDescription": (
"Sabbel needs microphone access to transcribe your speech locally."
),
},
}
# ---------------------------------------------------------------------------
# Monkey-patch: setuptools reads pyproject.toml and populates install_requires
# on the Distribution *after* setup() merges our attrs. py2app explicitly
# rejects a non-empty install_requires. We clear it right before py2app's
# finalize_options runs.
#
# Only apply the patch when py2app is available (i.e., when we're actually
# building the .app, not when setuptools is inspecting setup.py for metadata).
# ---------------------------------------------------------------------------
try:
from py2app.build_app import py2app as _py2app_cmd
_orig_finalize = _py2app_cmd.finalize_options
def _patched_finalize(self):
self.distribution.install_requires = []
_orig_finalize(self)
_py2app_cmd.finalize_options = _patched_finalize
except ImportError:
pass # py2app not installed — setuptools is just reading metadata
# ---------------------------------------------------------------------------
from setuptools import setup
setup(
name="Sabbel",
app=APP,
options={"py2app": OPTIONS},
)