lundie.io Get In Touch

MetaMaker: A Pre-AI Python Desktop Architecture Case Study

A desktop app focused on architecture, state, persistence, events and responsive workflow control.

MetaMaker: A Pre-AI Python Desktop Architecture Case Study

Snapshot

A pre-AI Python desktop application focused on architecture, state, events and responsive workflow control.

  • Core Build Pre-2024, evenings and weekends
  • Stack Python · CustomTkinter · SQLAlchemy · SQLite · Blinker
  • Codebase ~12.5k Python LOC, 112 commits Single developer · core architecture built without AI
  • Status Archived; Darkblock integration mocked after API shutdown

Project Overview

MetaMaker is a Python desktop application built with a strong focus on architecture, maintainability and responsive UI under real constraints.

The application began as a tool for managing file metadata and Darkblock1 upload workflows. It supported an experimental steganographic art collection built around my own paintings that I decided to attempt to ship as NFTs. As the project gained traction within the Darkblock community, I worked towards releasing it as an open-source tool for others to use. Darkblock shut down before that happened.

Built with CustomTkinter2, SQLAlchemy3 and SQLite, the app used MVC-style separation between views, controllers and managers. Dependency injection was wired by hand and a custom thread pool kept the UI responsive. A Blinker4 event layer tied it together and later refined with explicit payload classes and runtime signal contracts.

While the domain aged, the engineering instincts behind it sharpened my confidence as a developer. Those same instincts continue to show up across the projects I have built since: the internal tools behind PhonixLab, the production audit work in InPromptOut, and the boundaries drawn around state, events and persistence in newer codebases.

Highlights30-second skim

  • Architecture Read/write boundary via structural Protocols Views receive read-only *_accessor interfaces; controllers get the full *_manager. Same object, but the split is enforced at the type boundary, not at runtime.
  • Concurrency Custom thread pool, tag-based routing WorkItem tags route each result to its registered ThreadPoolListener, keeping CustomTkinter responsive during image processing and batch uploads.
  • Events Blinker event layer Decouples managers, controllers and views, later hardened with @dataclass payloads and runtime signal contracts for safer, testable events.
  • Persistence SQLAlchemy ORM over SQLite Repositories mediate every write, so views never touch the database directly.
  • Resilience Survived upstream collapse When Darkblock shut down its API in 2025, the integration was mocked rather than removed – preserving the full demo flow without rewriting business logic.

How I Built It

Three architectural decisions were primarily responsible for the heavy lifting in MetaMaker: a single hand-wired composition root, a custom thread pool wrapping ThreadPoolExecutor to keep CustomTkinter responsive, and a Blinker signal layer later hardened with payload classes and runtime contracts.

The sections below take them in turn, with separate deep-dive posts further unpacking the implementations.

1. Why I Separated Views, Controllers and Managers by Hand

MetaMaker has a single composition root: AppController. Every feature surface is wired there, each controller given its dependencies at construction. There is no DI framework. The four core managers (CollectionManager, SetManager, NftManager, DarkblockManager) live as members of AppController itself. The controllers, views and services that depend on them are constructed with those references passed in explicitly. While verbose, the clarity was crucial for a project that I was not working on daily. It also made debugging easier.

app/appcontroller.py – nm_init_config_nft_view_from_item
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
config_nft_controller = ConfigNftController(
    nft_manager=self._nft_manager,
    collection_manager=self._collection_manager,
    dblock_service_async=self._dblock_service_async,
    dblock_manager=self._dblock_manager,
    nav_manager=self,
)
config_nft_view = self._app_view.init_config_nft_view(
    collection_accessor=self._collection_manager,
    set_accessor=self._set_manager,
    nft_accessor=self._nft_manager,
    dblock_accessor=self._dblock_manager,
    thread_pool_manager=self._thread_pool_manager,
    bg_image_path=bg_image_path,
)

Note the argument names. Controllers receive a *_manager; views receive an *_accessor. Both point at the same manager object, but the parameter type narrows the interface the view is expected to use. The accessors are typed Protocol classes in app/features/view_mvc.py exposing only read-side methods – get_set, get_collection_id, get_nfts_by_id_range, and so on. No save, no update, no delete. Managers were never written against these protocols directly; they satisfy them because the read-only methods already existed. A view that tries to call a mutating method on what it received should fail static type checking at the boundary.

app/features/view_mvc.py – SetAccessor protocol
1
2
3
4
5
6
7
class SetAccessor(Protocol):
    def get_set(self, set_id: int) -> NftMetadataSet: ...
    def get_current_set_id(self) -> int: ...
    def get_description(self) -> str: ...
    def get_ext_url(self) -> str: ...
    def get_count_uncommitted_darkblocks(self): ...
    def get_total_nfts(self) -> int: ...

