webapp-shell 0.1__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.
Files changed (59) hide show
  1. webapp_shell/__init__.py +50 -0
  2. webapp_shell/__main__.py +53 -0
  3. webapp_shell/backend.py +30 -0
  4. webapp_shell/backend_process.py +136 -0
  5. webapp_shell/bridge/webapp-shell-bridge.d.ts +48 -0
  6. webapp_shell/bridge/webapp-shell-bridge.js +146 -0
  7. webapp_shell/cli.py +76 -0
  8. webapp_shell/config.py +27 -0
  9. webapp_shell/contract/__init__.py +10 -0
  10. webapp_shell/contract/guard.py +106 -0
  11. webapp_shell/contract/launch.py +49 -0
  12. webapp_shell/contract/tokens.py +41 -0
  13. webapp_shell/enums.py +20 -0
  14. webapp_shell/freezing/__init__.py +10 -0
  15. webapp_shell/freezing/app.spec +5 -0
  16. webapp_shell/freezing/builder.py +82 -0
  17. webapp_shell/freezing/entry.py +24 -0
  18. webapp_shell/freezing/plan.py +38 -0
  19. webapp_shell/freezing/profile.py +49 -0
  20. webapp_shell/freezing/spec.py +68 -0
  21. webapp_shell/freezing/verify.py +43 -0
  22. webapp_shell/identity.py +48 -0
  23. webapp_shell/installation.py +41 -0
  24. webapp_shell/json_types.py +4 -0
  25. webapp_shell/library_check.py +28 -0
  26. webapp_shell/messages.py +58 -0
  27. webapp_shell/native/__init__.py +4 -0
  28. webapp_shell/native/arguments.py +131 -0
  29. webapp_shell/native/errors.py +5 -0
  30. webapp_shell/native/filters.py +24 -0
  31. webapp_shell/native/os_actions.py +33 -0
  32. webapp_shell/native/qt_bridge.py +62 -0
  33. webapp_shell/native/qt_window.py +34 -0
  34. webapp_shell/native/registry.py +91 -0
  35. webapp_shell/native/standard.py +89 -0
  36. webapp_shell/native/start_location.py +28 -0
  37. webapp_shell/native/webview_background.py +39 -0
  38. webapp_shell/native/webview_bridge.py +25 -0
  39. webapp_shell/native/webview_icon.py +32 -0
  40. webapp_shell/native/webview_window.py +54 -0
  41. webapp_shell/native/window.py +34 -0
  42. webapp_shell/native/zoom.py +104 -0
  43. webapp_shell/native/zoom_range.py +9 -0
  44. webapp_shell/preferences.py +84 -0
  45. webapp_shell/protocol.py +34 -0
  46. webapp_shell/py.typed +0 -0
  47. webapp_shell/qt_shell.py +126 -0
  48. webapp_shell/registry.py +20 -0
  49. webapp_shell/settings.py +78 -0
  50. webapp_shell/shell.py +119 -0
  51. webapp_shell/smoke.py +30 -0
  52. webapp_shell/splash.py +52 -0
  53. webapp_shell/watchdog.py +60 -0
  54. webapp_shell/webview2.py +80 -0
  55. webapp_shell/webview_shell.py +86 -0
  56. webapp_shell-0.1.dist-info/METADATA +132 -0
  57. webapp_shell-0.1.dist-info/RECORD +59 -0
  58. webapp_shell-0.1.dist-info/WHEEL +4 -0
  59. webapp_shell-0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,106 @@
