Crossing the gap from Python script to double-clickable app
A v1.0.0 release maps the three rungs between a working script and something a non-developer can open, and what each rung costs the person who built it.
Most personal engineering tools die in the same place. The code works. The author runs it daily. Then someone else asks for a copy, and the answer is a paragraph that begins “first, install Python” — and that is where the conversation ends. The tool was never broken. It was just never shipped.
The v1.0.0 release of the Automated Stock and Crypto Analyzer is interesting less for what it draws than for how it plans to travel. The announcement lays out three delivery paths for one program: clone the repository and run it, download a standalone executable, or eventually open a dashboard in a browser. Those are not three features. They are three rungs on a ladder, and every rung upward moves labour off the user and onto the developer. That trade is the subject here.
The original note
The Automated Stock and Crypto Analyzer has reached a public v1.0.0 — a Windows desktop application for technical stock analysis with interactive charts. Technical users can clone the repository and run
python manual_stock_analyzer.py, with aBUILD.batto install dependencies. For everyone else, a standalone.exeis in development, and an online dashboard is planned so the tool can be used in a browser with no setup at all. Next on the roadmap: a full-suite analyzer, a Fundamental Analysis module, and an Options Chain module.
“Just run the script” is not shipping
A charting tool of this kind is an analysis aid — it renders what the data did, it does not tell anyone what to do about it — and nothing below depends on which indicators it draws. What matters is the delivery mechanism, and delivery is where the engineering actually lives.
python manual_stock_analyzer.py is a complete instruction only for a reader who
already has an interpreter, a matching version of it, a working compiler
toolchain for any package that ships native extensions, and the patience to
resolve a dependency tree. A BUILD.bat that installs the requirements
compresses that work but does not remove it. The user still needs Python on
PATH, still hits the machine’s existing site-packages, still gets a wall of
red text if a wheel is unavailable for their Python version.
That is fine. Source distribution is the correct first rung, and shipping it early beats shipping nothing. The mistake is believing a tool is distributed once the README exists. It is distributed when someone who has never opened a terminal can use it.
Three rungs, and who pays
| Rung | Developer pays | User pays |
|---|---|---|
| Clone and run | Nothing beyond the code | Python, toolchain, dep hell |
Frozen .exe |
Build pipeline, signing, blind debugging | A download |
| Hosted dashboard | Hosting, uptime, secrets, API quota | A URL |
Read that table as a conservation law. Total work does not shrink as the ladder is climbed; it relocates. Rung one is free for the maintainer and expensive for everyone else, which is why so many good tools have exactly one user. Rung two buys near-zero friction with a real build pipeline. Rung three buys instant access with an operational commitment that never ends.
The roadmap items in the release note — a Fundamental Analysis module, an
Options Chain module — are the visible half of the work. The .exe and the
dashboard are the invisible half, and that half decides whether the visible one
is ever used.
Freezing, in detail
Rung two is where most of the surprises live, so it is worth being precise about what “freezing” means.
A freezer such as PyInstaller does not compile Python to machine code. It walks the import graph statically, collects the modules it finds, bundles them alongside a copy of the CPython interpreter and any native extension libraries, and prepends a small bootloader. Running the result unpacks or maps that bundle, starts the embedded interpreter, and executes the entry script. Nothing gets faster. Nothing gets meaningfully obfuscated. The artifact simply becomes self-contained — and, because it now carries an interpreter and a scientific stack, it is typically tens of megabytes rather than a few kilobytes of source.
One-folder versus one-file
One-folder mode emits a directory: the executable next to its DLLs, extension modules, and data. It starts fast because nothing needs unpacking. It is awkward to hand to someone, because the directory must stay intact.
One-file mode emits a single executable, which is what people actually want to download. The cost is paid at every launch: the bootloader extracts the whole bundle into a temporary directory, runs from there, and cleans up on exit. That extraction is real work on every single start, and it is the reason one-file builds feel sluggish to open. It also means the on-disk location of the program’s own files changes every run — which breaks any code that assumes a stable path.
Finding bundled data at runtime
Because of that temp directory, the path a data file lives at during development
is not the path it lives at when frozen. One-file builds expose the extraction
root as sys._MEIPASS; one-folder builds have no such attribute and resolve
relative to the executable; a source run needs to resolve relative to the module.
All three cases need one helper, and getting the fallback wrong is the single
most common freezing bug.
import os
import sys
def resource_path(relative: str) -> str:
"""Resolve a bundled data file, frozen or running from source."""
if getattr(sys, "frozen", False):
# One-file builds unpack into a temp dir exposed as _MEIPASS.
# One-folder builds have no _MEIPASS, so fall back to the
# directory that holds the executable itself.
base = getattr(sys, "_MEIPASS", os.path.dirname(sys.executable))
else:
# Source run: resolve against this module, never against the
# current working directory, which the user controls.
base = os.path.dirname(os.path.abspath(__file__))
return os.path.join(base, relative)
Every read of an icon, a config template, a bundled CSV, or a font must go through that function. A bare relative path works perfectly in development and fails the first time a stranger runs the executable from their Downloads folder.
Hidden imports
Static analysis only sees imports it can read. Anything reached through
importlib, a plugin registry, an entry point, or a backend selected at runtime
by string name is invisible to the freezer, so the module is left out and the
program dies with ModuleNotFoundError in the field. Plotting backends,
database drivers, and encoding codecs are the usual casualties. The fix is to
declare them explicitly.
# One-folder: fastest startup, ships a directory the user must keep
# intact.
pyinstaller \
--name analyzer \
--windowed \
--add-data "assets;assets" \
--hidden-import matplotlib.backends.backend_tkagg \
app.py
# One-file: a single .exe, far easier to hand to someone. Every launch
# extracts the bundle to a temp dir first, so it starts more slowly.
pyinstaller --onefile \
--name analyzer \
--windowed \
--add-data "assets;assets" \
--hidden-import matplotlib.backends.backend_tkagg \
app.py
What each flag is doing:
--name analyzer— names the output directory and the executable.--windowed— GUI app: no console window appears on launch.--add-data "assets;assets"— bundles a data directory asSRC;DEST. The separator is;on Windows and:on POSIX, which is a common cause of a build that works on one machine and not another.--hidden-import ...— declares a module the static analysis missed.
Once the flag list grows, generate the .spec file and edit that instead; it is
the real build input and belongs in version control.
Signing, SmartScreen, and antivirus
An unsigned executable downloaded from the internet is treated as guilty. Windows SmartScreen shows a full-screen warning for binaries with no reputation, and reputation accrues from download volume and a consistent signing identity — a new unsigned build has neither. Heuristic antivirus engines are worse: the bootloader-plus-embedded-payload structure is structurally identical to what commercial packers and actual malware do, so false positives are routine, and a build can be quarantined mid-download with no explanation the user can act on.
The mitigations are unglamorous. Code signing removes most of it, at the price of an annual certificate and an identity check. Publishing checksums and submitting false positives to vendors help at the margin. None of it is a code problem, which is why it blindsides people who thought the build was the last step.
The silent crash
--windowed suppresses the console. That is correct for a GUI app and it means
stdout and stderr go nowhere — on Windows they can be None, so a stray
print() can itself raise. A traceback that would have been obvious in a
terminal now manifests as a window that never appears. The only fix is to write
diagnostics to a file, to a location that is writable, and to install a global
exception hook so unhandled errors land there too.
import logging
import os
import sys
from logging.handlers import RotatingFileHandler
def log_dir() -> str:
"""Pick a writable dir; the install dir may be read-only."""
base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
path = os.path.join(base, "Analyzer", "logs")
os.makedirs(path, exist_ok=True)
return path
def configure_logging() -> None:
handler = RotatingFileHandler(
os.path.join(log_dir(), "app.log"),
maxBytes=1_000_000,
backupCount=3,
encoding="utf-8",
)
handler.setFormatter(
logging.Formatter("%(asctime)s %(levelname)s %(message)s")
)
root = logging.getLogger()
root.setLevel(logging.INFO)
root.addHandler(handler)
def on_uncaught(exc_type, exc, tb):
root.critical("uncaught", exc_info=(exc_type, exc, tb))
sys.excepthook = on_uncaught
Call it as the first statement of the entry point, before any window is created. When a user reports “it doesn’t open”, the reply is a path to a log file.
A playbook
- Pin everything and build in a clean virtual environment. A freezer bundles what it finds on the build machine. Building inside the everyday environment silently ships unrelated packages and hides missing pins.
- Route every data-file read through
resource_path()before the first build attempt, not after the first bug report. - Add file logging and a
sys.excepthook, because a windowed build has no other way to explain itself. - Build one-folder first. It is faster to iterate on and isolates genuine packaging errors from one-file extraction weirdness. Switch to one-file only once the folder build runs correctly.
- Test on a machine that has never had Python installed. A clean virtual machine is the only honest test; the build machine will pass no matter what is broken.
- Freeze the build itself. Commit the
.spec, script the build in CI, and attach the artifact plus a checksum to a tagged release so a given version can always be rebuilt.
Failure modes
- Paths that resolve against the working directory. Correct in development, wrong the moment the user launches from a shortcut.
- Writing config or logs beside the executable.
Program Filesis not writable by a standard user; the app fails on first save. - Unpinned dependencies. Two builds a month apart are two different programs, and only one of them has been tested.
multiprocessingwithoutfreeze_support(). Frozen child processes re-execute the bootloader and spawn recursively.- Assuming a stable install path in a one-file build. The extraction directory is new every launch and is deleted on exit.
- Non-ASCII characters or long paths in the temp location. Some native extensions still handle these badly.
- Shipping without checking a fresh download. Antivirus can quarantine the file between the release page and the user’s disk.
The honest cost of the hosted rung
A browser dashboard deletes the entire freezing problem. There is no bootloader, no signing, no extraction, no SmartScreen. It replaces a one-time build cost with a permanent operational one: a host to pay for, uptime to maintain, API keys stored somewhere that is not the repository, and an upgrade path that now belongs to the maintainer.
The constraint that matters most for a market-data tool is quota. Upstream providers rate-limit per key. A desktop build distributes that naturally — every user brings their own key and their own limit. A single shared deployment shares one bucket, so throughput per user falls as adoption rises, and a handful of enthusiastic visitors can exhaust the allowance for everyone. That is architecture, not a footnote: it forces a decision between aggressive caching, per-user credentials, a paid tier, or an accepted ceiling on concurrent use.
Caching is the cheapest of those, and a coarse time-to-live goes a long way.
import functools
import hashlib
import json
import os
import time
def disk_cache(ttl_seconds: int, cache_dir: str):
"""Cache JSON-serializable results on disk for ttl_seconds."""
os.makedirs(cache_dir, exist_ok=True)
def decorator(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
src = json.dumps([fn.__name__, args, kwargs], default=str)
key = hashlib.sha256(src.encode("utf-8")).hexdigest()
path = os.path.join(cache_dir, key + ".json")
if os.path.exists(path):
age = time.time() - os.path.getmtime(path)
if age < ttl_seconds:
with open(path, encoding="utf-8") as fh:
return json.load(fh)
value = fn(*args, **kwargs)
with open(path, "w", encoding="utf-8") as fh:
json.dump(value, fh)
return value
return wrapper
return decorator
The same helper earns its place on the desktop rung too, where it buys offline reads rather than survival under load.
Closing
The ladder in that release note is the actual roadmap, and it is a more demanding one than the feature list beside it. A Fundamental Analysis module is bounded work with a clear end. A signed executable that opens on a stranger’s laptop, and a hosted version that stays up, are commitments.
Both are worth making, because a tool that only its author can install has an
audience of one. The distance between python app.py and a double-click is not
a packaging detail — it is the difference between code that exists and software
that is used.
