<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: h13ris</title>
    <description>The latest articles on Forem by h13ris (@hi3ris).</description>
    <link>https://forem.com/hi3ris</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3150332%2Fc3fe7bd4-db7d-4d45-b300-1f1938b58730.jpeg</url>
      <title>Forem: h13ris</title>
      <link>https://forem.com/hi3ris</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/hi3ris"/>
    <language>en</language>
    <item>
      <title>Inside WatchTower: 4-layer defacement detection in async Python</title>
      <dc:creator>h13ris</dc:creator>
      <pubDate>Tue, 05 May 2026 14:01:10 +0000</pubDate>
      <link>https://forem.com/hi3ris/inside-watchtower-4-layer-defacement-detection-in-async-python-3gpf</link>
      <guid>https://forem.com/hi3ris/inside-watchtower-4-layer-defacement-detection-in-async-python-3gpf</guid>
      <description>&lt;p&gt;A defaced website is a curious problem.&lt;/p&gt;

&lt;p&gt;It's loud — anyone visiting the page can see something is wrong. But it's also quiet from a server's perspective: HTTP returns 200, your uptime monitor is happy, your TLS cert hasn't moved, and the CMS logs show a "successful" content update from a legitimate-looking session. The signal is &lt;strong&gt;on the rendered page&lt;/strong&gt;, not in the metrics.&lt;/p&gt;

&lt;p&gt;I run a site at &lt;code&gt;hi3ris.blueshield.tg&lt;/code&gt; and surveil a couple of dozen others for various reasons. After my third "you've been hacked, by the way" message from a friend, I got tired of trusting external uptime services that don't know what my homepage is supposed to look like. So I built &lt;strong&gt;WatchTower&lt;/strong&gt; — an async-first defacement monitor that combines four detection layers, captures evidence, and alerts on multiple channels.&lt;/p&gt;

&lt;p&gt;This post is a tour of how it actually works under the hood. We'll go through the four detection layers, the async crawler, the way the PyQt6 UI is decoupled from scanning, and what's coming next (spoiler: a local model to replace Gemini).&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The full source is on &lt;a href="https://github.com/hi3ris" rel="noopener noreferrer"&gt;github.com/hi3ris&lt;/a&gt; — Python 3.10+, MIT licensed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why four detection layers, not one?
&lt;/h2&gt;

&lt;p&gt;The intuitive approach to "did the page change?" is to hash the HTML and compare. Done in three lines.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;
&lt;span class="n"&gt;sha&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ignore&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The problem: &lt;strong&gt;legitimate sites change all the time&lt;/strong&gt;. A timestamp in the footer. An ad rotator. A CSRF token in a form. A blog adding a new article. A pure SHA-256 comparison flags all of those as "changed", and you end up either drowning in false positives or whitelisting so aggressively that real defacements slip through.&lt;/p&gt;

&lt;p&gt;A real defacement detector needs to answer a more nuanced question: &lt;strong&gt;"did the page change in a way that matters?"&lt;/strong&gt; That question can't be answered by one signal alone. WatchTower stacks four:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;SHA-256&lt;/strong&gt; of normalized text — fastest, catches anything bit-exact&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Perceptual hash (pHash)&lt;/strong&gt; of the rendered screenshot — catches visual changes, robust to text noise&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TF-IDF cosine similarity&lt;/strong&gt; between old and new text — catches semantic shifts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI escalation&lt;/strong&gt; (currently Gemini, soon a local model) — last-resort visual analysis on suspicious cases&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Any one layer flagging is a yellow signal; multiple layers agreeing is when an alert fires. Let's look at each.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 1 — SHA-256: the cheap fast pass
&lt;/h3&gt;

&lt;p&gt;The first layer is just a content fingerprint. It runs on every scan, costs essentially nothing, and tells you whether anything changed at all. If the SHA matches the previous scan, you can skip the rest of the pipeline.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;calculate_sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text_content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text_content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ignore&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trick is what you hash. Hashing raw HTML triggers on every dynamic element. WatchTower normalizes first — strips scripts, comments, and a few well-known dynamic attributes — then hashes the visible text only. That keeps SHA-256 useful as a "did the visible content change?" filter.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 2 — Perceptual hashing: the visual eye
&lt;/h3&gt;

&lt;p&gt;When the SHA-256 changes, the next question is &lt;strong&gt;how visually different&lt;/strong&gt; the page is. That's a job for &lt;code&gt;imagehash.phash&lt;/code&gt; over the rendered screenshot.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;functools&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;lru_cache&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;imagehash&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;PIL&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;

&lt;span class="nd"&gt;@lru_cache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;maxsize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;calculate_phash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;image_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;image_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;imagehash&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;phash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;phash_distance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;imagehash&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hex_to_hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;imagehash&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hex_to_hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;pHash gives you a 64-bit hash where Hamming distance correlates with perceptual similarity. WatchTower's default threshold is &lt;strong&gt;distance &amp;gt; 10&lt;/strong&gt; (≈ 15% of bits flipped) before the page is flagged as visually changed. That tolerance is configurable — &lt;code&gt;phash_tolerance_percent&lt;/code&gt; in the config — because some homepages legitimately rotate hero images.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;lru_cache(maxsize=1000)&lt;/code&gt; matters: when you're scanning 50 sites every 30 seconds, recomputing pHashes for unchanged screenshots burns CPU for nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 3 — TF-IDF: the semantic check
&lt;/h3&gt;

&lt;p&gt;Visual hashing fails on text-only defacements: somebody rewriting your homepage with the exact same layout, but different words. For that we need a content similarity score.&lt;/p&gt;

&lt;p&gt;WatchTower uses scikit-learn's &lt;code&gt;TfidfVectorizer&lt;/code&gt; to vectorize old and new text, then cosine similarity to compare:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sklearn.feature_extraction.text&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;TfidfVectorizer&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sklearn.metrics.pairwise&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;cosine_similarity&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;

&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vectorizer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TfidfVectorizer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;stop_words&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;french_stop_words&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;max_features&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;dtype&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;float32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;text_similarity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;old_text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;matrix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vectorizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fit_transform&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;old_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_text&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;cosine_similarity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;matrix&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;matrix&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The threshold defaults to &lt;strong&gt;0.80&lt;/strong&gt; — below that, the text content has shifted enough to be suspicious. Stop words live in an external file (&lt;code&gt;assets/french_stop_words.txt&lt;/code&gt;), so the language can be swapped without touching code.&lt;/p&gt;

&lt;p&gt;What this layer catches: a defacer overwriting your "About us" page with a manifesto. SHA-256 fires, pHash might or might not (same template, just different text), but cosine similarity drops to ~0.2 instantly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 4 — AI escalation: the judge of last resort
&lt;/h3&gt;

&lt;p&gt;When the first three layers disagree, you have a borderline case: SHA changed, pHash is suspicious, semantic similarity is in a gray zone. WatchTower escalates these cases — and only these — to a vision-capable LLM with the screenshot and a focused prompt:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Compare the previous screenshot with the current one. Has this page been defaced? Look for: hacker tags, foreign-language banners, signature payloads ("hacked by ...", "your security is an illusion"), broken layouts, or political/ideological overlays. Reply with a confidence score and a one-line justification."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Today this hits Gemini (&lt;code&gt;gemini-1.5-flash-latest&lt;/code&gt;) with retries (2s, 8s, 32s exponential backoff) and a kill-switch — if 5 consecutive calls fail, the API is disabled for the session and the system falls back to layers 1–3 alone. Graceful degradation matters here: the monitor should never go offline because Google had a bad afternoon.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This is the layer I'm replacing.&lt;/strong&gt; Sending screenshots to a third-party API for every borderline case is a privacy and cost problem at scale, and the prompt has to be language-agnostic which is genuinely hard. I'm training a local CNN+text classifier on a corpus of confirmed defacements (zone-h archive + my own captures) and benchmarking it head-to-head with the Gemini path. When recall plateaus around the same level, Gemini gets demoted to a fallback.&lt;/p&gt;

&lt;h2&gt;
  
  
  The async crawler: how it stays fast
&lt;/h2&gt;

&lt;p&gt;Multi-layer detection is meaningless if you can't scan often enough. The first version of WatchTower was synchronous — and scanning 100 sites took &lt;strong&gt;145 seconds&lt;/strong&gt; per cycle. The current async version does the same in &lt;strong&gt;8 seconds&lt;/strong&gt; (18× faster on the same hardware).&lt;/p&gt;

&lt;p&gt;The core is &lt;code&gt;aiohttp&lt;/code&gt; with a tuned &lt;code&gt;TCPConnector&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;aiohttp&lt;/span&gt;

