abinazebinoy commited on
Commit
cd7f5fc
·
1 Parent(s): f2840cc

feat(D2): PyInstaller entry point and spec for the desktop backend

Browse files

Freezes the FastAPI app into a standalone executable. A separate entry
point (packaging/desktop_entry.py) was necessary, not optional -- the
Docker deployment starts via 'uvicorn app:app', a CLI string-import
that has no module/file to resolve against once frozen into a single
binary. This entry point imports the app object directly and calls
uvicorn.run() programmatically instead, sets BHARATGRAPH_DESKTOP_MODE
before any other import (so Phase D1's admin-auth hardening and local
config-file loading both apply), and binds to 127.0.0.1 only.

Verified for real, not just written: installed PyInstaller, ran an
actual build against this codebase, and ran the resulting binary --
/health, /runtime, /docs, and /forensics/ghost-companies all responded
correctly over real HTTP; /runtime specifically confirmed the D1/F-10
scraper_workers value is correctly bundled and functional. Cold start
is ~4s. The 503s on Neo4j-dependent routes are the correct behavior
with no database connected, not failures.

Scope, stated plainly: this is a Linux ELF binary verification, not a
Windows .exe -- the real DLL-bundling risk for compiled extensions
(lxml, scipy, numpy) is a Windows-specific concern this can't validate
without an actual Windows machine or windows-latest CI runner. spacy,
sentence-transformers, torch, and weasyprint are confirmed lazy-loaded
(app starts fine without them) but were not bundled in this build --
torch alone is 500+MB and a full build was not attempted here.

New finding, discovered via PyInstaller's own missing-module analysis,
unrelated to packaging itself: ai/translator.py and
ai/transliteration.py -- both genuinely wired into
api/routes/multilingual.py, not dead code -- import transformers and
indic_transliteration respectively, neither of which is declared in
requirements.txt. On a fresh install, the multilingual translation/
transliteration features would ImportError the moment anyone actually
used them. Flagged here, not fixed -- separate from D2's scope, and
needs a version-pinning decision, not a blind addition.