The read/write split lives in the type annotations on each view’s constructor, not in two separate runtime objects.

One manager, two typed views
flowchart LR
    AC["AppController\n(composition root)"]
    SM["SetManager\nget_* · save · update · delete"]
    C["SetController"]
    V["SetView"]

    AC -- "owns" --> SM
    SM -- "as SetManager\n(full interface)" --> C
    SM -. "as SetAccessor\n(read-only Protocol)" .-> V
  

Manual DI was a deliberate choice with a known upgrade path. AppController.init_dependencies() still carries the TODO I left for myself: “Use dependency injection library to fulfil dependencies.” The intent was to keep the wiring explicit while the architecture was still evolving and swap in a library once the boundaries stabilised. The boundaries did stabilise and the swap never became necessary for the project’s scope.

Detailed walkthrough: Inside MetaMaker – A Walkthrough of Architectural Decisions

Takeaway:

Explicit composition roots and narrow interfaces beat magic. AppController makes every dependency visible at the wiring point. The *_manager / *_accessor split keeps mutating methods off the view-facing interface through static typing. In a single-developer codebase, that combination of visibility and type enforcement is hard to give up.

2. Why Background Tasks Needed Explicit Routing Contracts

MetaMaker ran several concurrent background task types – image resizing, Darkblock uploads, API polling – each needing its result routed to a different consumer. The tag-and-listener contract makes that routing explicit at both ends:

Task submission · tag-based routing
sequenceDiagram
    box UI Thread
        participant C as Controller / View
        participant TPM as ThreadPoolManager
    end
    box Worker Thread
        participant W as Worker Thread
        participant L as ThreadPoolListener
    end

    C->>TPM: add_task(WorkItem[tag, callable])
    Note over TPM: internal manager thread polls queue,
dispatches to ThreadPoolExecutor TPM->>W: execute callable W-->>TPM: done_callback(Future) Note over TPM: matches future.tag → listener TPM->>L: on_async_result(ResultItem[tag, result])

Any component that wants results implements ThreadPoolListener and registers against a specific tag:

app/concurrency/thread_pool_manager.py – ThreadPoolListener
1
2
3
4
class ThreadPoolListener(ABC):
    @abstractmethod
    def on_async_result(self, result_item: ResultItem):
        NotImplementedError("error")

on_async_result receives a ResultItem – a named container holding the tag, consumer ID and the callable’s return value. From there, a service or manager can update the database or emit a Blinker signal the view is watching, without the view ever touching the work queue.

Takeaway:

The tag-and-listener pattern makes the routing contract explicit: every task declares its type at submission, every consumer declares what it handles at registration. There is no view-level polling and no shared callback state. The Blinker layer then carries the result from the listener into the event channel the view is already watching – keeping each boundary clean and independently traceable.

Detailed walkthrough: Threading Deep Dive – Keeping MetaMaker Responsive with a Custom Thread Pool

3. Why I Replaced a Custom Observer with a Structured Signal Layer

MetaMaker’s first event system was a hand-rolled Observable class, written before Blinker entered the picture. It worked: managers inherited from Observable, components registered callbacks directly against a RepoEvent enum value, and _notify_update_listeners fired them in sequence.

The limitation that grew with the app: every subscriber registered directly on the manager it cared about. A view reacting to events from two managers had to hold both references, register on each, and unregister both on teardown. The commit that renamed “listeners” to “observers” noted the whole system was already a candidate for replacement.

Before – Observable
flowchart LR
    SV["SetView"]
    SM["SetManager"]
    CM["CollectionManager"]

    SV -- "register(RepoEvent)" --> SM
    SV -- "register(RepoEvent)" --> CM
    SM -. "notify callback" .-> SV
    CM -. "notify callback" .-> SV
  
After – Blinker
flowchart LR
    SM["SetManager"]
    SIG(["set_updated\nsignal"])
    SV["SetView"]

    SM -- "emit_set_updated()" --> SIG
    SV -- ".connect()" --> SIG
  

Blinker removed the coupling. Named signals in signals.py let any component subscribe without holding a reference to the emitter. The second refinement was the payload. Early Blinker usage still passed raw **kwargs with no schema – the kind of ambiguity that makes handlers hard to test. app/events.py introduced @dataclass payload classes covering every signal, with a hybrid object + ID pattern so handlers get immediate access to data without forcing a re-query:

app/events.py + app/signals.py – payload class and emit function
1
2
3
4
5
6
7
8
9
@dataclass
class SetUpdatedEvent:
    """Payload for set_manager_on_updated signal."""
    nft_set: NftMetadataSet  # Hybrid: object + ID
    set_id: int