&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;connector&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;aiohttp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TCPConnector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                &lt;span class="c1"&gt;# max total open connections
&lt;/span&gt;    &lt;span class="n"&gt;limit_per_host&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;# max per host — prevents single-host bottleneck
&lt;/span&gt;    &lt;span class="n"&gt;ttl_dns_cache&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;# 5-minute DNS cache
&lt;/span&gt;    &lt;span class="n"&gt;enable_cleanup_closed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;aiohttp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ClientSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;connector&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;connector&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three knobs that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;limit_per_host=10&lt;/code&gt; is the one most people forget. Without it, scanning a slow site with 50 pages will block your entire pool.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ttl_dns_cache=300&lt;/code&gt; saves a DNS round-trip on every request. For monitoring, where you hit the same hosts on a loop, this is free latency.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;enable_cleanup_closed=True&lt;/code&gt; prevents file-descriptor leaks under sustained load — important for a long-running daemon.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Discovery is BFS-bounded:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@retry_on_network_error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_attempts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_fetch_and_parse_links&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_semaphore&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                    &lt;span class="c1"&gt;# bounds in-flight requests
&lt;/span&gt;        &lt;span class="n"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;http_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_html&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;delay_between_requests&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;delay_between_requests&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_extract_internal_links&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A semaphore (&lt;code&gt;concurrent_requests=5&lt;/code&gt; by default) caps in-flight requests, and a 0.1s polite delay keeps us off WAFs' bad side. The crawler stops at &lt;code&gt;max_depth=2&lt;/code&gt; and &lt;code&gt;max_pages=100&lt;/code&gt; per site — enough to catch the homepage and immediate deep links without spidering forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  The UI is decoupled from scanning — and that matters
&lt;/h2&gt;

&lt;p&gt;The PyQt6 dashboard runs on Qt's main thread. The async crawler and detection workers run on &lt;strong&gt;other&lt;/strong&gt; threads. They communicate exclusively through Qt signals:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AnalysisWorker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;QThread&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;analysis_completed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pyqtSignal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# result payload
&lt;/span&gt;    &lt;span class="n"&gt;alert_logged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pyqtSignal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;site&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sites&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run_layers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;analysis_completed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;should_alert&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
                &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;alert_logged&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alert_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;evidence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The dashboard connects to those signals once at startup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;analysis_completed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dashboard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;update_kpis&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;alert_logged&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alert_manager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;send_alert&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The benefit: every scan cycle pushes incremental updates to the UI as soon as a site is done. KPI cards tick up in real time, the table fills row by row. No "loading..." overlay, no UI freezes — even when a single site is timing out for 30 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The alerting pipeline: throttle, capture, dispatch
&lt;/h2&gt;

&lt;p&gt;A defacement alert without evidence is useless. WatchTower's alert manager runs three steps every time it fires:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Throttle check&lt;/strong&gt; — same site, same alert type within the last 15 minutes? Suppress. (&lt;code&gt;throttle_minutes&lt;/code&gt; config.) Without this, a flapping site would page you 50 times an hour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Evidence capture&lt;/strong&gt; — the screenshot, the rendered HTML, and the visible text are saved to &lt;code&gt;evidence/{domain}/{timestamp}_{alert_type}/&lt;/code&gt;. This becomes the audit trail when you need to explain the incident.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dispatch&lt;/strong&gt; — Telegram (bot API with photo + Markdown caption), SMTP (HTML email with base64 screenshot), and Discord-compatible webhooks. Each channel is independent; one failing doesn't block the others.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;send_alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alert_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;site_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_should_trigger_alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alert_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;site_url&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;                                          &lt;span class="c1"&gt;# throttled
&lt;/span&gt;    &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;evidence_manager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save_alert_evidence&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;site_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alert_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;alert_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;screenshot&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;screenshot&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;html_content&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;html&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;text_content&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;channel&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;enabled_channels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alert_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;site_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alert_channel_failed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;try/except&lt;/code&gt; per channel is deliberate. Telegram going down should not eat the email; email going down should not silence the webhook.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reputation enrichment
&lt;/h2&gt;

&lt;p&gt;The same pipeline is used for IoC enrichment. When a new external host is found in a page (a script src, an image, an iframe), WatchTower checks it against:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A local IoC file (&lt;code&gt;assets/ioc.txt&lt;/code&gt;) — instant, offline&lt;/li&gt;
&lt;li&gt;VirusTotal v3 (rate-limited to 4 req/min — the free tier ceiling)&lt;/li&gt;
&lt;li&gt;AbuseIPDB v2 (1 req/sec, confidence threshold 50%)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each external API has a session-local cache and a kill-switch on consecutive failures. The principle is the same as for Gemini: &lt;strong&gt;the monitor must keep monitoring even if the enrichment APIs vanish&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Two things are on the roadmap:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Local model replacing Gemini.&lt;/strong&gt; The training pipeline is in progress; the goal is a small image+text classifier (~50MB, CPU-friendly) that runs entirely offline. Privacy and cost both improve, and the prompt-engineering brittleness goes away.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full async monitoring controller.&lt;/strong&gt; Right now the controller still has some sync scaffolding — converting it to an async event loop would let the same process scan thousands of sites instead of hundreds.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I also want to expose a small REST API so a Grafana dashboard can pull KPIs directly, and ship a Docker image for headless deployments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;If you're building anything in the "watch this thing for changes" space — defacement, content drift, dependency hijack, anything — three patterns from WatchTower are worth stealing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Stack detection layers&lt;/strong&gt;, don't pick one. Each layer's blind spots are covered by the next, and combining cheap-to-expensive lets you short-circuit when nothing changed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bound everything async&lt;/strong&gt; — semaphores, per-host connection limits, retry caps, kill-switches on failing APIs. A monitor that DDoS's its own targets or gets stuck on a single bad host is worse than no monitor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decouple the UI from the work&lt;/strong&gt; — Qt signals across threads, or queues across processes, anything but blocking the loop your humans look at.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Source code, issues, and roadmap: &lt;a href="https://github.com/hi3ris" rel="noopener noreferrer"&gt;github.com/hi3ris&lt;/a&gt;. More projects at &lt;a href="https://hi3ris.blueshield.tg/" rel="noopener noreferrer"&gt;hi3ris.blueshield.tg&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you've shipped something similar — or have war stories about the perfect detection threshold — I want to hear it in the comments.&lt;/p&gt;

</description>
      <category>python</category>
      <category>security</category>
      <category>async</category>
      <category>devsecops</category>
    </item>
    <item>
      <title>PyQt6: how to ship Python GUIs that don't look like 1998</title>
      <dc:creator>h13ris</dc:creator>
      <pubDate>Mon, 04 May 2026 18:56:36 +0000</pubDate>
      <link>https://forem.com/hi3ris/pyqt6-how-to-ship-python-guis-that-dont-look-like-1998-2952</link>
      <guid>https://forem.com/hi3ris/pyqt6-how-to-ship-python-guis-that-dont-look-like-1998-2952</guid>
      <description>&lt;p&gt;I have a confession. For years, when a developer proudly showed me their Python app — gray square buttons, a &lt;code&gt;Listbox&lt;/code&gt; straight out of 1998 — I would politely nod. I've stopped doing that.&lt;/p&gt;

&lt;p&gt;Not because I turned mean. Because &lt;strong&gt;PyQt6 exists&lt;/strong&gt;, and there's no excuse anymore.&lt;/p&gt;

&lt;p&gt;This article is my attempt to convince you — yes, you, the one still typing &lt;code&gt;import tkinter&lt;/code&gt; out of habit — that something radically better is sitting one &lt;code&gt;pip install&lt;/code&gt; away. I'll walk you through side-by-side comparisons and real snippets from a project I've been building for months: &lt;strong&gt;WatchTower&lt;/strong&gt;, a website defacement monitoring system written entirely in PyQt6.&lt;/p&gt;

&lt;p&gt;Spoiler: by the end, you'll want to rewrite everything you ever shipped in Tkinter.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4xmfrpjp213a0tkr1pqo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4xmfrpjp213a0tkr1pqo.png" alt="WatchTower main dashboard built with PyQt6 — KPI cards, history chart, sites table, and side panel" width="605" height="340"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Tkinter problem in three lines
&lt;/h2&gt;

&lt;p&gt;Let's be honest. Tkinter ships with Python, it's free, it's documented, and it works. That's the whole pitch. The rest is masochism.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Tkinter — a "dashboard" that hurts to look at
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;tkinter&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;tk&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;tkinter&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ttk&lt;/span&gt;

&lt;span class="n"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Tk&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;My fancy tool&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;geometry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;400x300&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Label&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Status: OK&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bg&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;grey&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fg&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;white&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pady&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;button&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Scan&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;button&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pack&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mainloop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get something gray, flat, screaming "1998" at the top of its lungs. Want to change the font? Fight with &lt;code&gt;font=("Arial", 10)&lt;/code&gt;. Want a dark theme? Good luck. Want a real-time chart? Cram &lt;code&gt;matplotlib&lt;/code&gt; in there and pray.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same thing in PyQt6
&lt;/h2&gt;

