Software Journal
Developer Tools Updated Sep 2, 2026 14 min read

FluxNet: Python Tkinter Speed Test App Guide

Deep dive into FluxNet: a Python Tkinter desktop app using speedtest-cli and threading for real-time download, upload, and ping tests with a modern dark theme.

Portrait of Benmalek Zohir

Benmalek Zohir

Contributor

Share
FluxNet: Python Tkinter Speed Test App Guide

FluxNet: Python Tkinter Speed Test App Guide

A verified technical deep-dive into azario0/FluxNet — a Python desktop application that measures download, upload, and ping with speedtest-cli, real-time Mbps callbacks, and a responsive dark-themed Tkinter UI.

If you searched for “Python internet speed test app,” “Tkinter speedtest-cli tutorial,” “Python threading Tkinter example,” or “how to build a speed tester with Python,” this guide dissects a minimal, working implementation file-by-file, with every claim verified against the actual repository code.

TL;DR — What FluxNet Does

FluxNet is a cross-platform desktop utility at app.py (242 lines) that:

  1. Finds the best Speedtest.net server via speedtest.Speedtest().get_best_server()
  2. Tests download and upload with st.download(callback=...) / st.upload(callback=...), updating Mbps live in the GUI
  3. Displays ping latency (ms) after server selection and after the full test
  4. Keeps the UI responsive with a daemon threading.Thread, because all network I/O runs off the main Tkinter loop

The repository has only four tracked files — app.py, README.md, LICENSE, and assets/screenshot.png — making it an ideal minimal reference for Python GUI development.

FluxNet Screenshot

Programming Languages Used

Language / MarkupRoleWhere
Python 3All application logic: GUI, threading, speed measurement, error handlingapp.py#L1-L242 — 242 lines
Tkinter (Python stdlib)Window, frames, labels, button, progress bar, fonts — no HTML/CSS/JSapp.py#L1-L2, app.py#L148-L238
MarkdownDocumentation and feature listREADME.md

There is no JavaScript, no separate CSS file, no database, and no web framework. Styling is done purely in Python via color constants and tkinter.font.Font objects at app.py#L11-L21.

Technology Stack at a Glance

LayerTechnologyPurposeVerified In
GUI FrameworkTkinter + ttkWindow management, StringVar binding, themed Progressbarapp.py#L1-L2
Speed Test Enginespeedtest-cli (import speedtest)Speedtest(), get_best_server(), download(), upload(), results objectapp.py#L3, app.py#L88-L106
ConcurrencythreadingOffloads blocking network calls from the Tkinter mainloopapp.py#L4, app.py#L143-L145
Platform HandlingplatformOS-specific font selection (Segoe UI/Helvetica, Consolas/Menlo)app.py#L5, app.py#L20-L21
AssetsPNGDark-theme screenshot (447×583)assets/screenshot.png