def emit_set_updated(sender, nft_set: 'NftMetadataSet'):
    payload = SetUpdatedEvent(nft_set=nft_set, set_id=nft_set.id)
    set_manager_on_updated.send(sender, payload=payload)

Every signal has a matching named emit function – no component calls .send() directly. The third layer was enforcement. SignalHandlerMixin.register_signal_handlers() consults SIGNAL_CONTRACT when wiring each handler: if the signal has a registered type, the handler is automatically wrapped with enforce_contract before connecting. Any handler receiving the wrong payload type raises a TypeError immediately when DEBUG_SIGNALS=1, rather than failing silently three frames downstream.

app/features/signal_handler_mixin.py – automatic contract wrapping
1
2
3
4
5
6
7
8
for signal_name, handler in self.signal_handlers().items():
    sig = signal(signal_name)
    expected_type = SIGNAL_CONTRACT.get(signal_name)
    if expected_type:
        wrapped = enforce_contract(signal_name, expected_type)(handler)
    else:
        wrapped = handler
    sig.connect(wrapped)

During the migration from Observable, a temporary patched_send monkey-patch on Signal.send used inspect.stack() to detect any direct .send() call that bypassed the emit_* functions, logging a [LEGACY SIGNAL USE] warning with the exact file and line number. Both mechanisms are explored in depth in the Blinker deep-dive.

Takeaway:

Decoupling emitters from subscribers means managers can be tested in isolation: emit a signal, assert the handler received the right payload, no views required. Adding a new subscriber never touches the emitter, and teardown is a single .disconnect(). The @dataclass payload schema also turns anonymous **kwargs into payload.nft_set, which is much easier to inspect when debugging a failing test. That is a real advantage when time to dig through code is limited. SignalHandlerMixin then applies enforce_contract automatically at registration, so every component gets payload validation for free without any per-signal boilerplate.

Detailed walkthrough: Inside MetaMaker – Refining Blinker for Robust, Testable Signals


Results & Current Status

MetaMaker was used as a working desktop tool. It managed metadata and upload workflows for a real collection of my own painted, steganographic works. Across ~12.5k lines of Python and 112 commits, the boundaries drawn early stayed maintainable as features grew: the read/write Protocol split, the tag-routed thread pool, the structured signal layer. That was the entire point of wiring them by hand rather than reaching for a framework.

The release never happened since Darkblock shut down its API in 2025. But the integration was mocked rather than ripped out. As the upload path sat behind a service boundary, swapping live calls for a stub preserved the full demo flow without touching business logic.

The tool also had a visible product on the other end of it. The collection it managed shipped under a brand, illustration set and site I designed and built myself. That strand stands on its own as visual and front-end work, separate from the architecture.

The Crewman Alpha Mission collection: five hand-painted, monochrome space scenes plus a locked sixth tile, in a cohesive black-and-red identity.
Crewman Alpha Mission: the hand-painted collection MetaMaker managed, with the brand and identity I built around it. Illustration and art direction, separate from the architecture story above.

The project is now archived as a portfolio piece, kept for the engineering rather than the domain it once served: a record of how I draw boundaries under real constraints.


What I Would Do Differently Now

If I were rebuilding MetaMaker today, I would keep the core boundaries but trim the custom infrastructure around them. The composition root, the read/write Protocol split and the typed event payloads still feel like the right calls. Some of the surrounding framework code – the hand-rolled pieces that grew up alongside those boundaries – could be smaller, or handed to a library now that the shape has stopped moving.

I would also design the test and mock layer in from the start, rather than reaching for it reactively.


Go Deeper


Let’s Connect

If you are interested in the architecture behind MetaMaker, or want to talk about Python desktop applications, event-driven design, or drawing clean boundaries in small codebases, I’d be glad to hear from you.

Prefer to email me directly? hello@lundie.io


  1. Darkblock is a service for attaching encrypted, unlockable content to digital assets. ↩

  2. CustomTkinter is a modern and customisable python UI-library based on the Tkinter GUI Library ↩

  3. SQLAlchemy is a Python library for working with databases, and ORM (Object-Relational Mapping) allows mapping database tables to Python objects for easier data management. ↩

  4. Event-Driven Programming is a design pattern where components react to events; Blinker is a Python library that enables this through signals. ↩

Michael Lundie

About

Hey, nice to meet you.

I'm Michael – a software engineer, web developer, and educator with a habit of shipping tools for real users under real constraints. I've been building for the web since my early teens and I'm still at it.

More about me →

Get in Touch

Get In Touch

Prefer using email? Say hi at hello@lundie.io