&lt;p&gt;Now look at the equivalent in PyQt6:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# PyQt6 — clean, modern, stylable
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;PyQt6.QtWidgets&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;QApplication&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;QMainWindow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;QLabel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;QPushButton&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;QVBoxLayout&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;QWidget&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MainWindow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;QMainWindow&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;super&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setWindowTitle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;My fancy tool&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;central&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;QWidget&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;layout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;QVBoxLayout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;central&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;QLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Status: OK&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;button&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;QPushButton&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Scan&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;button&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;clicked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;on_scan&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addWidget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addWidget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;button&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setCentralWidget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;central&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setStyleSheet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
            QMainWindow { background-color: #282c34; }
            QLabel { color: #61afef; font-size: 14pt; font-weight: bold; }
            QPushButton {
                background-color: #61afef; color: #282c34;
                border-radius: 6px; padding: 8px 16px; font-weight: bold;
            }
            QPushButton:hover { background-color: #56a4e0; }
        &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;on_scan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Scanning...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;QApplication&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;window&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MainWindow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;show&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few extra lines. But the result? A dark window, a button with rounded corners and a hover effect, readable typography. &lt;strong&gt;And we didn't install a single external theme.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fz5pthwbtpit18w6qmjhj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fz5pthwbtpit18w6qmjhj.png" alt="Tkinter vs PyQt6 — side by side: gray Windows-95 styling on the left, modern dark dashboard with KPI cards on the right" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why PyQt6 changes the game
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Stylesheets (QSS) are basically CSS for your app
&lt;/h3&gt;

&lt;p&gt;This is probably the most underrated feature in Qt. If you know CSS, you already know 80% of QSS. Here's a slice of the dark theme I use in WatchTower:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;QWidget&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#282c34&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#abb2bf&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;font-family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;"Segoe UI"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;"Roboto"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;sans-serif&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10pt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nt"&gt;QPushButton&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#3a3f4b&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="m"&gt;#4b5263&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;6px&lt;/span&gt; &lt;span class="m"&gt;12px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nt"&gt;QPushButton&lt;/span&gt;&lt;span class="nd"&gt;:hover&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#4b5263&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nt"&gt;QHeaderView&lt;/span&gt;&lt;span class="nd"&gt;::section&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#21252b&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#e6efff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;6px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Load it from a &lt;code&gt;.qss&lt;/code&gt; file, apply it via &lt;code&gt;app.setStyleSheet(qss)&lt;/code&gt;, and &lt;strong&gt;the whole app&lt;/strong&gt; is themed in one shot. Light, dark, cyan, accessible — you swap a string. With Tkinter you styled widget by widget, by hand, while quietly weeping.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The signal/slot system is a superpower
&lt;/h3&gt;

&lt;p&gt;In Tkinter, components talk to each other through callbacks and sad global variables. In Qt, &lt;strong&gt;every widget can emit signals&lt;/strong&gt;, and anything else can subscribe.&lt;/p&gt;

&lt;p&gt;In WatchTower I have clickable KPI cards that emit a &lt;code&gt;card_clicked&lt;/code&gt; signal carrying the card's title (&lt;code&gt;"ACTIVE ALERTS"&lt;/code&gt;, &lt;code&gt;"MONITORED SITES"&lt;/code&gt;, etc.). The dashboard listens and filters the list accordingly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;PyQt6.QtCore&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pyqtSignal&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;PyQt6.QtWidgets&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;QFrame&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;KpiCard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;QFrame&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;card_clicked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pyqtSignal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# declares a signal that emits a string
&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;mousePressEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;card_clicked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;super&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;mousePressEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# On the dashboard side:
&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;alerts_card&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;card_clicked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;filter_by_category&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Decoupled. Testable. Scalable. The widget doesn't know who's listening, the listener doesn't know where the signal came from. Real event-driven design — not three layers of &lt;code&gt;command=lambda: ...&lt;/code&gt; stacked on top of each other.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Threading that doesn't freeze your UI
&lt;/h3&gt;

&lt;p&gt;The worst moment in any Tkinter app is when you fire off a long-running operation and the window goes white for 30 seconds. You know it. We all know it.&lt;/p&gt;

&lt;p&gt;PyQt gives you &lt;code&gt;QThread&lt;/code&gt; and &lt;code&gt;QRunnable&lt;/code&gt; with &lt;code&gt;QThreadPool&lt;/code&gt;, and &lt;strong&gt;signals work across threads&lt;/strong&gt;. In WatchTower, my async scanner runs in a dedicated worker and pipes results back to the UI through signals:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;PyQt6.QtCore&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;QThread&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pyqtSignal&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ScanWorker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;QThread&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;progress&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pyqtSignal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;# %, message
&lt;/span&gt;    &lt;span class="n"&gt;site_done&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pyqtSignal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;           &lt;span class="c1"&gt;# structured result
&lt;/span&gt;    &lt;span class="n"&gt;finished&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pyqtSignal&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sites&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;super&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sites&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sites&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;site&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sites&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scan_one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# blocking — that's fine in here
&lt;/span&gt;            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sites&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;site_done&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;finished&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;# In the main window:
&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;worker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ScanWorker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;my_sites&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;statusBar&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;showMessage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;site_done&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dashboard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;add_result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The UI stays responsive. You can scroll, click elsewhere, open a dialog. Tkinter doesn't do this natively, and the &lt;code&gt;threading&lt;/code&gt; + &lt;code&gt;after()&lt;/code&gt; workarounds are fragile.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. The widgets you actually want in 2026
&lt;/h3&gt;

&lt;p&gt;Tkinter gives you a &lt;code&gt;Listbox&lt;/code&gt;. Qt gives you &lt;code&gt;QListView&lt;/code&gt;, &lt;code&gt;QTableView&lt;/code&gt;, &lt;code&gt;QTreeView&lt;/code&gt; with a proper &lt;strong&gt;model/view&lt;/strong&gt; system, sorting, filtering, inline editing, drag &amp;amp; drop, and virtualization for millions of rows. Drop a &lt;code&gt;QSortFilterProxyModel&lt;/code&gt; on top and you get text search for free.&lt;/p&gt;

&lt;p&gt;For real-time charts, you have &lt;code&gt;QtCharts&lt;/code&gt; (official) or &lt;code&gt;pyqtgraph&lt;/code&gt; (extremely fast, perfect for monitoring dashboards). In WatchTower I plot live alert counts and CPU usage with zero lag.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2o0xbi4pkh1zw0p2vdsx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2o0xbi4pkh1zw0p2vdsx.png" alt="WatchTower's multi-site grid view — four websites rendered in parallel using QWebEngineView, with live alert badges" width="605" height="340"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And icons? One line with &lt;strong&gt;qtawesome&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;qtawesome&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;qta&lt;/span&gt;
&lt;span class="n"&gt;button&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setIcon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;qta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fa5s.shield-alt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;color&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#61afef&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Thousands of Font Awesome, Material Design, and Elusive icons — recolorable, resizable, vectorial.&lt;/p&gt;

&lt;h2&gt;
  
  
  WatchTower, the project that converted me
&lt;/h2&gt;

&lt;p&gt;I built WatchTower, a website defacement monitoring system, because I needed a real-time dashboard surveilling dozens of sites. Here's what PyQt6 let me do &lt;strong&gt;painlessly&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A modern dashboard&lt;/strong&gt; with clickable KPI cards, live stats, and a multi-site grid (1, 2, or 4 sites in parallel)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embedded WebViews&lt;/strong&gt; (&lt;code&gt;QWebEngineView&lt;/code&gt;) showing the actual rendered pages — practically impossible to do cleanly in Tkinter&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A theming system&lt;/strong&gt;: dark, light, cyan — hot-swappable without restarting&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Async workers&lt;/strong&gt; that scan in parallel without freezing the interface&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Incident timelines&lt;/strong&gt;, multi-tab config dialogs, system tray, native notifications, PDF report generation…&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every UI brick is a reusable widget that emits its own signals. When I add a new data source, I don't rewrite half the interface — I wire up a signal and move on.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6ounnmsonkjf44tfznc8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6ounnmsonkjf44tfznc8.png" alt="WatchTower's multi-tab configuration dialog — General, Site Signatures, Reputation, Alerts, Interface, API Keys, Backup tabs" width="605" height="340"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The same project in Tkinter? Either it wouldn't exist, or it'd be 10,000 lines of spaghetti.&lt;/p&gt;

&lt;h2&gt;
  
  
  "But Tkinter is in the standard library!"
&lt;/h2&gt;

&lt;p&gt;Yes. So is &lt;code&gt;urllib&lt;/code&gt;, and yet everyone uses &lt;code&gt;requests&lt;/code&gt;. Performance and ergonomics matter &lt;strong&gt;more&lt;/strong&gt; than skipping a &lt;code&gt;pip install&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For reference:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;PyQt6 qtawesome pyqtgraph
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's all you need to ship apps that look like real tools, not freshman CS projects.&lt;/p&gt;

&lt;h2&gt;
  
  
  PyQt6 vs PyQt5?
&lt;/h2&gt;

&lt;p&gt;I started on PyQt5 and moved to PyQt6 a while back. The main differences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Namespaced enums&lt;/strong&gt;: &lt;code&gt;Qt.AlignCenter&lt;/code&gt; becomes &lt;code&gt;Qt.AlignmentFlag.AlignCenter&lt;/code&gt;. More verbose, but much clearer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;exec_()&lt;/code&gt; is now &lt;code&gt;exec()&lt;/code&gt;&lt;/strong&gt;: Python 3 freed the keyword.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cleaner, better typed&lt;/strong&gt;, and that's the version Qt keeps investing in.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're starting today, go straight to PyQt6 (or PySide6 if you want the LGPL license).&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to start
&lt;/h2&gt;

</description>
      <category>python</category>
      <category>pyqt</category>
      <category>gui</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Créer des interfaces Python modernes et fluides avec PyQt6</title>
      <dc:creator>h13ris</dc:creator>
      <pubDate>Mon, 04 May 2026 18:29:44 +0000</pubDate>
      <link>https://forem.com/hi3ris/creer-des-interfaces-python-modernes-et-fluides-avec-pyqt6-b5m</link>
      <guid>https://forem.com/hi3ris/creer-des-interfaces-python-modernes-et-fluides-avec-pyqt6-b5m</guid>
      <description>&lt;p&gt;J'ai un aveu à faire : pendant longtemps, quand un dev me montrait fièrement son app Python avec un bouton gris carré et une &lt;code&gt;Listbox&lt;/code&gt; qui sentait Windows 95, je hochais la tête poliment. Aujourd'hui, j'ai arrêté.&lt;/p&gt;

&lt;p&gt;Pas parce que je suis devenu méchant. Parce que &lt;strong&gt;PyQt6 existe&lt;/strong&gt;, et qu'il n'y a plus aucune excuse.&lt;/p&gt;

&lt;p&gt;Cet article, c'est ma tentative de te convaincre — toi qui ouvres encore &lt;code&gt;tkinter&lt;/code&gt; par réflexe — qu'il existe quelque chose de radicalement mieux. Je vais te montrer pourquoi, à travers des comparaisons côte à côte et des extraits réels du projet sur lequel je bosse depuis des mois : &lt;strong&gt;WatchTower&lt;/strong&gt;, un système de monitoring de défacement web construit entièrement avec PyQt6.&lt;/p&gt;

&lt;p&gt;Spoiler : à la fin, tu vas vouloir réécrire tout ce que tu as fait avec Tkinter.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4xmfrpjp213a0tkr1pqo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4xmfrpjp213a0tkr1pqo.png" alt="Le dashboard principal de WatchTower construit avec PyQt6 — KPI cards, historique des alertes, table des sites surveillés et side panel de notifications" width="605" height="340"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Le dashboard principal de WatchTower. 100 % PyQt6, sans framework JS, sans Electron.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Le drame Tkinter en trois lignes
&lt;/h2&gt;

&lt;p&gt;Soyons honnêtes deux secondes. Tkinter est livré avec Python, c'est gratuit, c'est documenté, et ça marche. Voilà. C'est le seul argument. Le reste, c'est du masochisme.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Tkinter — un "dashboard" qui fait pleurer
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;tkinter&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;tk&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;tkinter&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ttk&lt;/span&gt;

&lt;span class="n"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Tk&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Mon super outil&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;geometry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;400x300&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Label&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Statut : OK&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bg&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;grey&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fg&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;white&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pady&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;button&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Scanner&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;button&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pack&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mainloop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tu obtiens un truc gris, plat, et qui te crie « 1998 » en pleine figure. Tu veux changer la police ? Tu te bagarres avec &lt;code&gt;font=("Arial", 10)&lt;/code&gt;. Tu veux un thème sombre ? Bon courage. Tu veux un graphique en temps réel ? Tu intègres &lt;code&gt;matplotlib&lt;/code&gt; au chausse-pied et tu pries.&lt;/p&gt;

&lt;h2&gt;
  
  
  La même chose en PyQt6
&lt;/h2&gt;

&lt;p&gt;Maintenant regarde le même bidule en PyQt6 :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# PyQt6 — propre, moderne, et stylable
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;PyQt6.QtWidgets&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;QApplication&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;QMainWindow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;QLabel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;QPushButton&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;QVBoxLayout&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;QWidget&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MainWindow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;QMainWindow&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;super&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setWindowTitle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Mon super outil&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;central&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;QWidget&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;layout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;QVBoxLayout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;central&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;QLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Statut : OK&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;button&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;QPushButton&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Scanner&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;button&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;clicked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;on_scan&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addWidget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addWidget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;button&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setCentralWidget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;central&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setStyleSheet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
            QMainWindow { background-color: #282c34; }
            QLabel { color: #61afef; font-size: 14pt; font-weight: bold; }
            QPushButton {
                background-color: #61afef; color: #282c34;
                border-radius: 6px; padding: 8px 16px; font-weight: bold;
            }
            QPushButton:hover { background-color: #56a4e0; }
        &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;on_scan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Scan en cours...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;QApplication&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;window&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MainWindow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;show&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;À peine plus de lignes. Mais le résultat ? Une fenêtre sombre, un bouton avec coins arrondis, un effet hover, et une typo lisible. &lt;strong&gt;Et tout ça sans installer un seul thème externe.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fz5pthwbtpit18w6qmjhj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fz5pthwbtpit18w6qmjhj.png" alt="Comparaison côte à côte — à gauche Tkinter avec son look Windows 95 gris et plat, à droite un dashboard PyQt6 sombre avec KPI cards colorées et bouton arrondi" width="800" height="400"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Tkinter (à gauche) vs PyQt6 (à droite). Devine lequel donne envie de cliquer.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Pourquoi PyQt6 change la donne
&lt;/h2&gt;
&lt;h3&gt;
  
  
  1. Les stylesheets (QSS), c'est du CSS pour ton app
&lt;/h3&gt;

&lt;p&gt;C'est probablement la fonctionnalité la plus sous-estimée de Qt. Tu connais CSS ? Alors tu connais déjà 80 % de QSS. Voici un extrait du thème sombre que j'utilise dans WatchTower :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;QWidget&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#282c34&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#abb2bf&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;font-family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;"Segoe UI"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;"Roboto"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;sans-serif&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10pt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nt"&gt;QPushButton&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#3a3f4b&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="m"&gt;#4b5263&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;6px&lt;/span&gt; &lt;span class="m"&gt;12px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nt"&gt;QPushButton&lt;/span&gt;&lt;span class="nd"&gt;:hover&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#4b5263&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nt"&gt;QHeaderView&lt;/span&gt;&lt;span class="nd"&gt;::section&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#21252b&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#e6efff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;6px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tu charges ça depuis un fichier &lt;code&gt;.qss&lt;/code&gt;, tu l'appliques avec &lt;code&gt;app.setStyleSheet(qss)&lt;/code&gt;, et &lt;strong&gt;toute l'application&lt;/strong&gt; est habillée d'un coup. Thème clair, sombre, cyan, accessible : il suffit de switcher la string. Avec Tkinter, tu changeais ça widget par widget, à la main, en pleurant.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Le système signaux/slots, c'est un super-pouvoir
&lt;/h3&gt;

&lt;p&gt;Dans Tkinter, la communication entre composants se fait avec des callbacks et des variables globales tristes. En Qt, &lt;strong&gt;chaque widget peut émettre des signaux&lt;/strong&gt;, et n'importe qui peut s'y connecter.&lt;/p&gt;

&lt;p&gt;Dans WatchTower, j'ai des cartes KPI cliquables qui émettent un signal &lt;code&gt;card_clicked&lt;/code&gt; portant le titre de la carte (« ALERTES ACTIVES », « SITES SURVEILLÉS », etc.). Le dashboard écoute ce signal et filtre la liste en conséquence :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;PyQt6.QtCore&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pyqtSignal&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;PyQt6.QtWidgets&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;QFrame&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;KpiCard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;QFrame&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;card_clicked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pyqtSignal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# déclare un signal qui émet une string
&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;mousePressEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;card_clicked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;super&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;mousePressEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Côté dashboard :
&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;alerts_card&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;card_clicked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;filter_by_category&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;C'est découplé, testable, et ça scale. Le widget ne sait pas qui l'écoute, et le listener ne sait pas d'où vient le signal. Du &lt;strong&gt;vrai&lt;/strong&gt; event-driven, pas du &lt;code&gt;command=lambda: ...&lt;/code&gt; empilé sur trois étages.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Le threading qui ne fait pas freezer ton UI
&lt;/h3&gt;

&lt;p&gt;Le pire moment d'une app Tkinter, c'est quand tu lances une opération longue et que la fenêtre devient blanche pendant 30 secondes. Tu connais. Tout le monde connaît.&lt;/p&gt;

&lt;p&gt;PyQt te donne &lt;code&gt;QThread&lt;/code&gt; et &lt;code&gt;QRunnable&lt;/code&gt; avec &lt;code&gt;QThreadPool&lt;/code&gt;, et &lt;strong&gt;les signaux marchent à travers les threads&lt;/strong&gt;. Concrètement, dans WatchTower, mon scanner async tourne dans un worker dédié et envoie les résultats à l'UI via signaux :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;PyQt6.QtCore&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;QThread&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pyqtSignal&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ScanWorker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;QThread&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;progress&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pyqtSignal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;# %, message
&lt;/span&gt;    &lt;span class="n"&gt;site_done&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pyqtSignal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;           &lt;span class="c1"&gt;# résultat structuré
&lt;/span&gt;    &lt;span class="n"&gt;finished&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pyqtSignal&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sites&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;super&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sites&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sites&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;site&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sites&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scan_one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# blocking, mais c'est OK ici
&lt;/span&gt;            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sites&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;site_done&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;finished&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;# Dans la fenêtre principale :
&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;worker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ScanWorker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;my_sites&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;statusBar&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;showMessage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;site_done&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dashboard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;add_result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;L'UI reste fluide. Tu peux scroller, cliquer ailleurs, ouvrir un dialog. Tkinter ne fait &lt;strong&gt;pas&lt;/strong&gt; ça nativement, et les workarounds avec &lt;code&gt;threading&lt;/code&gt; + &lt;code&gt;after()&lt;/code&gt; sont fragiles.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Les widgets qu'on attend en 2026
&lt;/h3&gt;

&lt;p&gt;Tkinter te donne une &lt;code&gt;Listbox&lt;/code&gt;. Qt te donne &lt;code&gt;QListView&lt;/code&gt;, &lt;code&gt;QTableView&lt;/code&gt;, &lt;code&gt;QTreeView&lt;/code&gt; avec un système &lt;strong&gt;modèle/vue&lt;/strong&gt; (MVC), tri, filtre, édition inline, drag &amp;amp; drop, virtualisation pour des millions de lignes. Tu peux brancher un &lt;code&gt;QSortFilterProxyModel&lt;/code&gt; au-dessus de ton modèle et obtenir une recherche textuelle gratuite.&lt;/p&gt;

&lt;p&gt;Pour les graphiques en temps réel, tu as &lt;code&gt;QtCharts&lt;/code&gt; (officiel) ou &lt;code&gt;pyqtgraph&lt;/code&gt; (très rapide, parfait pour du monitoring). Dans WatchTower, j'affiche en live l'évolution des alertes et la consommation CPU sans aucun freeze.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2o0xbi4pkh1zw0p2vdsx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2o0xbi4pkh1zw0p2vdsx.png" alt="Vue grille multi-sites de WatchTower — quatre sites web rendus en parallèle dans des QWebEngineView avec badges d'alerte" width="605" height="340"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Quatre sites surveillés en parallèle avec leur vrai rendu HTML. Bonne chance à faire ça en Tkinter.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Et pour les icônes ? Une seule ligne avec &lt;strong&gt;qtawesome&lt;/strong&gt; :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;qtawesome&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;qta&lt;/span&gt;
&lt;span class="n"&gt;button&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setIcon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;qta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fa5s.shield-alt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;color&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#61afef&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Des milliers d'icônes Font Awesome, Material Design, Elusive — colorables, redimensionnables, vectorielles.&lt;/p&gt;

&lt;h2&gt;
  
  
  WatchTower, le projet qui m'a fait basculer
&lt;/h2&gt;

&lt;p&gt;J'ai construit WatchTower, un système de monitoring de défacement web, pour avoir un tableau de bord temps réel surveillant des dizaines de sites. Voici ce que PyQt6 m'a permis de faire &lt;strong&gt;sans douleur&lt;/strong&gt; :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dashboard moderne&lt;/strong&gt; avec cartes KPI cliquables, statistiques live, et grille multi-sites (1, 2 ou 4 sites en parallèle)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WebViews intégrées&lt;/strong&gt; (&lt;code&gt;QWebEngineView&lt;/code&gt;) qui affichent le rendu réel des sites surveillés — impossible à faire proprement en Tkinter&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Système de thèmes&lt;/strong&gt; : sombre, clair, cyan, switchable à chaud sans relancer l'app&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Workers async&lt;/strong&gt; qui scannent en parallèle sans figer l'interface&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timeline d'incidents&lt;/strong&gt;, dialogs de configuration multi-onglets, system tray, notifications natives, génération de rapports PDF…&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Chaque brique de l'UI est un widget réutilisable qui émet ses propres signaux. Quand j'ajoute une nouvelle source de données, je n'ai pas à réécrire la moitié de l'interface : je connecte un signal, et c'est plié.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6ounnmsonkjf44tfznc8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6ounnmsonkjf44tfznc8.png" alt="Dialog de configuration multi-onglets de WatchTower — onglets Général, Sites Signatures, Réputation, Alertes, Interface, Clés API et Sauvegarde" width="605" height="340"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Sept onglets, chacun étant un widget indépendant. C'est ça, la modularité.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Le même projet en Tkinter ? Soit il n'aurait pas existé, soit il aurait fait 10 000 lignes de spaghetti.&lt;/p&gt;
&lt;h2&gt;
  
  
  « Mais Tkinter est dans la stdlib ! »
&lt;/h2&gt;

&lt;p&gt;Oui. Et &lt;code&gt;urllib&lt;/code&gt; aussi, mais tout le monde utilise &lt;code&gt;requests&lt;/code&gt;. La performance et l'ergonomie comptent &lt;strong&gt;plus&lt;/strong&gt; que l'absence d'un &lt;code&gt;pip install&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Pour info :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;PyQt6 qtawesome pyqtgraph
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Et tu as de quoi faire des apps qui ressemblent à des outils pros, pas à un TP de L1.&lt;/p&gt;

&lt;h2&gt;
  
  
  Et PyQt6 vs PyQt5 ?
&lt;/h2&gt;

&lt;p&gt;J'ai démarré sur PyQt5, je suis passé à PyQt6 il y a un moment. Les différences principales :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Enums namespacées&lt;/strong&gt; : &lt;code&gt;Qt.AlignCenter&lt;/code&gt; devient &lt;code&gt;Qt.AlignmentFlag.AlignCenter&lt;/code&gt;. Plus verbeux, mais beaucoup plus clair.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;exec_()&lt;/code&gt; devient &lt;code&gt;exec()&lt;/code&gt;&lt;/strong&gt; : Python 3 a libéré le mot-clé.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plus propre, mieux typé&lt;/strong&gt;, et c'est la version sur laquelle Qt continue d'investir.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Si tu démarres aujourd'hui, va directement sur PyQt6 (ou PySide6 si tu veux la licence LGPL).&lt;/p&gt;

&lt;h2&gt;
  
  
  Par où commencer
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;code&gt;pip install PyQt6&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Code une fenêtre minuscule. Ressens la satisfaction d'un bouton qui a l'air normal.&lt;/li&gt;
&lt;li&gt;Découvre &lt;code&gt;QSS&lt;/code&gt;, joue avec un thème sombre.&lt;/li&gt;
&lt;li&gt;Fais ton premier signal/slot custom.&lt;/li&gt;
&lt;li&gt;Ajoute &lt;code&gt;qtawesome&lt;/code&gt; et &lt;code&gt;pyqtgraph&lt;/code&gt; quand tu veux passer au niveau au-dessus.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;La doc de Qt est dense mais excellente, et 90 % du temps, ce que tu cherches existe déjà comme widget officiel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Faire des interfaces moches en 2026 est un &lt;strong&gt;choix&lt;/strong&gt;, pas une fatalité. PyQt6 te donne du QSS façon CSS, des signaux/slots qui tiennent la route, du threading propre, et un catalogue de widgets qui couvre la quasi-totalité des besoins.&lt;/p&gt;

&lt;p&gt;Si tu veux voir ce que ça donne à grande échelle, j'ai mis WatchTower en open-source sur &lt;a href="https://github.com/hi3ris" rel="noopener noreferrer"&gt;github.com/hi3ris&lt;/a&gt;, et le reste de mes projets traîne sur &lt;a href="https://hi3ris.blueshield.tg/" rel="noopener noreferrer"&gt;hi3ris.blueshield.tg&lt;/a&gt; si tu veux jeter un œil. Et si après ça tu reviens à Tkinter pour ta prochaine app desktop, c'est qu'on n'aura &lt;strong&gt;vraiment&lt;/strong&gt; pas eu la même conversation.&lt;/p&gt;

&lt;p&gt;Allez, à toi de jouer. Et arrête de pondre des Listbox grises.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Si cet article t'a parlé, lâche un ❤️ et un 🦄 — et dis-moi en commentaire le pire crime UI que tu aies commis avec Tkinter. Promis, pas de jugement (enfin, un peu).&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>pyqt</category>
      <category>gui</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>A Website at Home? Yep, Even Without a Static IP!</title>
      <dc:creator>h13ris</dc:creator>
      <pubDate>Fri, 06 Jun 2025 23:03:13 +0000</pubDate>
      <link>https://forem.com/hi3ris/a-website-at-home-yep-even-without-a-static-ip-3c92</link>
      <guid>https://forem.com/hi3ris/a-website-at-home-yep-even-without-a-static-ip-3c92</guid>
      <description>&lt;h2&gt;
  
  
  It all started with a joke...
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"You know, you could also host your site at home."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It's possible, yes. And I thought to myself: &lt;strong&gt;how could I do it... for FREE?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;To make a site accessible from the internet, what do you need?&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; A &lt;strong&gt;server&lt;/strong&gt; – in my case, it'll be my &lt;strong&gt;desktop&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt; A &lt;strong&gt;public IP&lt;/strong&gt; – I have one, but it's &lt;strong&gt;dynamic&lt;/strong&gt; (it changes).&lt;/li&gt;
&lt;li&gt; Continuous &lt;strong&gt;uptime&lt;/strong&gt; – and with the power cuts around here, we can forget about that.&lt;/li&gt;
&lt;li&gt; A &lt;strong&gt;domain name&lt;/strong&gt; – because sharing your IP address isn't very cool.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I'll handle finding solutions for points &lt;strong&gt;1, 2, and 4&lt;/strong&gt;.&lt;br&gt;
As for point &lt;strong&gt;3&lt;/strong&gt;, I'll leave that to the &lt;strong&gt;power company&lt;/strong&gt; (good luck, folks, I'm counting on you 😉).&lt;/p&gt;

&lt;h2&gt;
  
  
  Goal: Get a free domain name that automatically points to my public IP, even when it changes.
&lt;/h2&gt;

&lt;p&gt;This is the part where I'm supposed to tell you how I discovered America and defeated Satan before stumbling upon the &lt;strong&gt;FreedomBox&lt;/strong&gt; project, blah blah blah...&lt;/p&gt;

&lt;p&gt;But let's be serious: today, I'm talking about &lt;strong&gt;GnuDIP&lt;/strong&gt;, a DDNS service that FreedomBox introduced me to – and it's the real deal.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is GnuDIP?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;GnuDIP&lt;/strong&gt; is a &lt;strong&gt;Dynamic DNS (DDNS)&lt;/strong&gt; service.&lt;br&gt;
It allows you to link a &lt;strong&gt;domain name&lt;/strong&gt; to a &lt;strong&gt;changing IP address&lt;/strong&gt; – typically what you have if you're with a standard ISP.&lt;/p&gt;

&lt;p&gt;The public instance available at &lt;a href="https://gnudip.datasystems24.net" rel="noopener noreferrer"&gt;gnudip.datasystems24.net&lt;/a&gt; offers two free domains:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;freedombox.rocks&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sds-ip.de&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And yes, &lt;strong&gt;it's 100% free&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tutorial: Using GnuDIP to Get a Free Subdomain
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Create an Account
&lt;/h3&gt;

&lt;p&gt;Go to &lt;a href="https://gnudip.datasystems24.net" rel="noopener noreferrer"&gt;https://gnudip.datasystems24.net&lt;/a&gt;&lt;br&gt;
And sign up:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fokj2ek43byh4g2fgcgvr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fokj2ek43byh4g2fgcgvr.png" alt="GnuDIP signup form" width="475" height="350"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Create a Subdomain
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hostname&lt;/strong&gt;: choose a short, simple name with no special characters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Domain&lt;/strong&gt;: select &lt;code&gt;sds-ip.de&lt;/code&gt; or &lt;code&gt;freedombox.rocks&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvrx8f2wd9170hnh5ymxf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvrx8f2wd9170hnh5ymxf.png" alt="Subdomain registration" width="508" height="384"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Once registered, go back to the login page and sign in.&lt;/p&gt;

&lt;h3&gt;
  
  
  By default, the domain points to the IP you used to connect.
&lt;/h3&gt;

&lt;p&gt;But you can change it manually:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Far6vrbfe1q343sw5dhoo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Far6vrbfe1q343sw5dhoo.png" alt="Manual IP configuration" width="618" height="525"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Even Better: Automatically Update Your Public IP
&lt;/h3&gt;

&lt;p&gt;If your IP changes regularly (as it does with many providers), GnuDIP offers a simple solution: &lt;strong&gt;the "Quick Login URL"&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Just click on &lt;strong&gt;"Set quick login URL"&lt;/strong&gt;, copy the link, and use it in a small script that runs regularly, using &lt;code&gt;cron&lt;/code&gt; for example.&lt;/p&gt;

&lt;p&gt;A simple &lt;code&gt;curl&lt;/code&gt; to this link will automatically update the domain's IP:&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
bash
curl "[https://gnudip.datasystems24.net/nic/update?username=...&amp;amp;password=...&amp;amp;hostname=](https://gnudip.datasystems24.net/nic/update?username=...&amp;amp;password=...&amp;amp;hostname=)..."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>webdev</category>
      <category>opensource</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Are Quantum Computers Really Going to Break the Internet?</title>
      <dc:creator>h13ris</dc:creator>
      <pubDate>Fri, 06 Jun 2025 22:28:44 +0000</pubDate>
      <link>https://forem.com/hi3ris/so-are-quantum-computers-really-going-to-break-the-internet-2i1e</link>
      <guid>https://forem.com/hi3ris/so-are-quantum-computers-really-going-to-break-the-internet-2i1e</guid>
      <description>&lt;p&gt;It's all you hear about these days. The great "crypto-geddon" is coming. The quantum machine that will obliterate our passwords, our banks—our entire digital world.&lt;/p&gt;

&lt;p&gt;So, is it time to panic? Sell your Bitcoin, build a bunker? Or is this just another tech-apocalypse storm in a teacup?&lt;/p&gt;

&lt;p&gt;Let's be real and break it down.&lt;/p&gt;

&lt;h3&gt;
  
  
  So, What's the Actual Flaw?
&lt;/h3&gt;

&lt;p&gt;To put it simply, modern web security is like a padlock. It relies on a mathematical principle: it's super easy to multiply two huge prime numbers together, but it's a total nightmare to do the reverse. If I give you the result, it would take a normal computer thousands of years to figure out the two original numbers.&lt;/p&gt;

&lt;p&gt;The problem is, a researcher named Peter Shor figured out an algorithm back in 1994 that, on a quantum computer, could do that math in a few hours.&lt;/p&gt;

&lt;p&gt;So yes, on paper, the threat is real. Our current locks (the ones called RSA and ECC) have a known weakness. The question isn't &lt;em&gt;if&lt;/em&gt; they're vulnerable, but &lt;em&gt;when&lt;/em&gt; someone will have the key to open them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Okay, But When is the Real Danger?
&lt;/h3&gt;

&lt;p&gt;Take a breath. No, your bank account isn't getting drained tomorrow. Building a quantum computer powerful enough to do this is an incredibly complex feat of engineering. We're still a long way off. The most serious estimates point to 10, 15, or even 20 years from now.&lt;/p&gt;

&lt;p&gt;So, why is everyone talking about it &lt;em&gt;now&lt;/em&gt;?&lt;/p&gt;

&lt;p&gt;Because of a much more insidious idea: &lt;strong&gt;Harvest Now, Decrypt Later.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's the real danger today. Imagine intelligence agencies or criminal groups vacuuming up and storing tons of encrypted data that they can't currently read. They're just waiting patiently. The day they get their hands on a quantum computer, they can unpack all of our past secrets.&lt;/p&gt;

&lt;p&gt;Your work emails, trade secrets, health records... if that information needs to stay secret for more than 10 years, the problem isn't in the future. It's right now.&lt;/p&gt;

&lt;h3&gt;
  
  
  Luckily, the Experts Haven't Been Sleeping.
&lt;/h3&gt;

&lt;p&gt;Faced with this, the crypto community has been at work for years. The solution is called &lt;strong&gt;Post-Quantum Cryptography (PQC)&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The concept? If thieves learn how to pick our current locks, let's just switch to a completely different kind of lock. Let's use weird math problems that even a quantum computer can't solve. It's as simple as that.&lt;/p&gt;

&lt;p&gt;NIST (the U.S. institute that sets the tone for tech standards) launched a global competition years ago to find the best ones. The winners have been announced, and names like Kyber and Dilithium are the new rock stars.&lt;/p&gt;

&lt;h3&gt;
  
  
  And in the Real World, Is It Actually Being Deployed?
&lt;/h3&gt;

&lt;p&gt;The good news is, this isn't just theory anymore. It's already in the wild.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your &lt;strong&gt;Signal&lt;/strong&gt; app is already using one of these new algorithms to protect your messages, specifically against data harvesting.&lt;/li&gt;
&lt;li&gt;Your &lt;strong&gt;Chrome&lt;/strong&gt; browser is quietly testing and deploying a hybrid security model (classic + post-quantum) to keep your HTTPS connections safe.&lt;/li&gt;
&lt;li&gt;Tech giants, from Microsoft to the Linux community, are currently integrating these algorithms into the core of their systems.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The transition has definitely begun.&lt;/p&gt;

&lt;h3&gt;
  
  
  The 'But' in the Story (Because There's Always One).
&lt;/h3&gt;

&lt;p&gt;If everything is going so well, why is it taking so long? Because the task is monumental.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Key Size.&lt;/strong&gt; The new keys are much larger. It makes no difference to you and me, but for billions of small IoT devices or high-speed banking systems, it's a real headache.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Inertia from the 'Old World'.&lt;/strong&gt; Crypto is everywhere. In 20-year-old legacy software, in satellites, in cars... Updating all of that without breaking anything is a titanic effort.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;The Expert Shortage.&lt;/strong&gt; There are very few people who truly master this subject from A to Z. A whole generation of developers will need to be trained on these new tools.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  So, Do We Panic or Not?
&lt;/h3&gt;

&lt;p&gt;No, there's no need to panic. Crying "crypto-geddon" is over the top.&lt;/p&gt;

&lt;p&gt;But you can't be naive, either. The threat is real, and the danger of data harvesting is immediate. The good news is that the solution is already in motion.&lt;/p&gt;

&lt;p&gt;That's where the real work is: for companies, it's time to start looking for the old locks and figuring out how to change them. For us developers, it's time to start learning and playing with these new libraries.&lt;/p&gt;

&lt;p&gt;It's less spectacular than crying wolf, for sure. But it's how we actually move forward.&lt;/p&gt;

</description>
      <category>quantum</category>
    </item>
    <item>
      <title>L'ordinateur quantique va casser Internet. Vraiment ?</title>
      <dc:creator>h13ris</dc:creator>
      <pubDate>Fri, 06 Jun 2025 22:24:09 +0000</pubDate>
      <link>https://forem.com/hi3ris/lordinateur-quantique-va-casser-internet-vraiment--2j0k</link>
      <guid>https://forem.com/hi3ris/lordinateur-quantique-va-casser-internet-vraiment--2j0k</guid>
      <description>&lt;p&gt;On n'entend que ça en ce moment. Le grand "crypto-geddon" qui arrive. La machine quantique qui va pulvériser nos mots de passe, nos banques, bref, tout notre monde numérique.&lt;/p&gt;

&lt;p&gt;Alors, on cède à la panique ? On vend ses Bitcoins, on se construit un bunker ? Ou est-ce que c'est encore une de ces tempêtes dans un verre d'eau de la tech ?&lt;/p&gt;

&lt;p&gt;Franchement, faisons le tri.&lt;/p&gt;

&lt;h3&gt;
  
  
  La faille, elle est où au juste ?
&lt;/h3&gt;

&lt;p&gt;Pour faire simple, aujourd'hui, la sécurité du web, c'est un peu comme une serrure. Elle repose sur un principe mathématique : c'est super facile de multiplier deux très grands nombres, mais c'est un cauchemar de faire l'inverse. Si je vous donne le résultat, retrouver les deux nombres de départ vous prendrait des milliers d'années avec un ordi normal.&lt;/p&gt;

&lt;p&gt;Le problème, c'est qu'un chercheur, Peter Shor, a imaginé en 1994 un algorithme qui, sur un ordinateur quantique, ferait ce calcul en quelques heures.&lt;/p&gt;

&lt;p&gt;Donc oui, sur le papier, la menace existe. Nos serrures actuelles (celles qui s'appellent RSA et ECC) ont bien une faiblesse connue. La question n'est pas de savoir &lt;em&gt;si&lt;/em&gt; elles sont vulnérables, mais &lt;em&gt;quand&lt;/em&gt; quelqu'un aura l'outil pour les ouvrir.&lt;/p&gt;

&lt;h3&gt;
  
  
  OK, mais le vrai danger, c'est pour quand ?
&lt;/h3&gt;

&lt;p&gt;Respirez. Non, votre compte en banque ne sera pas vidé demain. Un ordinateur quantique assez puissant pour ça, c'est une bête incroyablement complexe à construire. On en est encore loin. Les estimations les plus sérieuses parient sur 10, 15, voire 20 ans.&lt;/p&gt;

&lt;p&gt;Alors, pourquoi on en parle &lt;em&gt;maintenant&lt;/em&gt; ?&lt;/p&gt;

&lt;p&gt;À cause d'une idée bien plus vicieuse : &lt;strong&gt;Récolter maintenant, déchiffrer plus tard.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;C'est ça, le vrai danger aujourd'hui. Imaginez des services de renseignement ou des groupes criminels qui aspirent et stockent des tonnes de données chiffrées qu'ils ne peuvent pas lire. Ils attendent juste, patiemment. Le jour où ils auront la machine quantique, ils pourront déballer tous nos secrets passés.&lt;/p&gt;

&lt;p&gt;Vos emails pros, des secrets industriels, des données médicales... Si ces infos doivent rester secrètes dans 10 ans, le problème n'est plus du tout futuriste. Il est bien présent.&lt;/p&gt;

&lt;h3&gt;
  
  
  Heureusement, les experts n'ont pas dormi.
&lt;/h3&gt;

&lt;p&gt;Face à ça, la communauté crypto s'est mise au boulot depuis des années. La solution, c'est ce qu'on appelle la &lt;strong&gt;cryptographie post-quantique (PQC)&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Le concept ? Si les voleurs apprennent à crocheter nos serrures actuelles, changeons de type de serrure. Utilisons des problèmes mathématiques tordus que même un ordinateur quantique ne sait pas résoudre. C'est aussi simple que ça.&lt;/p&gt;

&lt;p&gt;Le NIST (l'institut américain qui fait la pluie et le beau temps sur les standards) a lancé une compétition mondiale il y a des années pour trouver les meilleures. Les gagnants ont été annoncés, et des noms comme Kyber ou Dilithium sont les nouvelles stars.&lt;/p&gt;

&lt;h3&gt;
  
  
  Et concrètement, ça se déploie ?
&lt;/h3&gt;

&lt;p&gt;La bonne nouvelle, c'est que ce n'est plus de la théorie. Ça tourne déjà.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Votre appli &lt;strong&gt;Signal&lt;/strong&gt; utilise déjà un de ces nouveaux algorithmes pour protéger vos messages, justement contre la récolte de données.&lt;/li&gt;
&lt;li&gt;Votre navigateur &lt;strong&gt;Chrome&lt;/strong&gt; teste et déploie discrètement une sécurité hybride (classique + post-quantique) pour s'assurer que vos connexions HTTPS restent sûres.&lt;/li&gt;
&lt;li&gt;Les grands de la tech, de Microsoft à la communauté Linux, sont en train de les intégrer au cœur de leurs systèmes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;La transition a bel et bien commencé.&lt;/p&gt;

&lt;h3&gt;
  
  
  Le "mais" de l'histoire (parce qu'il y en a un).
&lt;/h3&gt;

&lt;p&gt;Si tout va bien, pourquoi ça prend du temps ? Parce que le chantier est immense.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;La taille des clés.&lt;/strong&gt; Les nouvelles clés sont beaucoup plus grosses. Ça ne change rien pour vous et moi, mais pour des milliards de petits objets connectés ou des systèmes bancaires ultra-rapides, c'est un vrai casse-tête.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;L'inertie du "vieux monde".&lt;/strong&gt; La crypto est partout. Dans des vieux logiciels qui tournent depuis 20 ans, dans des satellites, des voitures... Mettre à jour tout ça sans rien casser est un travail de titan.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Le manque d'experts.&lt;/strong&gt; Il y a très peu de gens qui maîtrisent vraiment le sujet de A à Z. Il va falloir former toute une génération de développeurs à ces nouveaux outils.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Alors, on panique ou pas ?
&lt;/h3&gt;

&lt;p&gt;Non, pas la peine de paniquer. Crier au "crypto-geddon" est exagéré.&lt;/p&gt;

&lt;p&gt;Mais il ne faut pas être naïf non plus. La menace est réelle, et le danger de la récolte de données est immédiat. La bonne nouvelle, c'est que la solution est en marche.&lt;/p&gt;

&lt;p&gt;Le vrai boulot, il est là : pour les entreprises, il faut commencer à regarder où sont les vieilles serrures et comment les changer. Pour nous, les développeurs, il est temps de commencer à se former et à jouer avec ces nouvelles bibliothèques.&lt;/p&gt;

&lt;p&gt;C'est moins spectaculaire que de crier au loup, c'est sûr. Mais c'est comme ça qu'on avance.&lt;/p&gt;

</description>
      <category>quantique</category>
    </item>
    <item>
      <title>Site web à la maison ? Oui, même sans IP public!</title>
      <dc:creator>h13ris</dc:creator>
      <pubDate>Thu, 15 May 2025 11:36:29 +0000</pubDate>
      <link>https://forem.com/hi3ris/site-web-a-la-maison-oui-meme-sans-ip-fixe--549j</link>
      <guid>https://forem.com/hi3ris/site-web-a-la-maison-oui-meme-sans-ip-fixe--549j</guid>
      <description>&lt;h2&gt;
  
  
  Tout est parti d'une blague...
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"Tu peux aussi héberger ton site chez toi."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;C'est possible, oui. Et je me suis dit : &lt;strong&gt;comment est-ce que je pourrais le faire... GRATUITEMENT ?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Pour rendre un site accessible depuis Internet, il faut quoi ?&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Un &lt;strong&gt;serveur&lt;/strong&gt; – dans mon cas, ce sera mon &lt;strong&gt;desktop&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Une &lt;strong&gt;IP publique&lt;/strong&gt; – j'en ai une, mais elle est &lt;strong&gt;dynamique&lt;/strong&gt; (elle change).&lt;/li&gt;
&lt;li&gt;Une &lt;strong&gt;disponibilité&lt;/strong&gt; continue – et là, avec les coupures d’électricité, on oublie &lt;/li&gt;
&lt;li&gt;Un &lt;strong&gt;nom de domaine&lt;/strong&gt; – parce que donner son IP, c’est pas très sexy.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Je me charge de trouver des solutions pour les points &lt;strong&gt;1, 2 et 4&lt;/strong&gt;.&lt;br&gt;&lt;br&gt;
Le point &lt;strong&gt;3&lt;/strong&gt;, je le laisse à la &lt;strong&gt;CEET&lt;/strong&gt; (courage les gars, je compte sur vous ).&lt;/p&gt;


&lt;h2&gt;
  
  
  Objectif : Avoir un nom de domaine gratuit qui s’adapte automatiquement à mon IP publique, même quand elle change.
&lt;/h2&gt;

&lt;p&gt;C’est le moment où je devrais vous raconter comment j’ai découvert l’Amérique et vaincu Satan avant de tomber sur le projet &lt;strong&gt;FreedomBox&lt;/strong&gt;, blablabla...&lt;/p&gt;

&lt;p&gt;Mais restons sérieux : aujourd’hui, je vous parle de &lt;strong&gt;GnuDIP&lt;/strong&gt;, un service DDNS que FreedomBox m’a fait découvrir – et c’est du lourd.&lt;/p&gt;


&lt;h2&gt;
  
  
  C’est quoi GnuDIP ?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;GnuDIP&lt;/strong&gt; est un service de &lt;strong&gt;DNS dynamique (DDNS)&lt;/strong&gt;.&lt;br&gt;&lt;br&gt;
Il permet de lier un &lt;strong&gt;nom de domaine&lt;/strong&gt; à une &lt;strong&gt;adresse IP changeante&lt;/strong&gt; – typiquement ce que tu as si tu es chez un FAI standard.&lt;/p&gt;

&lt;p&gt;L’instance publique dispo sur &lt;a href="https://gnudip.datasystems24.net" rel="noopener noreferrer"&gt;gnudip.datasystems24.net&lt;/a&gt; propose deux domaines gratuits :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;freedombox.rocks&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sds-ip.de&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Et oui, &lt;strong&gt;c’est 100 % gratuit&lt;/strong&gt;.&lt;/p&gt;


&lt;h2&gt;
  
  
  Tutoriel : Utiliser GnuDIP pour obtenir un sous-domaine gratuit
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Étape 1 : Création d’un compte
&lt;/h3&gt;

&lt;p&gt;Rendez-vous sur &lt;a href="https://gnudip.datasystems24.net" rel="noopener noreferrer"&gt;https://gnudip.datasystems24.net&lt;/a&gt;&lt;br&gt;&lt;br&gt;
Et inscrivez-vous :&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fokj2ek43byh4g2fgcgvr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fokj2ek43byh4g2fgcgvr.png" alt="inscription" width="475" height="350"&gt;&lt;/a&gt;&lt;/p&gt;


&lt;h3&gt;
  
  
  Étape 2 : Créer un sous-domaine
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hostname&lt;/strong&gt; : choisissez un nom court, simple, sans caractères spéciaux.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Domain&lt;/strong&gt; : sélectionnez &lt;code&gt;sds-ip.de&lt;/code&gt; ou &lt;code&gt;freedombox.rocks&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvrx8f2wd9170hnh5ymxf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvrx8f2wd9170hnh5ymxf.png" alt="enregistrement" width="508" height="384"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Une fois enregistré, retournez sur la page de connexion et connectez-vous.&lt;/p&gt;


&lt;h3&gt;
  
  
  Par défaut, le domaine pointe vers l’IP que vous avez utilisée pour vous connecter.
&lt;/h3&gt;

&lt;p&gt;Mais vous pouvez la modifier manuellement :&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Far6vrbfe1q343sw5dhoo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Far6vrbfe1q343sw5dhoo.png" alt="configs" width="618" height="525"&gt;&lt;/a&gt;&lt;/p&gt;


&lt;h3&gt;
  
  
  Mieux encore : Mettez à jour automatiquement votre IP publique
&lt;/h3&gt;

&lt;p&gt;Si votre IP change régulièrement (comme chez beaucoup de fournisseurs), GnuDIP vous propose une solution simple : &lt;strong&gt;le "Quick Login URL"&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Il suffit de cliquer sur &lt;strong&gt;"Set quick login URL"&lt;/strong&gt;, copier le lien, et l’utiliser dans un petit script qui s’exécute régulièrement via &lt;code&gt;cron&lt;/code&gt; par exemple.&lt;/p&gt;

&lt;p&gt;Un simple &lt;code&gt;curl&lt;/code&gt; vers ce lien mettra automatiquement à jour l’IP du domaine :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://gnudip.datasystems24.net/nic/update?username=...&amp;amp;password=...&amp;amp;hostname=..."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvw26sjknezcg6its9uzt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvw26sjknezcg6its9uzt.png" alt="Dynamique changes" width="800" height="355"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  Et tada !!!
&lt;/h3&gt;

&lt;p&gt;Votre domaine gratuit pointe vers votre réseau local, même si votre IP change.&lt;/p&gt;

&lt;p&gt;Il ne vous reste plus qu’à faire une redirection de port sur votre &lt;strong&gt;routeur&lt;/strong&gt; pour exposer un service (web, SSH, etc.).&lt;/p&gt;




&lt;h2&gt;
  
  
  Bonus1 : Et DuckDNS alors ?
&lt;/h2&gt;

&lt;p&gt;Si vous préférez une alternative encore plus simple, il y a aussi &lt;a href="https://www.duckdns.org" rel="noopener noreferrer"&gt;DuckDNS.org&lt;/a&gt;, qui offre des sous-domaines en &lt;code&gt;duckdns.org&lt;/code&gt; et fonctionne aussi avec un script ou une URL à appeler périodiquement.&lt;/p&gt;

&lt;p&gt;Configuration de base (avec &lt;code&gt;cron&lt;/code&gt;) :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"url=https://www.duckdns.org/update?domains=MONDOMAINE&amp;amp;token=MONTOKEN&amp;amp;ip="&lt;/span&gt; | curl &lt;span class="nt"&gt;-k&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; ~/duckdns/duck.log &lt;span class="nt"&gt;-K&lt;/span&gt; 
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Et ajoutez ce script à votre crontab :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;*&lt;/span&gt;/5 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; ~/duckdns/update.sh &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Bonus : Se connecter à son serveur chez soi en SSH grâce au domaine dynamique
&lt;/h2&gt;

&lt;p&gt;Maintenant que votre domaine pointe toujours vers votre IP publique, vous pouvez même l'utiliser pour accéder à votre machine à distance via &lt;strong&gt;SSH&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Exemple : connexion SSH avec domaine GnuDIP
&lt;/h3&gt;

&lt;p&gt;Imaginons que vous avez configuré le domaine &lt;code&gt;monserveur.sds-ip.de&lt;/code&gt; pour pointer vers votre IP publique.&lt;br&gt;&lt;br&gt;
Sur votre machine distante (là où vous voulez te connecter), il vous suffit d'utiliser :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh utilisateur@monserveur.sds-ip.de
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Et voilà, vous êtes chez vous&lt;/p&gt;

&lt;h3&gt;
  
  
  Pré-requis côté maison :
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Votre &lt;strong&gt;box/routeur&lt;/strong&gt; doit rediriger le port &lt;strong&gt;22&lt;/strong&gt; (ou un port personnalisé si vous avez modifié &lt;code&gt;sshd&lt;/code&gt;) vers votre machine.&lt;/li&gt;
&lt;li&gt;Votre machine doit être &lt;strong&gt;allumée&lt;/strong&gt;  et le service &lt;strong&gt;SSH actif&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;Astuce sécurité : changez le port SSH par défaut, utilisez une &lt;strong&gt;clé SSH&lt;/strong&gt; au lieu du mot de passe, et configurez &lt;strong&gt;fail2ban&lt;/strong&gt; pour limiter les attaques.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Cette configuration, combinée au domaine dynamique, vous permet un &lt;strong&gt;accès sécurisé et stable&lt;/strong&gt; à votre environnement local, même si votre IP change toutes les 12 heures !&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Finalement, j’ai quand même pris un &lt;strong&gt;VPS&lt;/strong&gt; pour héberger mon &lt;a href="https://hi3ris.blueshield.tg/" rel="noopener noreferrer"&gt;portfolio&lt;/a&gt;.&lt;br&gt;&lt;br&gt;
Mais, juste pour le fun, il est aussi dispo via GnuDIP ici :&lt;br&gt;&lt;br&gt;
&lt;a href="https://hi3ris.sds-ip.de" rel="noopener noreferrer"&gt;https://hi3ris.sds-ip.de&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>tutorial</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