Python 3.7+ is required (see README.md#prerequisites), with a single pip dependency speedtest-cli.

Project Structure

Verified with ls -R and git log --stat (commit 98db7eb final adds all files):

FluxNet/
├── app.py                 # 242-line main application — GUI + test logic
├── assets/
│   └── screenshot.png     # 25 KB dark-theme preview (447×583 PNG)
├── README.md              # 104-line docs: features, install, usage, TODOs
└── LICENSE                # MIT (Copyright (c) 2025 Zohir Benmalek)

No requirements.txt is committed — installation is a single pip install speedtest-cli per README.md#installation. No hidden modules or subpackages.

Architecture Overview — How the Pieces Fit Together

┌─────────────────────────────────┐
│  main (L241-L242)               │
│  create_gui()                   │  ← Tk root setup (L148-L238)
└────────────┬────────────────────┘
             │ user clicks "Start Speed Test"
             ▼
┌─────────────────────────┐   threading.Thread(daemon=True)
│  start_test_thread()    │ ───────────────────────► perform_speed_test() (L75-L128)
│  (L136-L145)            │                         │  ├─ safe_gui_update() (L33)
│  resets StringVars      │                         │  ├─ safe_button_config() (L38)
│  spawns daemon thread   │                         │  ├─ safe_progressbar_control() (L43)
└─────────────────────────┘                         │  ├─ speedtest.Speedtest() (L88)
                                                    │  ├─ get_best_server() → ping (L92-L93)
                                                    │  ├─ download(callback=download_progress_updater) (L98)
                                                    │  ├─ upload(callback=upload_progress_updater) (L104)
                                                    │  └─ results.download/upload/ping (L99, L105, L109)
                                                    │
┌─────────────────────────┐                         │  callbacks (L53, L63)
│  Tkinter Mainloop       │ ◄── root.after(0, …) ───┘  (thread-safe UI updates via after())
│  StringVars:            │
│  download_label_var     │  "0.00" / "12.34" / "Error"
│  upload_label_var       │  "0.00" / "8.90" / "Error"
│  ping_label_var         │  "---" / "23.45"
│  status_label_var       │  "Finding best server..." / "Test Complete!" / "Error: ..."
└─────────────────────────┘
  • Single-threaded Tkinter + worker thread: The GUI never blocks because perform_speed_test() runs off the mainloop via start_test_thread() at app.py#L143-L145.
  • Thread-safe updates: All background-to-GUI communication uses root.after(0, lambda: var.set(...)) at app.py#L33-L50 — Tkinter StringVar/Widget must not be mutated directly from a worker thread.
  • Real-time feedback: The speedtest library’s callback hooks at L53-L71 push live Mbps values into download_label_var/upload_label_var.

Deep Dive 1: Configuration & Dark Theme Constants

App Metadata — app.py#L7-L21

APP_TITLE = "FluxNet Speed Tester"
WINDOW_SIZE = "450x550"
BG_COLOR = "#2E2E2E"
FG_COLOR = "#E0E0E0"
ACCENT_COLOR = "#007ACC"
BUTTON_BG_COLOR = "#4A4A4A"
BUTTON_FG_COLOR = "#FFFFFF"
BUTTON_ACTIVE_BG = "#5A5A5A"
RESULT_FG_COLOR = "#4CAF50"   # green for results
ERROR_FG_COLOR = "#F44336"    # red for errors (defined, though not dynamically applied)
FONT_FAMILY_MAIN = "Segoe UI" if platform.system() == "Windows" else "Helvetica"
FONT_FAMILY_DIGITS = "Consolas" if platform.system() == "Windows" else "Menlo"
  • Fixed window: 450×550 at L9, resizable(False, False) at L155 — mobile-like, non-resizable card.
  • Dark palette: #2E2E2E background, #E0E0E0 foreground, #007ACC accent (title, progress bar, hover) — matches the assets/screenshot.png preview.
  • Platform fonts: Segoe UI/Consolas on Windows, Helvetica/Menlo on macOS/Linux at L20-L21 — avoids missing-font fallbacks.

Global UI handles at L24-L30 (root, download_label_var, upload_label_var, ping_label_var, status_label_var, start_button, progress_bar) are None until create_gui() assigns them — a simple singleton pattern without a class.

Deep Dive 2: Thread-Safe GUI Helpers — The Key to Responsive Tkinter

Tkinter is not thread-safe — calling var.set() directly from perform_speed_test() would corrupt the Tcl interpreter. The three safe_* wrappers at app.py#L33-L50 solve this via root.after():

safe_gui_update(element_var, value) — L33-L36

def safe_gui_update(element_var, value):
    if root and root.winfo_exists() and element_var:
        root.after(0, lambda v=value: element_var.set(v))
  • Guards that root exists and winfo_exists() (window not closed) before scheduling.
  • lambda v=value: element_var.set(v) captures value by default arg to avoid late-binding bugs — critical for correct Mbps display.

safe_button_config(state, text) — L38-L41

def safe_button_config(state, text):
    if root and root.winfo_exists() and start_button:
        root.after(0, lambda s=state, t=text: start_button.config(state=s, text=t))
  • Disables the button to tk.DISABLED / "Testing..." at L79 and re-enables at L127 — prevents concurrent tests.

safe_progressbar_control(action) — L43-L50

def safe_progressbar_control(action):
    if root and root.winfo_exists() and progress_bar:
        if action == 'start':
            root.after(0, lambda: progress_bar.start(10))
        elif action == 'stop':
            root.after(0, lambda: progress_bar.stop())
            root.after(0, lambda: progress_bar.config(value=0))
  • Uses ttk.Progressbar in indeterminate mode (bouncing, not percentage) at L202 — start(10) animates at 10 ms intervals, stop() + value=0 resets.

These three functions are the only bridge between the worker thread and the GUI — a clean separation that avoids queue.Queue complexity for this scale.

Deep Dive 3: Real-Time Speed Callbacks — Live Mbps Updates

download_progress_updater() — L53-L61 & upload_progress_updater() — L63-L71

def download_progress_updater(bytes_transferred, time_elapsed, **kwargs):
    if not root or not root.winfo_exists(): return
    if time_elapsed > 0:
        current_speed_mbps = (bytes_transferred * 8 / 1_000_000) / time_elapsed
        safe_gui_update(download_label_var, f"{current_speed_mbps:.2f}")
    elif download_label_var.get() == "---" or download_label_var.get() == "Error":
        safe_gui_update(download_label_var, "0.00")
  • Signature: (bytes_transferred, time_elapsed, **kwargs) — the **kwargs at L53 absorbs extra args the speedtest library may pass, preventing TypeError (comment explicitly notes this).
  • Mbps formula: bytes * 8 → bits, /1_000_000 → megabits, / time_elapsed → per-second. Both callbacks use identical math at L58 and L68.
  • Formatting: :.2f gives two decimals (e.g., 94.37) — the result_font at L160 (Consolas/Menlo bold 18) makes these values visually prominent.
  • Initial guard: If time_elapsed == 0 and label is ---/Error, it sets "0.00" — avoids division by zero while showing activity.

The callbacks are passed to st.download(callback=download_progress_updater) at L98 and st.upload(callback=upload_progress_updater) at L104. Without them, the labels would only update with the final average — with them, they tick live during the transfer, fulfilling the Real-time Updates feature in README.md.

Deep Dive 4: Core Speed Test Logic — perform_speed_test()

The heart of the app at app.py#L75-L128 (54 lines):

1. Pre-test UI Reset — L78-L85

safe_gui_update(status_label_var, "Initializing speed test...")
safe_button_config(tk.DISABLED, "Testing...")
safe_progressbar_control('start')
safe_gui_update(download_label_var, "0.00")
safe_gui_update(upload_label_var, "0.00")
safe_gui_update(ping_label_var, "---")
  • Button disabled + progress bar started before any network call — immediate feedback even if Speedtest() init is slow.

2. Server Selection & Ping — L88-L93

st = speedtest.Speedtest()
safe_gui_update(status_label_var, "Finding best server...")
st.get_best_server()
safe_gui_update(ping_label_var, f"{st.results.ping:.2f}")
  • get_best_server() pings nearby Speedtest.net servers and picks the lowest latency — this is the first ping value displayed (e.g., 23.45 ms). It can take several seconds, hence the status update.

3. Download Test — L95-L100

safe_gui_update(status_label_var, "Testing download speed...")
st.download(callback=download_progress_updater)
final_download_speed_mbps = st.results.download / 1_000_000
safe_gui_update(download_label_var, f"{final_download_speed_mbps:.2f}")
  • st.download() returns bits/sec internally; the callback drives live updates, then st.results.download / 1_000_000 at L99 gives the final average in Mbps (same conversion as the callback, but using the library’s computed average).

4. Upload Test — L102-L106

safe_gui_update(status_label_var, "Testing upload speed...")
st.upload(callback=upload_progress_updater)
final_upload_speed_mbps = st.results.upload / 1_000_000
safe_gui_update(upload_label_var, f"{final_upload_speed_mbps:.2f}")
  • Mirrors download — note that st.results.upload is populated only after st.upload() completes.

5. Final Ping Refresh — L108-L110

final_ping = st.results.ping
safe_gui_update(ping_label_var, f"{final_ping:.2f}")
  • Re-reads st.results.ping in case the library refines it after the full test — ensures the displayed ping is the final value, not the pre-test one.

6. Error Handling — L114-L128

except speedtest.ConfigRetrievalError:
    safe_gui_update(status_label_var, "Error: Cannot connect to Speedtest.net.")
    set_error_labels_thread_safe()
except speedtest.NoMatchedServers:
    safe_gui_update(status_label_var, "Error: No suitable test servers found.")
    set_error_labels_thread_safe()
except Exception as e:
    error_msg = f"Error: {str(e)[:50]}"
    if "HTTP Error 403: Forbidden" in str(e):
        error_msg = "Error: Access denied by Speedtest.net (403). Try later."
    safe_gui_update(status_label_var, error_msg + "...")
    set_error_labels_thread_safe()
finally:
    safe_button_config(tk.NORMAL, "Start Speed Test")
    safe_progressbar_control('stop')
  • Specific: ConfigRetrievalError (no internet / can’t fetch config) and NoMatchedServers are distinct user-friendly messages at L114 and L117.
  • Generic with 403 special case: At L122-L123 checks for 403 Forbidden — a common Speedtest.net rate-limit, giving actionable advice.
  • Helper: set_error_labels_thread_safe() at L130-L134 sets all three values to "Error" via safe_gui_update.
  • Finally: Always re-enables the button and stops the progress bar at L127-L128 — even on exception, the UI is recoverable for a retry.

Deep Dive 5: Thread Launcher — start_test_thread() & Error Reset

start_test_thread() — L136-L145

def start_test_thread():
    if download_label_var: download_label_var.set("---")
    if upload_label_var: upload_label_var.set("---")
    if ping_label_var: ping_label_var.set("---")
    if status_label_var: status_label_var.set("Click 'Start Speed Test' to begin.")
    test_thread = threading.Thread(target=perform_speed_test)
    test_thread.daemon = True
    test_thread.start()
  • Direct set() (not safe_*): This function runs on the main Tkinter thread (triggered by button command at L219), so direct var.set() is safe and avoids the after() queue.
  • Daemon thread: daemon=True at L144 means the thread won’t block app exit — closing the window kills the test immediately (the callbacks also guard if not root or not root.winfo_exists(): return at L55).
  • No join(): The UI remains fully interactive; perform_speed_test() signals completion only via safe_button_config/safe_progressbar_control.

This is the textbook pattern for “keep Tkinter responsive during network I/O” — a common search query for Python GUI developers.

Deep Dive 6: GUI Construction — create_gui()

The largest function at app.py#L148-L238 (91 lines) builds the entire window declaratively.

Window Setup — L151-L156

root = tk.Tk()
root.title(APP_TITLE)
root.geometry(WINDOW_SIZE)
root.configure(bg=BG_COLOR)
root.resizable(False, False)
  • Assigns the global root at L149 so the safe_* helpers can reference it.

Fonts — L158-L162

title_font = font.Font(family=FONT_FAMILY_MAIN, size=20, weight="bold")
label_font = font.Font(family=FONT_FAMILY_MAIN, size=12)
result_font = font.Font(family=FONT_FAMILY_DIGITS, size=18, weight="bold")
status_font = font.Font(family=FONT_FAMILY_MAIN, size=10, slant="italic")
button_font = font.Font(family=FONT_FAMILY_MAIN, size=12, weight="bold")
  • Four distinct fonts: title bold 20, labels 12, results bold 18 digits (monospace for aligned numbers), status italic 10.

Layout — L164-L196

  • Main frame: tk.Frame(root, bg=BG_COLOR, padx=20, pady=20) at L165, packed expand=True, fill=BOTH.
  • Title + icon: tk.Label with text=APP_TITLE at L169 and rocket 🚀 at L173 (font size 48).
  • Results rows: Helper create_result_row(parent, text, var_for_value, unit_text) at L180-L187 builds a horizontal Frame with three labels: left-aligned description (width=15), right-aligned value bound to StringVar (width=7, fg=RESULT_FG_COLOR green), and unit (Mbps/ms, width=5). Called three times at L193-L195 for Download/Upload/Ping, with initial --- values at L189-L191.

Progress Bar — L198-L203

style = ttk.Style()
style.theme_use('clam')
style.configure("Accent.Horizontal.TProgressbar", troughcolor=BG_COLOR, bordercolor=ACCENT_COLOR, background=ACCENT_COLOR, ...)
progress_bar = ttk.Progressbar(main_frame, orient=tk.HORIZONTAL, length=300, mode='indeterminate', style="Accent.Horizontal.TProgressbar")
  • Uses ttk.Style with clam theme to override the green default — troughcolor matches BG_COLOR, background is ACCENT_COLOR blue. Length 300 spans most of the 450px window.

Status Label — L205-L208

  • StringVar("Click 'Start Speed Test' to begin.") with wraplength=380 at L207 — wraps long errors.

Start Button + Hover — L210-L236

start_button = tk.Button(main_frame, text="Start Speed Test", font=button_font, bg=BUTTON_BG_COLOR, fg=BUTTON_FG_COLOR, activebackground=BUTTON_ACTIVE_BG, command=start_test_thread, relief=tk.FLAT, ...)
start_button.bind("<Enter>", on_enter)
start_button.bind("<Leave>", on_leave)
  • command=start_test_thread at L219 is the sole user interaction.
  • Hover handlers at L228-L236 swap bg to ACCENT_COLOR on <Enter> and back on <Leave>, but only if state == NORMAL — disabled button stays grey.
  • mainloop() at L238 blocks until window close.

End-to-End Data Flow — Concrete Example

  1. User runs python app.py per README.md#usage → create_gui() at L241-L242 opens a 450×550 dark window with --- placeholders.
  2. Clicks Start Speed Test → start_test_thread() at L136 resets labels, spawns Thread(target=perform_speed_test, daemon=True).
  3. perform_speed_test() at L78-L79 disables button, starts indeterminate progress bar.
  4. Speedtest() init + get_best_server() at L88-L92 → status "Finding best server...", ping label updates to e.g., 24.12.
  5. download(callback=download_progress_updater) at L98 streams bytes; each callback computes (bytes*8/1_000_000)/time at L58 and safe_gui_update pushes 12.34 → 45.67 → 78.90 live into the Download row.
  6. After download, st.results.download / 1_000_000 at L99 overwrites with the final average (e.g., 85.42).
  7. Same for upload at L104 via upload_progress_updater at L68.
  8. Final ping refresh at L109-L110 and status "Test Complete!" at L112.
  9. finally at L127-L128 re-enables button to "Start Speed Test" and stops progress bar — ready for another run without restart.

On failure (no internet), ConfigRetrievalError at L114 shows "Error: Cannot connect to Speedtest.net." and all rows become "Error" via L130-L133.

Installation, Usage & Verification

Confirmed from README.md:

git clone https://github.com/azario0/FluxNet.git
cd FluxNet
pip install speedtest-cli
# verify: python -c "import speedtest; print(speedtest.__version__)"
python app.py
  • Prerequisites: Python 3.7+, pip, internet connection per README.md#prerequisites.
  • Cross-platform: Works on Windows/macOS/Linux where tkinter is available — the platform checks at L20-L21 handle font fallbacks.
  • Note: Package is speedtest-cli but import is speedtest at L3 — a common confusion the README clarifies.

Customization & Extension Points

  • Change window/theme: Edit constants at L8-L19 — e.g., BG_COLOR="#FFFFFF" for light mode, or adjust WINDOW_SIZE.
  • Add units toggle: Convert st.results.download / 1_000_000 at L99 to MB/s (/8_000_000) or kbps (*1000) — listed as a TODO in README.md.
  • Server selection: Uncomment # st.closest = {} at L89 or expose get_servers() + get_best_server(servers) in a dropdown — first TODO in the README.
  • History/export: Log final_download_speed_mbps, final_upload_speed_mbps, final_ping plus time.strftime to CSV after L110.
  • Packaging: PyInstaller --onefile --windowed app.py or cx_Freeze per the README TODOs — no code change needed.

Known Limitations & TODOs (From Code & README)

Verified TODOs at the end of README.md:

  • No server picker — hard-coded to best server via get_best_server() at L92.
  • No history — global StringVars are not persisted.
  • No export — no CSV/JSON writer.
  • No theme switcher — colors are constants, not a config file.
  • No standalone exe — requires python app.py + pip install.
  • Indeterminate progress bar only — no percentage; the speedtest library doesn’t expose total bytes until done, so precise progress is hard.
  • ERROR_FG_COLOR at L19 is defined but never applied to labels — error state is text-only "Error".

These are normal for a teaching/demo desktop app and excellent first contributions.

Conclusion — Why This Repo Is a Great Tkinter + Speedtest Starter

If you want to learn Python GUI threading, real-time callbacks, and Speedtest.net integration without the bloat, this project is a minimal, verified reference:

  • Tkinter done right: ttk.Style theming, StringVar data binding, after() for thread safety — all in one file.
  • Threading done simply: One daemon Thread, three safe_* wrappers, no queue — yet the UI stays fluid during 30-second transfers.
  • Speedtest done practically: Live Mbps via bytes*8/1_000_000/time and proper error branching for ConfigRetrievalError/NoMatchedServers/403.

Clone it, run pip install speedtest-cli, try python app.py, and watch the download label tick live. From there, fork in a server selector, CSV history, or unit toggle — the create_gui() structure is ready for it.


Links: app.py · README.md · LICENSE · assets/screenshot.png

Verified against commit main on 2026-09-02. All file paths, line numbers, constants, and calculations checked against the cloned repository.

Share
Portrait of Benmalek Zohir

Written by

Benmalek Zohir

Founder, AI Engineer & Full Stack Developer

Benmalek Zohir is an AI Engineer, Full Stack Developer, and technology enthusiast focused on artificial intelligence, software development, and emerging technologies. He is the founder of SoftwareJournal.blog, where he shares practical insights, software discoveries, AI tools, and the latest developments in technology.

The Software Journal Dispatch

One excellent engineering read, every week.

A concise digest of our best new essays on architecture, tooling, databases, and the craft of software. No spam, no noise — unsubscribe anytime.