← All writing

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.

9 min readPythonShippingDeveloper Tooling

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 a BUILD.bat to install dependencies. For everyone else, a standalone .exe is 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:

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

  1. 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.
  2. Route every data-file read through resource_path() before the first build attempt, not after the first bug report.
  3. Add file logging and a sys.excepthook, because a windowed build has no other way to explain itself.
  4. 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.
  5. 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.
  6. 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

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.

This post grew out of a note originally published on LinkedIn in October 2025. Read the original.
Get in touchRead the full story