1
+ """Access control for a Starlette or FastAPI backend: the launch token, from a header or a cookie.
2
+
3
+ app.add_middleware(TokenGuard, validator=TokenValidator(token, wire), wire=wire)
4
+
5
+ and for a WebSocket route, which middleware does not cover:
6
+
7
+ guard = WebSocketGuard(validator, wire)
8
+
9
+ @app.websocket("/api/events")
10
+ async def events(socket: WebSocket):
11
+ if not await guard.accept(socket):
12
+ return
13
+ ...
14
+
15
+ Needs `starlette` (`pip install "webapp-shell[backend]"`).
16
+ """
17
+
18
+ from dataclasses import dataclass, field
19
+
20
+ from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
21
+ from starlette.requests import Request
22
+ from starlette.responses import JSONResponse, RedirectResponse, Response
23
+ from starlette.types import ASGIApp
24
+ from starlette.websockets import WebSocket
25
+
26
+ __all__ = ["GuardResponses", "TokenGuard", "WebSocketGuard"]
27
+
28
+ from ..protocol import WireProtocol
29
+ from .tokens import OriginPolicy, TokenValidator
30
+
31
+ ORIGIN_HEADER = "origin"
32
+ HOST_HEADER = "host"
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class GuardResponses:
37
+ """What a refused request gets back. A project with its own error format replaces them."""
38
+
39
+ unauthorized: dict[str, str] = field(default_factory=lambda: {"code": "UNAUTHORIZED"})
40
+ foreign_origin: dict[str, str] = field(default_factory=lambda: {"code": "FOREIGN_ORIGIN"})
41
+
42
+
43
+ class TokenGuard(BaseHTTPMiddleware):
44
+ """Protects the API with the launch token.
45
+
46
+ Opening the app with `/?token=...` turns the token into a cookie and redirects to the clean URL,
47
+ so a launcher only has to open one URL and needs no cookie code of its own.
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ app: ASGIApp,
53
+ validator: TokenValidator,
54
+ wire: WireProtocol,
55
+ origins: OriginPolicy | None = None,
56
+ responses: GuardResponses | None = None,
57
+ ):
58
+ super().__init__(app)
59
+ self._validator = validator
60
+ self._wire = wire
61
+ self._origins = origins or OriginPolicy()
62
+ self._responses = responses or GuardResponses()
63
+
64
+ def _bearer(self, request: Request) -> str | None:
65
+ return request.headers.get(self._wire.token_header) or request.cookies.get(self._wire.token_cookie)
66
+
67
+ def _bootstrap_session(self, request: Request) -> RedirectResponse:
68
+ response = RedirectResponse(str(request.url.remove_query_params(self._wire.token_query)))
69
+ response.set_cookie(self._wire.token_cookie, self._validator.token, httponly=True, samesite="strict")
70
+ return response
71
+
72
+ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
73
+ wire = self._wire
74
+ path = request.url.path
75
+ in_api = path.startswith(wire.api_prefix)
76
+ if self._validator.is_valid(request.query_params.get(wire.token_query)) and not in_api:
77
+ return self._bootstrap_session(request)
78
+ if in_api and not self._origins.allows(request.headers.get(ORIGIN_HEADER), request.headers.get(HOST_HEADER)):
79
+ return JSONResponse(self._responses.foreign_origin, status_code=403)
80
+ if in_api and path != wire.health_path and not self._validator.is_valid(self._bearer(request)):
81
+ return JSONResponse(self._responses.unauthorized, status_code=401)
82
+ return await call_next(request)
83
+
84
+
85
+ class WebSocketGuard:
86
+ """The same rules for a socket as `TokenGuard` has for a request: the token (header, cookie or query string) and
87
+ the origin. A refused socket is closed with `wire.ws_close_unauthorized` before it is accepted."""
88
+
89
+ def __init__(self, validator: TokenValidator, wire: WireProtocol, origins: OriginPolicy | None = None):
90
+ self._validator = validator
91
+ self._wire = wire
92
+ self._origins = origins or OriginPolicy()
93
+
94
+ def allows(self, socket: WebSocket) -> bool:
95
+ from_own_page = self._origins.allows(socket.headers.get(ORIGIN_HEADER), socket.headers.get(HOST_HEADER))
96
+ return from_own_page and self._validator.is_valid_connection(
97
+ socket.headers, socket.cookies, socket.query_params
98
+ )
99
+
100
+ async def accept(self, socket: WebSocket) -> bool:
101
+ """Accepts the socket and returns True, or closes it and returns False: the route then just returns."""
102
+ if not self.allows(socket):
103
+ await socket.close(code=self._wire.ws_close_unauthorized)
104
+ return False
105
+ await socket.accept()
106
+ return True
@@ -0,0 +1,49 @@
1
+ """The command line the shell starts a backend with, read by the backend: the port, the compiled frontend, the
2
+ preferences file and the token. The argument names come from the same `BackendSpec` the shell builds the command from,
3
+ so the two cannot drift."""
4
+
5
+ import argparse
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from ..backend import BackendSpec
10
+ from ..protocol import WireProtocol
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class LaunchArguments:
15
+ port: int
16
+ token: str
17
+ frontend: Path | None
18
+ preferences_file: Path | None
19
+ host: str
20
+
21
+
22
+ def add_launch_arguments(parser: argparse.ArgumentParser, backend: BackendSpec) -> None:
23
+ """Adds the standard arguments to a backend's parser. Read them back with `LaunchArguments.of(args, ...)`."""
24
+ wire = backend.wire
25
+ parser.add_argument("--host", default="127.0.0.1")
26
+ parser.add_argument(backend.port_argument, dest="port", type=int, required=True)
27
+ parser.add_argument(
28
+ "--token",
29
+ default=wire.token_from_environment(),
30
+ help=f"the launch token; {wire.token_env} in the environment is safer, a command line is visible to other users",
31
+ )
32
+ parser.add_argument(backend.frontend_argument, dest="frontend", type=Path, default=None)
33
+ if backend.preferences_argument:
34
+ parser.add_argument(backend.preferences_argument, dest="preferences_file", type=Path, default=None)
35
+
36
+
37
+ def read_launch_arguments(
38
+ parser: argparse.ArgumentParser, args: argparse.Namespace, wire: WireProtocol
39
+ ) -> LaunchArguments:
40
+ """Checks the token is there (the parser reports the error) and gathers the standard arguments."""
41
+ if not args.token:
42
+ parser.error(f"a token is needed: --token or {wire.token_env}")
43
+ return LaunchArguments(
44
+ port=args.port,
45
+ token=args.token,
46
+ frontend=args.frontend,
47
+ preferences_file=getattr(args, "preferences_file", None),
48
+ host=args.host,
49
+ )
@@ -0,0 +1,41 @@
1
+ """Decides whether a candidate is the launch token, and whether a request comes from the app's own page. No HTTP
2
+ framework code here."""
3
+
4
+ import hmac
5
+ from collections.abc import Mapping
6
+ from urllib.parse import urlsplit
7
+
8
+ from ..protocol import WireProtocol
9
+
10
+
11
+ class TokenValidator:
12
+ def __init__(self, token: str, wire: WireProtocol):
13
+ self._token = token
14
+ self._wire = wire
15
+
16
+ @property
17
+ def token(self) -> str:
18
+ return self._token
19
+
20
+ def is_valid(self, candidate: str | None) -> bool:
21
+ # Compared as bytes: `compare_digest` refuses text with non-ASCII characters, which a caller controls.
22
+ return bool(candidate) and hmac.compare_digest(candidate.encode(), self._token.encode())
23
+
24
+ def is_valid_connection(
25
+ self, headers: Mapping[str, str], cookies: Mapping[str, str], query: Mapping[str, str]
26
+ ) -> bool:
27
+ """A socket can carry the token in a header (tools), the cookie (the browser) or the query string."""
28
+ wire = self._wire
29
+ candidate = headers.get(wire.token_header) or cookies.get(wire.token_cookie) or query.get(wire.token_query)
30
+ return self.is_valid(candidate)
31
+
32
+
33
+ class OriginPolicy:
34
+ """A browser tells which page made a request in `Origin`. Cookies are shared by every port of a host, so another
35
+ program's page on 127.0.0.1 would otherwise be allowed to call this one: only the app's own page is."""
36
+
37
+ def allows(self, origin: str | None, host: str | None) -> bool:
38
+ """True when the request names no origin (a tool, not a page) or the origin is the host being called."""
39
+ if origin is None:
40
+ return True
41
+ return bool(host) and urlsplit(origin).netloc == host
webapp_shell/enums.py ADDED
@@ -0,0 +1,20 @@
1
+ from enum import StrEnum
2
+
3
+
4
+ class ShellKind(StrEnum):
5
+ """The desktop shells. The value is what the command line and the run scripts use."""
6
+
7
+ QT = "qt"
8
+ WEBVIEW = "webview"
9
+
10
+
11
+ class NativeMethod(StrEnum):
12
+ """The native functions a page may call (02-architecture.md). The value is the name on the wire, in both shells."""
13
+
14
+ PICK_FOLDER = "pickFolder"
15
+ PICK_FILE = "pickFile"
16
+ SAVE_AS = "saveAs"
17
+ REVEAL = "reveal"
18
+ OPEN_URL = "openUrl"
19
+ SET_BOOT_COLORS = "setBootColors"
20
+ SET_ZOOM = "setZoom"
@@ -0,0 +1,10 @@
1
+ """Freezing a program into one folder with PyInstaller: the project writes a `FreezePlan`, the library does the rest.
2
+
3
+ python -m webapp_shell freeze acme_freeze:PLAN --path . qt webview
4
+
5
+ Needs `pyinstaller` (`pip install "webapp-shell[freeze]"`); only `webapp_shell.freezing.spec` imports it.
6
+ """
7
+
8
+ from .plan import DataFolder, FreezePlan
9
+
10
+ __all__ = ["DataFolder", "FreezePlan"]
@@ -0,0 +1,5 @@
1
+ # -*- mode: python ; coding: utf-8 -*-
2
+ # The one PyInstaller spec of webapp_shell: the plan and the shell come from the environment (see builder.py).
3
+ from webapp_shell.freezing.spec import freeze
4
+
5
+ freeze()
@@ -0,0 +1,82 @@
1
+ """Freezes the program for each shell and checks the result: `python -m webapp_shell freeze PLAN [qt] [webview]`.
2
+
3
+ Each shell is built into `<output>/<shell>/<product>/`: the other shell's libraries must not be in it, the files the
4
+ plan requires must be, and the frozen program's own `--check-libraries` must pass. Nothing here makes an installer.
5
+ """
6
+
7
+ import argparse
8
+ import importlib.util
9
+ import os
10
+ import subprocess
11
+ import sys
12
+ from importlib import resources
13
+ from pathlib import Path
14
+
15
+ from ..enums import ShellKind
16
+ from ..installation import Installation
17
+ from .plan import FreezePlan
18
+ from .profile import PROFILES
19
+ from .verify import BuildVerifier, Finding
20
+
21
+ PLAN_VARIABLE = "WEBAPP_SHELL_FREEZE_PLAN" # how the spec learns what to freeze: the plan's reference ...
22
+ KIND_VARIABLE = "WEBAPP_SHELL_FREEZE_KIND" # ... and the shell
23
+
24
+
25
+ class Builder:
26
+ def __init__(self, plan: FreezePlan, reference: str, paths: tuple[Path, ...], output: Path, work: Path):
27
+ self.plan, self.reference, self.paths = plan, reference, paths
28
+ self.output, self.work = output, work
29
+
30
+ def folder(self, kind: ShellKind) -> Path:
31
+ return self.output / kind.value / self.plan.product
32
+
33
+ def _program(self, kind: ShellKind) -> Path:
34
+ program = self.folder(kind) / self.plan.product
35
+ return program.with_suffix(".exe") if sys.platform == "win32" else program
36
+
37
+ def _library_problems(self, kind: ShellKind) -> str:
38
+ """Runs the frozen program's own check: every library the program loads on first use must be in it."""
39
+ done = subprocess.run([str(self._program(kind)), Installation.CHECK_LIBRARIES], capture_output=True, text=True)
40
+ return "" if done.returncode == 0 else (done.stdout + done.stderr).strip()
41
+
42
+ def _environment(self, kind: ShellKind) -> dict[str, str]:
43
+ roots = [str(path.resolve()) for path in self.paths]
44
+ python_path = os.pathsep.join([*roots, *filter(None, [os.environ.get("PYTHONPATH")])])
45
+ return {**os.environ, "PYTHONPATH": python_path, PLAN_VARIABLE: self.reference, KIND_VARIABLE: kind.value}
46
+
47
+ def build(self, kind: ShellKind) -> int:
48
+ if importlib.util.find_spec("PyInstaller") is None:
49
+ print('PyInstaller is not installed: pip install "webapp-shell[freeze]"')
50
+ return 1
51
+ spec = resources.files("webapp_shell.freezing") / "app.spec"
52
+ with resources.as_file(spec) as spec_path:
53
+ command = [
54
+ sys.executable, "-m", "PyInstaller", str(spec_path), "--noconfirm", "--clean",
55
+ "--distpath", str(self.output / kind.value), "--workpath", str(self.work / kind.value),
56
+ ] # fmt: skip
57
+ done = subprocess.run(command, env=self._environment(kind))
58
+ if done.returncode:
59
+ return done.returncode
60
+ findings = BuildVerifier(self.folder(kind), PROFILES[kind], self.plan).findings()
61
+ if not findings and (problem := self._library_problems(kind)):
62
+ findings = [Finding("libraries", problem)]
63
+ for finding in findings:
64
+ print(f"{kind.value}: {finding.path}: {finding.problem}")
65
+ print(f"{kind.value}: {self.folder(kind)} " + ("is not right" if findings else "checked"))
66
+ return 1 if findings else 0
67
+
68
+
69
+ def main(argv: list[str] | None = None) -> int:
70
+ parser = argparse.ArgumentParser(prog="python -m webapp_shell freeze", description=(__doc__ or "").splitlines()[0])
71
+ parser.add_argument("plan", help="module:attribute of the project's FreezePlan")
72
+ parser.add_argument("shells", nargs="*", type=ShellKind, default=list(PROFILES), help="default: every shell")
73
+ parser.add_argument(
74
+ "--path", type=Path, action="append", default=[], help="a folder to import the project from (repeatable)"
75
+ )
76
+ parser.add_argument("--output", type=Path, default=Path("dist"))
77
+ parser.add_argument("--work", type=Path, default=Path("build"))
78
+ args = parser.parse_args(argv)
79
+ for path in args.path:
80
+ sys.path.insert(0, str(path.resolve()))
81
+ builder = Builder(FreezePlan.load(args.plan), args.plan, tuple(args.path), args.output, args.work)
82
+ return max(builder.build(kind) for kind in args.shells)
@@ -0,0 +1,24 @@
1
+ """The entry point of a frozen program: a file written for each build."""
2
+
3
+ from ..enums import ShellKind
4
+ from .plan import FreezePlan
5
+
6
+ ENTRY_SOURCE = '''"""Entry point of the frozen {shell} build (generated by webapp_shell.freezing)."""
7
+
8
+ import multiprocessing
9
+ import sys
10
+
11
+ from webapp_shell import Installation, run
12
+ from {module} import {attribute} as target
13
+
14
+ if __name__ == "__main__":
15
+ multiprocessing.freeze_support() # a child process of the program is the same executable
16
+ config = target() if callable(target) else target
17
+ args = sys.argv[1:]
18
+ sys.exit(run(config, args if args[:1] and args[0] in Installation.COMMANDS else ["{shell}", *args]))
19
+ '''
20
+
21
+
22
+ def entry_source(plan: FreezePlan, kind: ShellKind) -> str:
23
+ module, _, attribute = plan.config.partition(":")
24
+ return ENTRY_SOURCE.format(shell=kind.value, module=module, attribute=attribute)
@@ -0,0 +1,38 @@
1
+ """What a project says about its frozen build. The rest (the entry file, what each shell leaves out, the checks of the
2
+ finished folder) belongs to the library."""
3
+
4
+ import importlib
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class DataFolder:
11
+ """A folder of the project that goes into the build as data (the compiled frontend, files the backend reads)."""
12
+
13
+ source: Path
14
+ target: str # the folder inside the build, e.g. "frontend/dist"
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class FreezePlan:
19
+ product: str # the name of the folder and of the executable
20
+ config: str # "module:attribute": the `ShellConfig` (or a function returning one) the program runs with
21
+ packages: tuple[str, ...] # whose submodules are all collected: the project's own, and any that load by name
22
+ data_folders: tuple[DataFolder, ...] = ()
23
+ required: tuple[
24
+ str, ...
25
+ ] = () # files that must be in the finished build, relative to its data, e.g. "frontend/dist/index.html"
26
+ collect_all: tuple[str, ...] = () # third-party packages with data files or native libraries
27
+ runtime_hooks: tuple[Path, ...] = () # scripts that run before anything else in the frozen program
28
+ excludes: tuple[str, ...] = () # more libraries to keep out, besides the developer tools the library leaves out
29
+ icon: Path | None = None # `.ico`, used for the Windows executable
30
+
31
+ @classmethod
32
+ def load(cls, reference: str) -> "FreezePlan":
33
+ """The plan named `module:attribute`; the module must be importable (`--path`)."""
34
+ module, _, name = reference.partition(":")
35
+ plan = getattr(importlib.import_module(module), name)
36
+ if not isinstance(plan, cls):
37
+ raise TypeError(f"{reference} is not a FreezePlan")
38
+ return plan
@@ -0,0 +1,49 @@
1
+ """What each shell brings and what it must leave out, so a build carries one window toolkit and not the other."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from ..enums import ShellKind
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class ShellProfile:
10
+ """One shell, with the libraries of the other kept out of its build."""
11
+
12
+ kind: ShellKind
13
+ modules: tuple[str, ...] # webapp_shell modules that belong to this shell only
14
+ forbidden: tuple[str, ...] # top-level names that must not be in the finished build
15
+
16
+ @property
17
+ def other_modules(self) -> tuple[str, ...]:
18
+ return tuple(m for p in PROFILES.values() if p.kind is not self.kind for m in p.modules)
19
+
20
+ @property
21
+ def excludes(self) -> list[str]:
22
+ """Libraries the freezer must leave out even if something imports them."""
23
+ return [*COMMON_EXCLUDES, *self.forbidden, *self.other_modules]
24
+
25
+ def wanted_module(self, name: str) -> bool:
26
+ return not any(name == m or name.startswith(f"{m}.") for m in self.other_modules)
27
+
28
+
29
+ PROFILES: dict[ShellKind, ShellProfile] = {
30
+ ShellKind.QT: ShellProfile(
31
+ ShellKind.QT,
32
+ modules=("webapp_shell.qt_shell", "webapp_shell.native.qt_bridge", "webapp_shell.native.qt_window"),
33
+ forbidden=("webview", "pythonnet", "clr_loader", "proxy_tools", "bottle"),
34
+ ),
35
+ ShellKind.WEBVIEW: ShellProfile(
36
+ ShellKind.WEBVIEW,
37
+ modules=(
38
+ "webapp_shell.webview_shell",
39
+ "webapp_shell.native.webview_bridge",
40
+ "webapp_shell.native.webview_background",
41
+ "webapp_shell.native.webview_window",
42
+ "webapp_shell.native.zoom",
43
+ ),
44
+ forbidden=("PySide6", "shiboken6", "PyQt5", "PyQt6", "qtpy"),
45
+ ),
46
+ }
47
+
48
+ # Tools of the developer, never of the user.
49
+ COMMON_EXCLUDES = ["tkinter", "pytest", "ruff", "pip", "IPython", "notebook"] # not setuptools: pkg_resources needs it
@@ -0,0 +1,68 @@
1
+ # pyright: basic
2
+ """The only module that imports PyInstaller: runs inside a PyInstaller build and describes it from the `FreezePlan`."""
3
+
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import cast
8
+
9
+ from PyInstaller.building.api import COLLECT, EXE, PYZ
10
+ from PyInstaller.building.build_main import Analysis
11
+ from PyInstaller.config import CONF
12
+ from PyInstaller.utils.hooks import collect_all, collect_submodules
13
+
14
+ from ..enums import ShellKind
15
+ from .builder import KIND_VARIABLE, PLAN_VARIABLE
16
+ from .entry import entry_source
17
+ from .plan import FreezePlan
18
+ from .profile import PROFILES
19
+
20
+ LIBRARY_PACKAGES = ("webapp_shell",) # the registry loads its shells by name, so every submodule of this shell is in
21
+
22
+
23
+ def freeze() -> None:
24
+ plan = FreezePlan.load(os.environ[PLAN_VARIABLE])
25
+ kind = ShellKind(os.environ[KIND_VARIABLE])
26
+ profile = PROFILES[kind]
27
+
28
+ workpath = Path(cast(str, CONF["workpath"])) # PyInstaller types CONF loosely; it is a path
29
+ entry = workpath / f"entry_{kind.value}.py"
30
+ entry.parent.mkdir(parents=True, exist_ok=True)
31
+ entry.write_text(entry_source(plan, kind), encoding="utf-8")
32
+
33
+ datas = [(str(folder.source), folder.target) for folder in plan.data_folders]
34
+ binaries = []
35
+ hiddenimports = []
36
+ for package in plan.collect_all:
37
+ found = collect_all(package)
38
+ datas += found[0]
39
+ binaries += found[1]
40
+ hiddenimports += found[2]
41
+ for package in (*LIBRARY_PACKAGES, *plan.packages):
42
+ hiddenimports += collect_submodules(package, filter=profile.wanted_module)
43
+
44
+ analysis = Analysis(
45
+ [str(entry)],
46
+ pathex=[],
47
+ binaries=binaries,
48
+ datas=datas,
49
+ hiddenimports=hiddenimports,
50
+ hookspath=[],
51
+ runtime_hooks=[str(hook) for hook in plan.runtime_hooks],
52
+ excludes=[*profile.excludes, *plan.excludes],
53
+ noarchive=False,
54
+ )
55
+ pyz = PYZ(analysis.pure)
56
+ exe = EXE(
57
+ pyz,
58
+ analysis.scripts,
59
+ [],
60
+ exclude_binaries=True,
61
+ name=plan.product,
62
+ debug=False,
63
+ strip=False,
64
+ upx=False,
65
+ console=False,
66
+ icon=str(plan.icon) if plan.icon and sys.platform == "win32" else None,
67
+ )
68
+ COLLECT(exe, analysis.binaries, analysis.datas, strip=False, upx=False, name=plan.product)
@@ -0,0 +1,43 @@
1
+ """Looks inside a finished build and fails when it holds something it must not, or lacks something it needs."""
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ from .plan import FreezePlan
7
+ from .profile import ShellProfile
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class Finding:
12
+ path: str
13
+ problem: str
14
+
15
+
16
+ class BuildVerifier:
17
+ """`folder` is the program's folder (the one that is handed over for installing)."""
18
+
19
+ def __init__(self, folder: Path, profile: ShellProfile, plan: FreezePlan):
20
+ self.folder, self.profile, self.plan = Path(folder), profile, plan
21
+
22
+ def findings(self) -> list[Finding]:
23
+ return [*self._missing(), *self._forbidden()]
24
+
25
+ def _inside(self) -> Path:
26
+ """PyInstaller puts the bundled files in `_internal` from version 6, beside the executable before."""
27
+ internal = self.folder / "_internal"
28
+ return internal if internal.is_dir() else self.folder
29
+
30
+ def _missing(self) -> list[Finding]:
31
+ found = [Finding(name, "missing") for name in self.plan.required if not (self._inside() / name).exists()]
32
+ if not any((self.folder / f"{self.plan.product}{suffix}").exists() for suffix in ("", ".exe")):
33
+ found.append(Finding(self.plan.product, "the executable is missing"))
34
+ return found
35
+
36
+ def _forbidden(self) -> list[Finding]:
37
+ names = {name.lower() for name in self.profile.forbidden}
38
+ found: list[Finding] = []
39
+ for path in self.folder.rglob("*"):
40
+ parts = {part.lower().split(".")[0] for part in path.relative_to(self.folder).parts}
41
+ if parts & names:
42
+ found.append(Finding(path.relative_to(self.folder).as_posix(), "belongs to the other shell"))
43
+ return found
@@ -0,0 +1,48 @@
1
+ """Who the program is: the one place a project says its name, where it keeps things and how its opening looks."""
2
+
3
+ import re
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+
7
+ from .preferences import BootColors
8
+
9
+ _SLUG = re.compile(r"[a-z][a-z0-9]*")
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class AppIdentity:
14
+ """What the shell shows and stores for one program. Nothing else in the library names the program."""
15
+
16
+ name: str # shown in the window title, the splash and the messages: "Acme Notes"
17
+ slug: str # lowercase letters and digits: names the page's native object, the Qt script, the default wire names
18
+ storage_dir: Path # the program's own folder for what the shell remembers: "~/.acme"
19
+ boot_colors: BootColors # the opening screen's colours until the page reports the user's own
20
+ icon_png: Path | None = None # window icon (macOS, Linux) and the splash mark
21
+ icon_ico: Path | None = None # window icon on Windows, which draws only `.ico` files
22
+ window_size: tuple[int, int] = field(default=(1280, 800))
23
+
24
+ def __post_init__(self) -> None:
25
+ if not _SLUG.fullmatch(self.slug):
26
+ raise ValueError(f"slug must be lowercase letters and digits, starting with a letter: {self.slug!r}")
27
+ if not self.name.strip():
28
+ raise ValueError("name must not be empty")
29
+
30
+ @property
31
+ def native_global(self) -> str:
32
+ """The name of the object the Qt shell puts on the page: `window.<slug>Native`."""
33
+ return f"{self.slug}Native"
34
+
35
+ @property
36
+ def native_ready_event(self) -> str:
37
+ """The event the page waits for before using `native_global`."""
38
+ return f"{self.slug}nativeready"
39
+
40
+ @property
41
+ def shell_preferences_path(self) -> Path:
42
+ """What the shell itself needs before the app exists (the opening colours)."""
43
+ return self.storage_dir / "shell.json"
44
+
45
+ @property
46
+ def app_preferences_path(self) -> Path:
47
+ """The program's own preferences, one file for every shell, owned by the backend."""
48
+ return self.storage_dir / "preferences.json"
@@ -0,0 +1,41 @@
1
+ """Where the program lives and how its backend is started: from the source tree, or frozen into one folder."""
2
+
3
+ import os
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from .backend import BackendSpec
8
+
9
+
10
+ class Installation:
11
+ """A frozen program (PyInstaller) has one executable and no `python -m`: it starts its own backend by running
12
+ itself with `RUN_BACKEND`. From the source tree the backend is `python -m <module>`, as always."""
13
+
14
+ RUN_BACKEND = "--run-backend"
15
+ CHECK_LIBRARIES = "--check-libraries" # imports every required library; a build runs it on the frozen program
16
+ COMMANDS = (RUN_BACKEND, CHECK_LIBRARIES) # what a frozen program does instead of opening a window
17
+
18
+ def __init__(self, source_root: Path, frozen: bool | None = None, bundle: Path | None = None):
19
+ self.frozen = bool(getattr(sys, "frozen", False)) if frozen is None else frozen
20
+ self._bundle = bundle if bundle is not None else Path(getattr(sys, "_MEIPASS", source_root))
21
+ self._source_root = source_root
22
+
23
+ @property
24
+ def root(self) -> Path:
25
+ """The folder that holds the compiled frontend (and whatever else the program ships)."""
26
+ return self._bundle if self.frozen else self._source_root
27
+
28
+ def package_dirs(self, backend: BackendSpec) -> tuple[Path, ...]:
29
+ """Import roots to add by hand. A frozen program carries its packages inside."""
30
+ return () if self.frozen else backend.package_dirs
31
+
32
+ def backend_command(self, backend: BackendSpec) -> list[str]:
33
+ return [sys.executable, self.RUN_BACKEND] if self.frozen else [sys.executable, "-m", backend.module]
34
+
35
+
36
+ def ensure_standard_streams() -> None:
37
+ """A windowed program has no console: `sys.stdout` and `sys.stderr` are None, and libraries that ask a stream
38
+ whether it is a terminal (the backend's web server does) fail. They get a stream that discards its text."""
39
+ for name in ("stdout", "stderr"):
40
+ if getattr(sys, name) is None:
41
+ setattr(sys, name, open(os.devnull, "w", encoding="utf-8")) # noqa: SIM115 - kept open for the whole run
@@ -0,0 +1,4 @@
1
+ """What JSON can carry. Everything that crosses between the page and Python is one of these, never a bare `dict`."""
2
+
3
+ type JsonValue = str | int | float | bool | list[JsonValue] | dict[str, JsonValue] | None
4
+ type JsonObject = dict[str, JsonValue]