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 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