packaging/bharatgraph-backend.spec ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- mode: python ; coding: utf-8 -*-
2
+ """
3
+ BharatGraph Desktop Backend -- PyInstaller spec (Phase D2)
4
+
5
+ Freezes packaging/desktop_entry.py (see that file's own docstring for why
6
+ a separate entry point exists) into a single executable, bound only to
7
+ 127.0.0.1, meant to be spawned as a child process by the Tauri shell
8
+ (Phase D4) -- not run standalone in production.
9
+ """
10
+ import os
11
+
12
+ PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(SPEC)))
13
+
14
+ block_cipher = None
15
+
16
+ a = Analysis(
17
+ [os.path.join(PROJECT_ROOT, 'packaging', 'desktop_entry.py')],
18
+ pathex=[PROJECT_ROOT],
19
+ binaries=[],
20
+ datas=[
21
+ # NOTE: verified via full-codebase grep that no current code path
22
+ # actually reads this file at runtime -- api/routes/sources.py
23
+ # (the one route this file's name suggests it feeds) uses a
24
+ # hardcoded Python dict (SCRAPER_META) plus a live Neo4j query
25
+ # instead. Included anyway: it's 96KB, essentially free to bundle,
26
+ # and is the most likely candidate to become a real runtime
27
+ # dependency once Phase D7 (Dataset Manager) is built. Not
28
+ # removed, but not assumed necessary either -- stated plainly so
29
+ # a future reader doesn't have to re-derive this.
30
+ (os.path.join(PROJECT_ROOT, 'datasets', 'bharatgraph_sources.csv'), 'datasets'),
31
+ ],
32
+ hiddenimports=[
33
+ # api/main.py's own app.include_router(...) calls are regular
34
+ # top-level imports, so PyInstaller's static analysis should
35
+ # already find every api.routes.* module -- not listed
36
+ # defensively here, unlike uvicorn's own dynamic loading below,
37
+ # specifically to let a real build prove that assumption right
38
+ # or wrong rather than papering over it with a hidden-imports
39
+ # list that would hide a real gap.
40
+ 'uvicorn.logging',
41
+ 'uvicorn.loops',
42
+ 'uvicorn.loops.auto',
43
+ 'uvicorn.protocols',
44
+ 'uvicorn.protocols.http',
45
+ 'uvicorn.protocols.http.auto',
46
+ 'uvicorn.protocols.websockets',
47
+ 'uvicorn.protocols.websockets.auto',
48
+ 'uvicorn.lifespan',
49
+ 'uvicorn.lifespan.on',
50
+ ],
51
+ hookspath=[],
52
+ hooksconfig={},
53
+ runtime_hooks=[],
54
+ excludes=[],
55
+ noarchive=False,
56
+ )
57
+
58
+ pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
59
+
60
+ exe = EXE(
61
+ pyz,
62
+ a.scripts,
63
+ a.binaries,
64
+ a.zipfiles,
65
+ a.datas,
66
+ [],
67
+ name='bharatgraph-backend',
68
+ debug=False,
69
+ bootloader_ignore_signals=False,
70
+ strip=False,
71
+ upx=False,
72
+ console=True,
73
+ disable_windowed_traceback=False,
74
+ argv_emulation=False,
75
+ target_arch=None,
76
+ codesign_identity=None,
77
+ entitlements_file=None,
78
+ )
packaging/desktop_entry.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ BharatGraph Desktop -- PyInstaller entry point (Phase D2)
3
+
4
+ Why this file exists, separately from app.py: the Docker deployment starts
5
+ the server via `uvicorn app:app`, a CLI command that resolves the string
6
+ "app:app" to a module and attribute at runtime using Python's normal import
7
+ machinery. A PyInstaller-frozen executable does not have that machinery
8
+ available the same way -- there is no `app.py` file on disk to resolve a
9
+ string against once everything is bundled into a single binary. The
10
+ standard, reliable fix (used by PyInstaller's own documentation for
11
+ freezing ASGI apps) is to import the app object directly in Python code and
12
+ call uvicorn.run() programmatically instead of via its CLI string-lookup
13
+ path. This file is that direct entry point.
14
+
15
+ Sets BHARATGRAPH_DESKTOP_MODE=true before importing anything else, so the
16
+ Phase D1 admin-auth hardening in api/routes/admin.py (auto-generated,
17
+ persisted per-install secret; no DEBUG_MODE bypass path) applies correctly
18
+ whenever the app is started this way, and so config/settings.py's local
19
+ config-file loading is the primary configuration path a desktop user
20
+ actually has, rather than a code path that only matters at packaging time.
21
+
22
+ Binds to 127.0.0.1 only, matching the target architecture ("bound to
23
+ 127.0.0.1 only") -- this process is meant to be reached only by the Tauri
24
+ shell's own webview (Phase D4), never by the network.
25
+ """
26
+ import os
27
+ import sys
28
+
29
+ # Must happen before importing api.main (or anything that transitively
30
+ # imports config.settings), so the desktop-mode admin-auth branch and the
31
+ # local config-file loading are both active from the very first import.
32
+ os.environ.setdefault("BHARATGRAPH_DESKTOP_MODE", "true")
33
+
34
+ # PyInstaller sets sys.frozen and sys._MEIPASS when running from a frozen
35
+ # bundle; ensure the app's own package root is importable either way, so
36
+ # this entry point works identically when run as a plain script (for local
37
+ # testing, exactly as this was verified) and when frozen.
38
+ if getattr(sys, "frozen", False):
39
+ _app_root = sys._MEIPASS # type: ignore[attr-defined]
40
+ else:
41
+ _app_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
42
+ sys.path.insert(0, _app_root)
43
+
44
+ import uvicorn
45
+ from api.main import app
46
+
47
+
48
+ def main() -> None:
49
+ port = int(os.getenv("BHARATGRAPH_DESKTOP_PORT", "8000"))
50
+ uvicorn.run(
51
+ app,
52
+ host="127.0.0.1",
53
+ port=port,
54
+ log_level=os.getenv("BHARATGRAPH_LOG_LEVEL", "info"),
55
+ # Single worker: matches the existing Docker deployment's own
56
+ # --workers 1 (api/main.py's docstring/Dockerfile), and a desktop
57
+ # install is single-user by definition -- no reason to diverge.
58
+ workers=1,
59
+ )
60
+
61
+
62
+ if __name__ == "__main__":
63
+ main()