kavacha 0.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kavacha/__init__.py +16 -0
- kavacha/_modidx.py +89 -0
- kavacha/build.py +143 -0
- kavacha/bundle.py +102 -0
- kavacha/icons.py +81 -0
- kavacha/menus.py +44 -0
- kavacha/probe.py +93 -0
- kavacha/spec.py +106 -0
- kavacha/window.py +523 -0
- kavacha-0.0.2.dist-info/METADATA +125 -0
- kavacha-0.0.2.dist-info/RECORD +14 -0
- kavacha-0.0.2.dist-info/WHEEL +4 -0
- kavacha-0.0.2.dist-info/entry_points.txt +2 -0
- kavacha-0.0.2.dist-info/licenses/LICENSE +201 -0
kavacha/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
__version__ = "0.0.2"
|
|
2
|
+
|
|
3
|
+
# The names a caller reaches for. `spec`, `build`, `bundle` and `probe` need nothing but fastcore,
|
|
4
|
+
# so describing an app and asking what a build would do works on a machine that cannot build one.
|
|
5
|
+
from .probe import backend, framework_python, is_framework, running_from, shell_ready, wait_for_http
|
|
6
|
+
from .spec import App, doc_types, mypyc_modules, tree
|
|
7
|
+
from .bundle import finish, frozen_distribution, graft, link_duplicates, strip_zip
|
|
8
|
+
from .build import build, check, lock_requirements, read_stamp, write_stamp
|
|
9
|
+
# The vocabulary a host builds its menu table in. Dataclasses over stdlib, so importing them
|
|
10
|
+
# costs no pywebview and no AppKit.
|
|
11
|
+
from .menus import Binding, Js, MenuItem, Std
|
|
12
|
+
|
|
13
|
+
__all__ = ['backend', 'framework_python', 'is_framework', 'running_from', 'shell_ready',
|
|
14
|
+
'wait_for_http', 'App', 'doc_types', 'mypyc_modules', 'tree', 'finish',
|
|
15
|
+
'frozen_distribution', 'graft', 'link_duplicates', 'strip_zip', 'build', 'check',
|
|
16
|
+
'lock_requirements', 'read_stamp', 'write_stamp', 'Binding', 'Js', 'MenuItem', 'Std']
|
kavacha/_modidx.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Autogenerated by nbdev
|
|
2
|
+
|
|
3
|
+
d = { 'settings': { 'branch': 'main',
|
|
4
|
+
'doc_baseurl': '/kavacha',
|
|
5
|
+
'doc_host': 'https://vedicreader.github.io',
|
|
6
|
+
'git_url': 'https://github.com/vedicreader/kavacha',
|
|
7
|
+
'lib_path': 'kavacha'},
|
|
8
|
+
'syms': { 'kavacha.build': { 'kavacha.build.build': ('build.html#build', 'kavacha/build.py'),
|
|
9
|
+
'kavacha.build.build_venv': ('build.html#build_venv', 'kavacha/build.py'),
|
|
10
|
+
'kavacha.build.check': ('build.html#check', 'kavacha/build.py'),
|
|
11
|
+
'kavacha.build.git_stamp': ('build.html#git_stamp', 'kavacha/build.py'),
|
|
12
|
+
'kavacha.build.lock_requirements': ('build.html#lock_requirements', 'kavacha/build.py'),
|
|
13
|
+
'kavacha.build.read_stamp': ('build.html#read_stamp', 'kavacha/build.py'),
|
|
14
|
+
'kavacha.build.run': ('build.html#run', 'kavacha/build.py'),
|
|
15
|
+
'kavacha.build.stamp_path': ('build.html#stamp_path', 'kavacha/build.py'),
|
|
16
|
+
'kavacha.build.write_stamp': ('build.html#write_stamp', 'kavacha/build.py')},
|
|
17
|
+
'kavacha.bundle': { 'kavacha.bundle._lib_dir': ('bundle.html#_lib_dir', 'kavacha/bundle.py'),
|
|
18
|
+
'kavacha.bundle.finish': ('bundle.html#finish', 'kavacha/bundle.py'),
|
|
19
|
+
'kavacha.bundle.frozen_distribution': ('bundle.html#frozen_distribution', 'kavacha/bundle.py'),
|
|
20
|
+
'kavacha.bundle.graft': ('bundle.html#graft', 'kavacha/bundle.py'),
|
|
21
|
+
'kavacha.bundle.install_modern_icon': ('bundle.html#install_modern_icon', 'kavacha/bundle.py'),
|
|
22
|
+
'kavacha.bundle.link_duplicates': ('bundle.html#link_duplicates', 'kavacha/bundle.py'),
|
|
23
|
+
'kavacha.bundle.strip_zip': ('bundle.html#strip_zip', 'kavacha/bundle.py')},
|
|
24
|
+
'kavacha.icons': { 'kavacha.icons._open': ('icons.html#_open', 'kavacha/icons.py'),
|
|
25
|
+
'kavacha.icons._resized': ('icons.html#_resized', 'kavacha/icons.py'),
|
|
26
|
+
'kavacha.icons.favicon': ('icons.html#favicon', 'kavacha/icons.py'),
|
|
27
|
+
'kavacha.icons.icns': ('icons.html#icns', 'kavacha/icons.py'),
|
|
28
|
+
'kavacha.icons.ico': ('icons.html#ico', 'kavacha/icons.py'),
|
|
29
|
+
'kavacha.icons.icons_for': ('icons.html#icons_for', 'kavacha/icons.py'),
|
|
30
|
+
'kavacha.icons.iconset': ('icons.html#iconset', 'kavacha/icons.py')},
|
|
31
|
+
'kavacha.menus': { 'kavacha.menus.Binding': ('menus.html#binding', 'kavacha/menus.py'),
|
|
32
|
+
'kavacha.menus.Binding.bound': ('menus.html#binding.bound', 'kavacha/menus.py'),
|
|
33
|
+
'kavacha.menus.Js': ('menus.html#js', 'kavacha/menus.py'),
|
|
34
|
+
'kavacha.menus.MenuItem': ('menus.html#menuitem', 'kavacha/menus.py'),
|
|
35
|
+
'kavacha.menus.MenuItem.__post_init__': ('menus.html#menuitem.__post_init__', 'kavacha/menus.py'),
|
|
36
|
+
'kavacha.menus.Std': ('menus.html#std', 'kavacha/menus.py')},
|
|
37
|
+
'kavacha.probe': { 'kavacha.probe.backend': ('probe.html#backend', 'kavacha/probe.py'),
|
|
38
|
+
'kavacha.probe.framework_python': ('probe.html#framework_python', 'kavacha/probe.py'),
|
|
39
|
+
'kavacha.probe.is_framework': ('probe.html#is_framework', 'kavacha/probe.py'),
|
|
40
|
+
'kavacha.probe.py_version': ('probe.html#py_version', 'kavacha/probe.py'),
|
|
41
|
+
'kavacha.probe.running_from': ('probe.html#running_from', 'kavacha/probe.py'),
|
|
42
|
+
'kavacha.probe.shell_ready': ('probe.html#shell_ready', 'kavacha/probe.py'),
|
|
43
|
+
'kavacha.probe.wait_for_http': ('probe.html#wait_for_http', 'kavacha/probe.py')},
|
|
44
|
+
'kavacha.spec': { 'kavacha.spec.App': ('spec.html#app', 'kavacha/spec.py'),
|
|
45
|
+
'kavacha.spec.App.__post_init__': ('spec.html#app.__post_init__', 'kavacha/spec.py'),
|
|
46
|
+
'kavacha.spec.App.bundle_name': ('spec.html#app.bundle_name', 'kavacha/spec.py'),
|
|
47
|
+
'kavacha.spec.App.excluded': ('spec.html#app.excluded', 'kavacha/spec.py'),
|
|
48
|
+
'kavacha.spec.App.info_plist': ('spec.html#app.info_plist', 'kavacha/spec.py'),
|
|
49
|
+
'kavacha.spec.App.out': ('spec.html#app.out', 'kavacha/spec.py'),
|
|
50
|
+
'kavacha.spec.App.py2app_options': ('spec.html#app.py2app_options', 'kavacha/spec.py'),
|
|
51
|
+
'kavacha.spec.App.py2exe_options': ('spec.html#app.py2exe_options', 'kavacha/spec.py'),
|
|
52
|
+
'kavacha.spec.doc_types': ('spec.html#doc_types', 'kavacha/spec.py'),
|
|
53
|
+
'kavacha.spec.mypyc_modules': ('spec.html#mypyc_modules', 'kavacha/spec.py'),
|
|
54
|
+
'kavacha.spec.tree': ('spec.html#tree', 'kavacha/spec.py')},
|
|
55
|
+
'kavacha.window': { 'kavacha.window.ShellApi': ('window.html#shellapi', 'kavacha/window.py'),
|
|
56
|
+
'kavacha.window.ShellApi.open_folder': ('window.html#shellapi.open_folder', 'kavacha/window.py'),
|
|
57
|
+
'kavacha.window.ShellApi.pick_folder': ('window.html#shellapi.pick_folder', 'kavacha/window.py'),
|
|
58
|
+
'kavacha.window.ShellApi.recent': ('window.html#shellapi.recent', 'kavacha/window.py'),
|
|
59
|
+
'kavacha.window._decorate': ('window.html#_decorate', 'kavacha/window.py'),
|
|
60
|
+
'kavacha.window._disabled': ('window.html#_disabled', 'kavacha/window.py'),
|
|
61
|
+
'kavacha.window._dock_delegates': ('window.html#_dock_delegates', 'kavacha/window.py'),
|
|
62
|
+
'kavacha.window._point': ('window.html#_point', 'kavacha/window.py'),
|
|
63
|
+
'kavacha.window._recent_target': ('window.html#_recent_target', 'kavacha/window.py'),
|
|
64
|
+
'kavacha.window._title': ('window.html#_title', 'kavacha/window.py'),
|
|
65
|
+
'kavacha.window._unreachable': ('window.html#_unreachable', 'kavacha/window.py'),
|
|
66
|
+
'kavacha.window.backend': ('window.html#backend', 'kavacha/window.py'),
|
|
67
|
+
'kavacha.window.dock_menu': ('window.html#dock_menu', 'kavacha/window.py'),
|
|
68
|
+
'kavacha.window.errstr': ('window.html#errstr', 'kavacha/window.py'),
|
|
69
|
+
'kavacha.window.eval_js': ('window.html#eval_js', 'kavacha/window.py'),
|
|
70
|
+
'kavacha.window.install_menu_patch': ('window.html#install_menu_patch', 'kavacha/window.py'),
|
|
71
|
+
'kavacha.window.keep_running_in_dock': ('window.html#keep_running_in_dock', 'kavacha/window.py'),
|
|
72
|
+
'kavacha.window.mac_key': ('window.html#mac_key', 'kavacha/window.py'),
|
|
73
|
+
'kavacha.window.make_window': ('window.html#make_window', 'kavacha/window.py'),
|
|
74
|
+
'kavacha.window.menu_bar': ('window.html#menu_bar', 'kavacha/window.py'),
|
|
75
|
+
'kavacha.window.menu_chord': ('window.html#menu_chord', 'kavacha/window.py'),
|
|
76
|
+
'kavacha.window.menus': ('window.html#menus', 'kavacha/window.py'),
|
|
77
|
+
'kavacha.window.off_main_thread': ('window.html#off_main_thread', 'kavacha/window.py'),
|
|
78
|
+
'kavacha.window.open_window': ('window.html#open_window', 'kavacha/window.py'),
|
|
79
|
+
'kavacha.window.quiet_text_substitution': ('window.html#quiet_text_substitution', 'kavacha/window.py'),
|
|
80
|
+
'kavacha.window.run_shell': ('window.html#run_shell', 'kavacha/window.py'),
|
|
81
|
+
'kavacha.window.shell_api': ('window.html#shell_api', 'kavacha/window.py'),
|
|
82
|
+
'kavacha.window.shell_ready': ('window.html#shell_ready', 'kavacha/window.py'),
|
|
83
|
+
'kavacha.window.splash': ('window.html#splash', 'kavacha/window.py'),
|
|
84
|
+
'kavacha.window.start_cdp': ('window.html#start_cdp', 'kavacha/window.py'),
|
|
85
|
+
'kavacha.window.use_app': ('window.html#use_app', 'kavacha/window.py'),
|
|
86
|
+
'kavacha.window.wait_for_http': ('window.html#wait_for_http', 'kavacha/window.py'),
|
|
87
|
+
'kavacha.window.warm_cdp': ('window.html#warm_cdp', 'kavacha/window.py'),
|
|
88
|
+
'kavacha.window.watch_open_events': ('window.html#watch_open_events', 'kavacha/window.py'),
|
|
89
|
+
'kavacha.window.window_size': ('window.html#window_size', 'kavacha/window.py')}}}
|
kavacha/build.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/03_build.ipynb.
|
|
2
|
+
|
|
3
|
+
# %% ../nbs/03_build.ipynb #ec24be20
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
import json, os, shutil, subprocess, sys, time
|
|
6
|
+
from fastcore.all import Path
|
|
7
|
+
|
|
8
|
+
from .probe import framework_python, is_framework, py_version, running_from
|
|
9
|
+
from .bundle import finish
|
|
10
|
+
|
|
11
|
+
# %% auto #0
|
|
12
|
+
__all__ = ['STAMP', 'REEXEC', 'run', 'lock_requirements', 'build_venv', 'git_stamp', 'stamp_path', 'write_stamp', 'read_stamp',
|
|
13
|
+
'check', 'build']
|
|
14
|
+
|
|
15
|
+
# %% ../nbs/03_build.ipynb #7b260a31
|
|
16
|
+
STAMP = 'build.json'
|
|
17
|
+
REEXEC = 'KAVACHA_BUILD_VENV'
|
|
18
|
+
|
|
19
|
+
def run(*args, **kw):
|
|
20
|
+
print('·', ' '.join(str(a) for a in args))
|
|
21
|
+
return subprocess.run([str(a) for a in args], check=True, **kw)
|
|
22
|
+
|
|
23
|
+
# %% ../nbs/03_build.ipynb #98cf7446
|
|
24
|
+
def lock_requirements(root, extras=(), out=None):
|
|
25
|
+
"Export `uv.lock` as pinned requirements, or return `None`."
|
|
26
|
+
root = Path(root)
|
|
27
|
+
if not (root/'uv.lock').exists(): return None
|
|
28
|
+
args = ['uv', 'export', '--frozen', '--no-dev', '--no-hashes', '--no-emit-project',
|
|
29
|
+
'--format', 'requirements-txt']
|
|
30
|
+
for e in extras: args += ['--extra', e]
|
|
31
|
+
try: got = subprocess.run(args, cwd=str(root), capture_output=True, text=True, timeout=300)
|
|
32
|
+
except (OSError, subprocess.SubprocessError): return None
|
|
33
|
+
if got.returncode or not got.stdout.strip():
|
|
34
|
+
print(f'· uv export failed; resolving from pyproject instead: {got.stderr.strip()[:200]}')
|
|
35
|
+
return None
|
|
36
|
+
out = Path(out or root/'packaging'/'.app-venv'/'app-requirements.txt')
|
|
37
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
out.write_text(got.stdout)
|
|
39
|
+
return out
|
|
40
|
+
|
|
41
|
+
# %% ../nbs/03_build.ipynb #0431c2e0
|
|
42
|
+
def build_venv(python, venv, root, extras=(), force=False):
|
|
43
|
+
"A plain build venv using `python`."
|
|
44
|
+
venv = Path(venv)
|
|
45
|
+
if force: shutil.rmtree(venv, ignore_errors=True)
|
|
46
|
+
exe = venv/'bin'/'python'
|
|
47
|
+
if exe.exists() and py_version(exe) != py_version(python):
|
|
48
|
+
print(f'· rebuilding {venv.name}: it is on {py_version(exe)}, this build wants {py_version(python)}')
|
|
49
|
+
shutil.rmtree(venv, ignore_errors=True)
|
|
50
|
+
if not exe.exists():
|
|
51
|
+
venv.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
run(python, '-m', 'venv', venv)
|
|
53
|
+
run(exe, '-m', 'pip', 'install', '--upgrade', '--quiet', 'pip', 'setuptools', 'wheel')
|
|
54
|
+
spec = f'{root}[{",".join(extras)}]' if extras else str(root)
|
|
55
|
+
if (req := lock_requirements(root, extras, venv/'app-requirements.txt')) is not None:
|
|
56
|
+
run(exe, '-m', 'pip', 'install', '--quiet', '--upgrade', '-r', req)
|
|
57
|
+
run(exe, '-m', 'pip', 'install', '--quiet', '--no-deps', '-e', spec)
|
|
58
|
+
else: run(exe, '-m', 'pip', 'install', '--quiet', '-e', spec)
|
|
59
|
+
return exe
|
|
60
|
+
|
|
61
|
+
# %% ../nbs/03_build.ipynb #43eee4ee
|
|
62
|
+
def git_stamp(root, version=''):
|
|
63
|
+
"The commit the tree is on and whether it is dirty, or `None` outside a checkout."
|
|
64
|
+
def git(*a):
|
|
65
|
+
r = subprocess.run(['git', *a], cwd=str(root), capture_output=True, text=True, timeout=30)
|
|
66
|
+
return r.stdout.strip() if not r.returncode else None
|
|
67
|
+
try: sha = git('rev-parse', 'HEAD')
|
|
68
|
+
except (OSError, subprocess.SubprocessError): return None
|
|
69
|
+
if not sha: return None
|
|
70
|
+
return {'commit': sha, 'dirty': bool(git('status', '--porcelain', '--untracked-files=no')),
|
|
71
|
+
'version': version, 'built': time.strftime('%Y-%m-%dT%H:%M:%S')}
|
|
72
|
+
|
|
73
|
+
def stamp_path(bundle):
|
|
74
|
+
"Where the build stamp lives inside `bundle`, on either platform."
|
|
75
|
+
b = Path(bundle)
|
|
76
|
+
return (b/'Contents'/'Resources'/STAMP) if b.suffix == '.app' else (b/STAMP)
|
|
77
|
+
|
|
78
|
+
def write_stamp(bundle, root, version=''):
|
|
79
|
+
"Write and return the bundle's source stamp."
|
|
80
|
+
if (st := git_stamp(root, version)) is None:
|
|
81
|
+
st = {'commit': '', 'dirty': False, 'version': version,
|
|
82
|
+
'built': time.strftime('%Y-%m-%dT%H:%M:%S')}
|
|
83
|
+
p = stamp_path(bundle)
|
|
84
|
+
if p.parent.is_dir(): p.write_text(json.dumps(st, indent=1) + '\n')
|
|
85
|
+
return st
|
|
86
|
+
|
|
87
|
+
def read_stamp(bundle):
|
|
88
|
+
"The stamp `bundle` was built with, or None when it has none."
|
|
89
|
+
try: return json.loads(stamp_path(bundle).read_text())
|
|
90
|
+
except (OSError, ValueError): return None
|
|
91
|
+
|
|
92
|
+
# %% ../nbs/03_build.ipynb #accc2d7f
|
|
93
|
+
def check(spec, root, venv=None):
|
|
94
|
+
"What a build would do here, without doing it. Answers on any platform, Linux included."
|
|
95
|
+
root, venv = Path(root), Path(venv or Path(root)/'packaging'/'.app-venv')
|
|
96
|
+
rows = {'platform': sys.platform, 'interpreter': sys.executable,
|
|
97
|
+
'app': spec.name, 'out': str(spec.out(root))}
|
|
98
|
+
if sys.platform not in ('darwin', 'win32'):
|
|
99
|
+
rows['freezer'] = f'none — {sys.platform} builds nothing; macOS and Windows build on their own OS'
|
|
100
|
+
else: rows['freezer'] = 'py2app' if sys.platform == 'darwin' else 'py2exe'
|
|
101
|
+
if sys.platform == 'darwin':
|
|
102
|
+
rows['framework_build'] = is_framework()
|
|
103
|
+
if not rows['framework_build']:
|
|
104
|
+
rows['would_rebuild'] = str(venv)
|
|
105
|
+
rows['on'] = str(framework_python() or '')
|
|
106
|
+
try:
|
|
107
|
+
import webview # noqa: F401
|
|
108
|
+
rows['pywebview'] = 'installed'
|
|
109
|
+
except ImportError: rows['pywebview'] = 'MISSING'
|
|
110
|
+
rows['running'] = running_from(spec.out(root))
|
|
111
|
+
return rows
|
|
112
|
+
|
|
113
|
+
# %% ../nbs/03_build.ipynb #6215af9c
|
|
114
|
+
def build(spec, root, setup_py, venv=None, alias=False, force=False, rebuild_venv=False,
|
|
115
|
+
identity=None):
|
|
116
|
+
"Build `spec` into `root/dist`, using framework Python on macOS."
|
|
117
|
+
root, venv = Path(root), Path(venv or Path(root)/'packaging'/'.app-venv')
|
|
118
|
+
out = spec.out(root)
|
|
119
|
+
if not force and (live := running_from(out)):
|
|
120
|
+
raise SystemExit(
|
|
121
|
+
f'{out} is running as pid {", ".join(map(str, live))}. Quit it first, or pass force.\n'
|
|
122
|
+
'A build replaces the standard library the running app imports from, and it fails from '
|
|
123
|
+
'then on with a zip error that says nothing about the build.')
|
|
124
|
+
if sys.platform == 'darwin' and not is_framework():
|
|
125
|
+
if os.environ.get(REEXEC):
|
|
126
|
+
raise SystemExit(f'{sys.executable} is still not a framework build; run `check` to see why')
|
|
127
|
+
if (found := framework_python()) is None:
|
|
128
|
+
raise SystemExit('py2app needs a framework Python, and this one is a standalone build '
|
|
129
|
+
'whose stdlib extension modules are not files.\n'
|
|
130
|
+
'Install one and try again: brew install python@3.12')
|
|
131
|
+
print(f'framework Python: {found}')
|
|
132
|
+
exe = build_venv(found, venv, root, spec.extras, force=rebuild_venv)
|
|
133
|
+
run(exe, sys.argv[0], *(['--alias'] if alias else []), env={**os.environ, REEXEC: '1'})
|
|
134
|
+
return out
|
|
135
|
+
args = [sys.executable, str(setup_py)] + (['py2app'] if sys.platform == 'darwin' else [])
|
|
136
|
+
if alias and sys.platform == 'darwin': args.append('--alias')
|
|
137
|
+
run(*args, cwd=str(root))
|
|
138
|
+
if out.exists() and sys.platform == 'darwin':
|
|
139
|
+
for name, where in finish(out, spec, identity).items(): print(f' {name}: {where}')
|
|
140
|
+
st = write_stamp(out, root, spec.version)
|
|
141
|
+
print(f"\nbuilt {out}\n from {st['commit'][:12]}"
|
|
142
|
+
f"{' with uncommitted changes' if st['dirty'] else ''}")
|
|
143
|
+
return out
|
kavacha/bundle.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/02_bundle.ipynb.
|
|
2
|
+
|
|
3
|
+
# %% ../nbs/02_bundle.ipynb #4767bcd3
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
import hashlib, os, shutil, sys, zipfile
|
|
6
|
+
from fastcore.all import Path, first
|
|
7
|
+
|
|
8
|
+
# %% auto #0
|
|
9
|
+
__all__ = ['graft', 'link_duplicates', 'strip_zip', 'install_modern_icon', 'finish', 'frozen_distribution']
|
|
10
|
+
|
|
11
|
+
# %% ../nbs/02_bundle.ipynb #2b553810
|
|
12
|
+
def graft(app, name, python=None):
|
|
13
|
+
"Put the installed `name` package into a built bundle."
|
|
14
|
+
import importlib.util
|
|
15
|
+
spec = importlib.util.find_spec(name)
|
|
16
|
+
if spec is None or not spec.submodule_search_locations:
|
|
17
|
+
raise SystemExit(f'cannot graft {name}: it is not installed here')
|
|
18
|
+
src = Path(first(spec.submodule_search_locations))
|
|
19
|
+
lib = _lib_dir(app)
|
|
20
|
+
want = 'python%d.%d' % (python or sys.version_info[:2])
|
|
21
|
+
if lib.name != want: raise SystemExit(f'cannot graft {name}: bundle is {lib.name}, this is {want}')
|
|
22
|
+
dest = lib/name
|
|
23
|
+
for stray in lib.glob(f'{name}.*'): stray.unlink()
|
|
24
|
+
shutil.rmtree(dest, ignore_errors=True)
|
|
25
|
+
shutil.copytree(src, dest, ignore=shutil.ignore_patterns('__pycache__', '*.pyc'))
|
|
26
|
+
return dest.relative_to(app)
|
|
27
|
+
|
|
28
|
+
def _lib_dir(app):
|
|
29
|
+
"The bundle's `lib/pythonX.Y`, on either platform's layout."
|
|
30
|
+
app = Path(app)
|
|
31
|
+
for base in (app/'Contents'/'Resources'/'lib', app/'lib', app):
|
|
32
|
+
if (hit := first(sorted(base.glob('python3.*')))) is not None: return Path(hit)
|
|
33
|
+
raise SystemExit(f'{app} has no lib/python3.* to write into')
|
|
34
|
+
|
|
35
|
+
# %% ../nbs/02_bundle.ipynb #f054e586
|
|
36
|
+
def link_duplicates(app, names, floor=1_000_000):
|
|
37
|
+
"Hardlink duplicate package files and return megabytes saved."
|
|
38
|
+
lib = _lib_dir(app)
|
|
39
|
+
seen, saved = {}, 0
|
|
40
|
+
for name in names:
|
|
41
|
+
for f in sorted((lib/name).rglob('*')):
|
|
42
|
+
if not f.is_file() or f.is_symlink() or f.stat().st_size < floor: continue
|
|
43
|
+
key = (f.stat().st_size, hashlib.sha256(f.read_bytes()).hexdigest())
|
|
44
|
+
if (other := seen.get(key)) is None: seen[key] = f; continue
|
|
45
|
+
if os.stat(f).st_ino == os.stat(other).st_ino: continue
|
|
46
|
+
saved += f.stat().st_size
|
|
47
|
+
f.unlink(); os.link(other, f)
|
|
48
|
+
return round(saved / 1e6)
|
|
49
|
+
|
|
50
|
+
# %% ../nbs/02_bundle.ipynb #9e0b3d82
|
|
51
|
+
def strip_zip(app, prefixes=()):
|
|
52
|
+
"Remove matching entries from the bundle archive and return megabytes saved."
|
|
53
|
+
z = first(sorted(_lib_dir(app).parent.glob('python3*.zip')))
|
|
54
|
+
if not z or not prefixes: return 0
|
|
55
|
+
prefixes = tuple(prefixes)
|
|
56
|
+
keep, dropped = [], 0
|
|
57
|
+
with zipfile.ZipFile(z) as src:
|
|
58
|
+
for info in src.infolist():
|
|
59
|
+
if info.filename.startswith(prefixes): dropped += info.compress_size; continue
|
|
60
|
+
keep.append((info, src.read(info.filename)))
|
|
61
|
+
if not dropped: return 0
|
|
62
|
+
tmp = Path(str(z) + '.new')
|
|
63
|
+
with zipfile.ZipFile(tmp, 'w', zipfile.ZIP_DEFLATED) as out:
|
|
64
|
+
for info, data in keep: out.writestr(info, data)
|
|
65
|
+
tmp.replace(z)
|
|
66
|
+
return round(dropped / 1e6)
|
|
67
|
+
|
|
68
|
+
# %% ../nbs/02_bundle.ipynb #22fd45d1
|
|
69
|
+
def install_modern_icon(app, icon, identity=None, deployment_target='14.0'):
|
|
70
|
+
"Install an Icon Composer icon before signing, retaining the ICNS fallback."
|
|
71
|
+
icon = Path(icon) if icon else None
|
|
72
|
+
if icon is None or not icon.is_dir(): return None
|
|
73
|
+
try: from iconmage import install_icon
|
|
74
|
+
except ImportError:
|
|
75
|
+
print(' iconmage not installed; keeping the ICNS icon alone '
|
|
76
|
+
'(uv tool install git+https://github.com/AnswerDotAI/iconmage.git)')
|
|
77
|
+
return None
|
|
78
|
+
try: return install_icon(icon, app, deployment_target=deployment_target, identity=identity)
|
|
79
|
+
except Exception as e:
|
|
80
|
+
print(f' Icon Composer icon not installed ({type(e).__name__}: {e}); keeping the ICNS one')
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
# %% ../nbs/02_bundle.ipynb #4f455d17
|
|
84
|
+
def finish(app, spec, identity=None):
|
|
85
|
+
"Everything a bundle needs after the freezer wrote it, in the order it needs it."
|
|
86
|
+
out = {}
|
|
87
|
+
for name in spec.grafted: out[name] = str(graft(app, name))
|
|
88
|
+
dropped = [*spec.unreachable, *[f'{n}/' for n in spec.grafted]]
|
|
89
|
+
out['stripped_mb'] = strip_zip(app, dropped)
|
|
90
|
+
out['linked_mb'] = link_duplicates(app, spec.grafted)
|
|
91
|
+
out['modern_icon'] = bool(install_modern_icon(app, spec.modern_icon, identity))
|
|
92
|
+
return out
|
|
93
|
+
|
|
94
|
+
# %% ../nbs/02_bundle.ipynb #8be45025
|
|
95
|
+
def frozen_distribution():
|
|
96
|
+
"Return a setuptools distribution that ignores project dependencies."
|
|
97
|
+
from setuptools.dist import Distribution
|
|
98
|
+
class Frozen(Distribution):
|
|
99
|
+
def parse_config_files(self, *args, **kwargs):
|
|
100
|
+
super().parse_config_files(*args, **kwargs)
|
|
101
|
+
self.install_requires = []
|
|
102
|
+
return Frozen
|
kavacha/icons.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/05_icons.ipynb.
|
|
2
|
+
|
|
3
|
+
# %% ../nbs/05_icons.ipynb #1752ff04
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
import subprocess
|
|
6
|
+
from fastcore.all import Path
|
|
7
|
+
|
|
8
|
+
# %% auto #0
|
|
9
|
+
__all__ = ['ICNS_SIZES', 'ICO_SIZES', 'iconset', 'icns', 'ico', 'favicon', 'icons_for']
|
|
10
|
+
|
|
11
|
+
# %% ../nbs/05_icons.ipynb #f00569e4
|
|
12
|
+
ICNS_SIZES = ((16, 'icon_16x16'), (32, 'icon_16x16@2x'), (32, 'icon_32x32'),
|
|
13
|
+
(64, 'icon_32x32@2x'), (128, 'icon_128x128'), (256, 'icon_128x128@2x'),
|
|
14
|
+
(256, 'icon_256x256'), (512, 'icon_256x256@2x'), (512, 'icon_512x512'),
|
|
15
|
+
(1024, 'icon_512x512@2x'))
|
|
16
|
+
|
|
17
|
+
ICO_SIZES = (16, 24, 32, 48, 64, 128, 256)
|
|
18
|
+
|
|
19
|
+
# %% ../nbs/05_icons.ipynb #894757de
|
|
20
|
+
def _open(src):
|
|
21
|
+
"The source image as RGBA, square, or the reason it cannot be used."
|
|
22
|
+
from PIL import Image
|
|
23
|
+
im = Image.open(src).convert('RGBA')
|
|
24
|
+
if im.width != im.height:
|
|
25
|
+
raise ValueError(f'{src} is {im.width}x{im.height}; an app icon has to be square')
|
|
26
|
+
if im.width < 512:
|
|
27
|
+
raise ValueError(f'{src} is {im.width}px; macOS draws icons at 1024 and will upscale this')
|
|
28
|
+
return im
|
|
29
|
+
|
|
30
|
+
def _resized(im, size):
|
|
31
|
+
from PIL import Image
|
|
32
|
+
return im.resize((size, size), Image.LANCZOS)
|
|
33
|
+
|
|
34
|
+
# %% ../nbs/05_icons.ipynb #c4c119cd
|
|
35
|
+
def iconset(src, out):
|
|
36
|
+
"The `.iconset` folder `iconutil` turns into an `.icns`. Returns the folder."
|
|
37
|
+
im, out = _open(src), Path(out)
|
|
38
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
for size, name in ICNS_SIZES: _resized(im, size).save(out/f'{name}.png')
|
|
40
|
+
return out
|
|
41
|
+
|
|
42
|
+
def icns(src, out, keep_iconset=False):
|
|
43
|
+
"Build an `.icns` with macOS `iconutil`."
|
|
44
|
+
out = Path(out)
|
|
45
|
+
folder = out.with_suffix('.iconset')
|
|
46
|
+
iconset(src, folder)
|
|
47
|
+
try: subprocess.run(['iconutil', '-c', 'icns', str(folder), '-o', str(out)], check=True,
|
|
48
|
+
capture_output=True)
|
|
49
|
+
except FileNotFoundError as e:
|
|
50
|
+
raise RuntimeError('an .icns is built by `iconutil`, which only macOS has') from e
|
|
51
|
+
finally:
|
|
52
|
+
if not keep_iconset:
|
|
53
|
+
for f in folder.glob('*.png'): f.unlink()
|
|
54
|
+
folder.rmdir()
|
|
55
|
+
return out
|
|
56
|
+
|
|
57
|
+
# %% ../nbs/05_icons.ipynb #7babd804
|
|
58
|
+
def ico(src, out, sizes=ICO_SIZES):
|
|
59
|
+
"A Windows `.ico` from one square image. Pillow writes every size into the one file."
|
|
60
|
+
im, out = _open(src), Path(out)
|
|
61
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
im.save(out, format='ICO', sizes=[(s, s) for s in sizes])
|
|
63
|
+
return out
|
|
64
|
+
|
|
65
|
+
# %% ../nbs/05_icons.ipynb #2e2ddd68
|
|
66
|
+
def favicon(src, out, size=64):
|
|
67
|
+
"Write a PNG favicon for the web UI."
|
|
68
|
+
out = Path(out)
|
|
69
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
_resized(_open(src), size).save(out)
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
def icons_for(src, assets, name):
|
|
74
|
+
"Every icon asset a bundle wants, written under `assets`. Skips the ones this platform cannot make."
|
|
75
|
+
import sys
|
|
76
|
+
assets, out = Path(assets), {}
|
|
77
|
+
assets.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
out['favicon'] = str(favicon(src, assets/'favicon.png'))
|
|
79
|
+
out['ico'] = str(ico(src, assets/f'{name}.ico'))
|
|
80
|
+
if sys.platform == 'darwin': out['icns'] = str(icns(src, assets/f'{name}.icns'))
|
|
81
|
+
return out
|
kavacha/menus.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/06_menus.ipynb.
|
|
2
|
+
|
|
3
|
+
# %% ../nbs/06_menus.ipynb #11fab8fe
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
# %% auto #0
|
|
8
|
+
__all__ = ['MenuItem', 'Std', 'Js', 'Binding']
|
|
9
|
+
|
|
10
|
+
# %% ../nbs/06_menus.ipynb #1a54959d
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class MenuItem:
|
|
13
|
+
"One action in a menu. A blank action is a separator."
|
|
14
|
+
action: str = ''
|
|
15
|
+
kind: str = ''
|
|
16
|
+
def __post_init__(self):
|
|
17
|
+
if self.kind not in ('', 'warn'):
|
|
18
|
+
raise ValueError(f'{self.action or "separator"}: bad menu kind {self.kind!r}')
|
|
19
|
+
|
|
20
|
+
# %% ../nbs/06_menus.ipynb #d30dc4ea
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Std:
|
|
23
|
+
"A row AppKit implements itself. The selector goes to the responder chain, target nil."
|
|
24
|
+
title: str
|
|
25
|
+
selector: str
|
|
26
|
+
key: str = ''
|
|
27
|
+
mods: str = 'mod'
|
|
28
|
+
|
|
29
|
+
# %% ../nbs/06_menus.ipynb #86983e95
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Js:
|
|
32
|
+
"A row no action backs, named by the expression it runs."
|
|
33
|
+
title: str
|
|
34
|
+
expr: str
|
|
35
|
+
|
|
36
|
+
# %% ../nbs/06_menus.ipynb #191bfb64
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Binding:
|
|
39
|
+
"What a menu row reads from the host's keymap: the chords, the label, and where it applies."
|
|
40
|
+
keys: tuple = ()
|
|
41
|
+
label: str = ''
|
|
42
|
+
scope: str = 'global'
|
|
43
|
+
@property
|
|
44
|
+
def bound(self): return bool(self.keys)
|
kavacha/probe.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/00_probe.ipynb.
|
|
2
|
+
|
|
3
|
+
# %% ../nbs/00_probe.ipynb #3b4b84e6
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
import json, os, subprocess, sys, sysconfig, time
|
|
6
|
+
from fastcore.all import Path, first
|
|
7
|
+
|
|
8
|
+
# %% auto #0
|
|
9
|
+
__all__ = ['BACKENDS', 'FRAMEWORK_CANDIDATES', 'backend', 'shell_ready', 'py_version', 'is_framework', 'framework_python',
|
|
10
|
+
'wait_for_http', 'running_from']
|
|
11
|
+
|
|
12
|
+
# %% ../nbs/00_probe.ipynb #64edb644
|
|
13
|
+
BACKENDS = {'darwin': 'cocoa', 'win32': 'edgechromium', 'linux': 'gtk'}
|
|
14
|
+
|
|
15
|
+
FRAMEWORK_CANDIDATES = (
|
|
16
|
+
'/Library/Frameworks/Python.framework/Versions/3.13/bin/python3.13',
|
|
17
|
+
'/Library/Frameworks/Python.framework/Versions/3.14/bin/python3.14',
|
|
18
|
+
'/Library/Frameworks/Python.framework/Versions/3.12/bin/python3.12',
|
|
19
|
+
'/opt/homebrew/opt/python@3.13/bin/python3.13',
|
|
20
|
+
'/opt/homebrew/opt/python@3.14/bin/python3.14',
|
|
21
|
+
'/opt/homebrew/opt/python@3.12/bin/python3.12',
|
|
22
|
+
'/usr/local/opt/python@3.13/bin/python3.13',
|
|
23
|
+
'/usr/local/opt/python@3.14/bin/python3.14',
|
|
24
|
+
'/usr/local/opt/python@3.12/bin/python3.12',
|
|
25
|
+
'/usr/bin/python3',
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# %% ../nbs/00_probe.ipynb #ce90fe5b
|
|
29
|
+
def backend(platform=None):
|
|
30
|
+
"pywebview's GUI name for `platform`, or None where there is no supported webview."
|
|
31
|
+
return BACKENDS.get(platform or sys.platform)
|
|
32
|
+
|
|
33
|
+
def shell_ready(platform=None):
|
|
34
|
+
"`(ok, why)`: whether a native window can be opened here, and what is missing if not."
|
|
35
|
+
gui = backend(platform)
|
|
36
|
+
if gui is None: return False, f'no native webview backend for {platform or sys.platform}'
|
|
37
|
+
try: import webview # noqa: F401
|
|
38
|
+
except ImportError as e: return False, f'pywebview is not installed ({e})'
|
|
39
|
+
return True, gui
|
|
40
|
+
|
|
41
|
+
# %% ../nbs/00_probe.ipynb #0de4f037
|
|
42
|
+
def py_version(python):
|
|
43
|
+
"The `(major, minor)` of `python`, or None when it will not say."
|
|
44
|
+
try: out = subprocess.run([str(python), '-c', 'import sys;print(*sys.version_info[:2])'],
|
|
45
|
+
capture_output=True, text=True, timeout=30)
|
|
46
|
+
except (OSError, subprocess.SubprocessError): return None
|
|
47
|
+
try: return tuple(int(n) for n in out.stdout.split()) or None
|
|
48
|
+
except ValueError: return None
|
|
49
|
+
|
|
50
|
+
def is_framework(python=None):
|
|
51
|
+
"Whether `python` (default: this interpreter) is a macOS framework build py2app can use."
|
|
52
|
+
if python is None: return bool(sysconfig.get_config_var('PYTHONFRAMEWORK'))
|
|
53
|
+
probe = ('import json,sysconfig,sys;'
|
|
54
|
+
"print(json.dumps([sysconfig.get_config_var('PYTHONFRAMEWORK') or '', "
|
|
55
|
+
'list(sys.version_info[:2])]))')
|
|
56
|
+
try: out = subprocess.run([str(python), '-c', probe], capture_output=True, text=True, timeout=30)
|
|
57
|
+
except (OSError, subprocess.SubprocessError): return False
|
|
58
|
+
if out.returncode: return False
|
|
59
|
+
try: name, version = json.loads(out.stdout.strip() or 'null')
|
|
60
|
+
except (ValueError, TypeError): return False
|
|
61
|
+
return bool(name) and tuple(version) >= (3, 12)
|
|
62
|
+
|
|
63
|
+
def framework_python(candidates=FRAMEWORK_CANDIDATES):
|
|
64
|
+
"The first framework interpreter on this machine that py2app can build against."
|
|
65
|
+
return first(p for c in candidates if (p := Path(c)).exists() and is_framework(p))
|
|
66
|
+
|
|
67
|
+
# %% ../nbs/00_probe.ipynb #8e6da7fd
|
|
68
|
+
def wait_for_http(url, timeout=60, interval=.1):
|
|
69
|
+
"Block until `url` answers, or `timeout` passes. True if the server came up."
|
|
70
|
+
import urllib.request
|
|
71
|
+
deadline = time.time() + timeout
|
|
72
|
+
while time.time() < deadline:
|
|
73
|
+
try:
|
|
74
|
+
urllib.request.urlopen(url, timeout=2)
|
|
75
|
+
return True
|
|
76
|
+
except Exception: time.sleep(interval)
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
# %% ../nbs/00_probe.ipynb #d18e585d
|
|
80
|
+
def running_from(bundle, ps_output=None):
|
|
81
|
+
"PIDs of processes whose executable is inside `bundle`."
|
|
82
|
+
target = str(Path(bundle).resolve())
|
|
83
|
+
if ps_output is None:
|
|
84
|
+
try: ps_output = subprocess.run(['ps', '-Ao', 'pid,command'], capture_output=True,
|
|
85
|
+
text=True, timeout=10).stdout
|
|
86
|
+
except (OSError, subprocess.SubprocessError): return []
|
|
87
|
+
pids = []
|
|
88
|
+
for line in ps_output.splitlines()[1:]:
|
|
89
|
+
pid, _, command = line.strip().partition(' ')
|
|
90
|
+
exe = command.split(' ', 1)[0]
|
|
91
|
+
if exe.startswith(target + os.sep) and pid.isdigit() and int(pid) != os.getpid():
|
|
92
|
+
pids.append(int(pid))
|
|
93
|
+
return pids
|