Now, we embark on WebDev-style application development in Nix, moving forward in earnest. Everything so far in this blog has been akin to designing Dorothy’s “There’s no place like home” spell—a tricky thing to describe, but much like cleansing the palate or returning to a martial arts ready stance. Just as your taste buds and physical prowess shouldn’t sway to the whims of vendors, neither should your technical abilities. It is the pursuit of generic technical skills that can future-proof you, even as AI rapidly advances.
The Idea of ‘Home’ in Unix/Linux
So I chip off a flake of my system. Home is anything Unix/Linux-like. It can be macOS, because Apple’s Darwin Unix is close enough. Any popular Linux distro, whether it’s Debian, RedHat, or Arch-derived, is a fine home too. Even Windows with its WSL (Windows Subsystem for Linux) qualifies.
The Problem with Scripting Different Platforms
They all, in fact, have the same problem. You can’t just write scripts to cobble together home, because your automated brew
scripts would be different from your apt
scripts, which are different from your yum
or pacman
scripts. This problem unfortunately gets fixed these days with Docker containers and virtual machines, but that just makes a tangled web of not-very-friendly home. Home should be a script. Have you watched those YouTube videos? First, you install this, then you do that, and after a hundred little touches (getting any of which wrong will break the system), you’re ready to start.
Enter Nix: The Solution to Homebuilding
If you haven’t gotten the idea yet, this whacky wonderful Swiss Army knife called nix
solves all that. It’s like your original OS installer, brew, yum, apt, pacman, and even Python’s pip installer all rolled into one. Getting “home” back should just be a single file that brilliantly builds your home on-demand. We’ll splurge a little and say two files: one for the overarching system and one for the apps within. It should just be that easy.
Configuration.nix: Building Home on Any Platform
And so it is! My “home” script is called /etc/nixos/configuration.nix
. Think of it as my house. Your house can be a MacBook or a Windows laptop, or any machine you sit down at, because you can sprinkle some magic fairy dust (install nix), and like Dorothy, be transported to a familiar Linux-like environment by virtue of the nix
command. Your home can be built from bricks, like NixOS. But even if it’s built from sticks like macOS or straw like Windows, the same basic trick gets you a familiar home with a driveway and a garage to drive your secondary home (car, aka app) into.
Nix Flakes: Secondary Homes (Apps)
Those secondary homes are called things like ~/repos/myapp/flake.nix
. While not as big as your main home, a nix flake can still carry around a lot of the things that you need. A car is like a little home, right? You feel comfortable in it, and the location of the steering wheel and seats aren’t always changing, right? So muscle memory can kick in, and you can immediately drive well. That’s a nix flake. And after being lost in the wilderness for a bit, head spinning from the pace of change in technology, I finally have my car ready. That was the subject of that lengthy article yesterday.
The Tech Counter-Revolution: Simplicity and Efficiency
So I say it’s time to start writing that app in earnest, yet here I go rambling on and on. You get it, right? We are on the verge of a tech counter-revolution. It’s one that favors simplicity and doing more with less. Less code. Less surface area. Less to audit. Less people. Sounds scary? No, it’s enormously empowering for the individual, and a lot like the return to the early days of the Web, where a Webmaster with a good knowledge of the LAMP platform and how HTTP works could pretty much do everything. I’ve learned recently that this concept is referred to lately as Infrastructure as Code or IaC. One flake to rule them all!
Refurbishing Pipulate with Nix and Large Language Models
Yesterday was already a pretty epic day getting my flake drivable. I’m refurbishing my old Pipulate Free and Open Source Software for SEO vehicle. It’s gone through iterations where it was a local web app and iterations where it was fully Jupyter Notebook-based. Now, I’m getting the best of all worlds, with a sprinkling of locally installed large language models if the host machine has Ollama installed with an LLM like llama3.1 (which I’m currently using). This provides a certain baseline of capabilities that are reasonable on modern machines. Even MacBooks can have built-in ChatGPT-like features with a quick install and download, and it has endless application on a local web app.
Final Preparations for Development
Most of today went into that last round of preparation, making the car ready for driving. In this case, it was a quick start
and stop
command to get both JupyterLab and the FastHTML server running while not tying up the command-line console it was launched from. I’m a vim/NeoVim user, and I may wish to use the console for text editing. But I also tested the integration with VSCode-like programs that detect and use the Python virtual environments. So, basically, you can use your favorite editor even though I’ve effectively bundled in an AI-assisted Jupyter Notebooks editing environment by default. But go ahead and use your own favorite editor, like Cursor. The start
program opens a tab for JupyterLab in your default browser, along with a tab for FastHTML. FastHTML is set to pick up file changes, so you don’t need to stop and start the server to see your changes.
Custom Prompts and Environment Enhancements
What else? Oh, I got the custom prompts working so you can see that you’re in a Python (.venv) in the conventional way Python users expect. And when you type exit
to get out of it and go one virtual environment nested level up into Nix, you see a custom prompt there letting you know you’re still in a Nix shell. Macs do this automatically (at least with the Determinate Systems installer), so you will see a double indication on Macs when stepping out. And I don’t have command-line recall working when in the Python .venv
. But I chased rabbits down rabbit holes long enough today.
Moving Forward: Writing the FastHTML App
It’s time to write a FastHTML app. But before I do, here’s the current state of the master template nix flake.
The Pipulate Nix Flake
This is a Nix flake that defines a reproducible development environment for a project called Pipulate. This flake sets up a development shell with all necessary dependencies and includes scripts to start and stop services like JupyterLab and a custom FastHTML server.
{
# ____ _ _ _ This is a Nix flake that defines a reproducible
# | _ \(_)_ __ _ _| | __ _| |_ ___ development environment for a project called
# | |_) | | '_ \| | | | |/ _` | __/ _ \ Pipulate. This flake sets up a development shell
# | __/| | |_) | |_| | | (_| | || __/ with all necessary dependencies and includes
# |_| |_| .__/ \__,_|_|\__,_|\__\___| scripts to start and stop services like JupyterLab
# |_| and a custom FastHTML server.
# Define the external dependencies (other flakes) this flake uses
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-24.05"; # Nix packages source
flake-utils.url = "github:numtide/flake-utils"; # Helpful utilities for flakes
};
# Define the outputs of this flake
outputs = inputs @ { self, nixpkgs, flake-utils, ... }:
# Generate outputs for each default system (e.g., x86_64-linux, aarch64-darwin)
flake-utils.lib.eachDefaultSystem (system: let
# Import nixpkgs for the current system, allowing unfree packages
pkgs = import nixpkgs {
inherit system;
config.allowUnfree = true;
};
# Import local configuration if it exists, otherwise use an empty set
localConfig = if builtins.pathExists ./local.nix then import ./local.nix else {};
cudaSupport = if localConfig ? cudaSupport then localConfig.cudaSupport else false;
# Detect the current platform
isLinux = pkgs.stdenv.isLinux;
isDarwin = pkgs.stdenv.isDarwin;
# Define common packages used across all platforms
commonPackages = with pkgs; [
python311
python311.pkgs.pip
python311.pkgs.virtualenv
cmake
ninja
gcc
git
zlib
stdenv.cc.cc.lib
figlet
];
# Create a shell script to set up the development environment
runScript = pkgs.writeShellScriptBin "runScript" ''
set -e
export NIXPKGS_ALLOW_UNFREE=1
export LD_LIBRARY_PATH=${pkgs.lib.makeLibraryPath commonPackages}:$LD_LIBRARY_PATH
${if isLinux && cudaSupport then "export LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib:$LD_LIBRARY_PATH" else ""}
figlet "Pipulate"
echo "Welcome to the Pipulate development environment on ${system}!"
echo
echo "- Checking if pip packages are installed..."
${if cudaSupport && isLinux then "echo '- CUDA support enabled.'" else ""}
test -d .venv || ${pkgs.python311.interpreter} -m venv .venv
set -e # Exit immediately if a command exits with a non-zero status
if source .venv/bin/activate && \
pip install --upgrade pip --quiet && \
pip install -r requirements.txt --quiet; then
package_count=$(pip list --format=freeze | wc -l)
echo "- Done. $package_count pip packages installed."
else
echo "Warning: An error occurred during setup."
fi
# Check if numpy is importable (the lyncpin to know if the environment is ready)
echo "- Checking if numpy is importable..."
if python -c "import numpy" 2>/dev/null; then
echo "- numpy is importable (good to go!)"
echo
echo "- Start JupyterLab and FastHTML server with: start"
echo "- Stop JupyterLab and FastHTML server with: stop"
echo "- To exit the Pipulate environment, type 'exit' twice."
echo
else
echo "Error: numpy could not be imported. Check your installation."
fi
# Check for Ollama server
echo "Checking Ollama connectivity..."
ollama_response=$(python ollama_check.py)
echo "Ollama says: $ollama_response"
echo
echo "Learn more at https://pipulate.com <--Ctrl+Click"
# Create the start script
cat << EOF > .venv/bin/start
#!/bin/sh
stop
echo "Starting JupyterLab and server..."
jupyter lab > jupyter.log 2>&1 &
python server.py > server.log 2>&1 &
echo "JupyterLab and server started in the background."
echo "JupyterLab log: jupyter.log"
echo "Server log: server.log"
sleep 2
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
xdg-open "http://localhost:5001" > /dev/null 2>&1 &
elif [[ "$OSTYPE" == "darwin"* ]]; then
open "http://localhost:5001" > /dev/null 2>&1 &
else
echo "Unsupported OS."
fi
EOF
chmod +x .venv/bin/start
# Create the stop script
cat << EOF > .venv/bin/stop
#!/bin/sh
echo "Stopping JupyterLab, Jupyter kernels, and server..."
pkill -f "jupyter-lab"
pkill -f "ipykernel_launcher"
pkill -f "python server.py"
echo "Waiting for processes to terminate..."
sleep 2
if pgrep -f "jupyter-lab" > /dev/null || pgrep -f "ipykernel_launcher" > /dev/null || pgrep -f "python server.py" > /dev/null; then
echo "Some processes are still running. Forcefully terminating..."
pkill -9 -f "jupyter-lab"
pkill -9 -f "ipykernel_launcher"
pkill -9 -f "python server.py"
fi
echo "All processes have been stopped."
EOF
chmod +x .venv/bin/stop
# Override PROMPT_COMMAND and set custom PS1
export PROMPT_COMMAND=""
PS1='$(printf "\033[01;34m(%s)\033[00m \033[01;32m[%s@%s:%s]$\033[00m " "$(basename "$VIRTUAL_ENV")" "\u" "\h" "\w")'
export PS1
exec bash --norc --noprofile
'';
# Define the development shell for Linux systems
linuxDevShell = pkgs.mkShell {
buildInputs = commonPackages ++ (with pkgs; [
pythonManylinuxPackages.manylinux2014Package
stdenv.cc.cc.lib
]) ++ pkgs.lib.optionals (cudaSupport && system == "x86_64-linux") (with pkgs; [
cudatoolkit
cudnn
(ollama.override { acceleration = "cuda"; })
]);
shellHook = ''
${runScript}/bin/runScript
# Set up Nix prompt for when user exits Python venv
nix_prompt='\[\033[1;34m\][nix-dev]\[\033[0m\] \w $ '
if [ -n "$ZSH_VERSION" ]; then
setopt PROMPT_SUBST
PS1="%F{blue}[nix-dev]%f %~ $ "
else
PS1="$nix_prompt"
fi
'';
};
# Define the development shell for Darwin (macOS) systems
darwinDevShell = pkgs.mkShell {
buildInputs = commonPackages;
shellHook = ''
${runScript}/bin/runScript
# Set up Nix prompt for when user exits Python venv
if [ -n "$ZSH_VERSION" ]; then
setopt PROMPT_SUBST
PS1="%F{blue}[nix-dev]%f %~ $ "
else
PS1='\[\033[1;34m\][nix-dev]\[\033[0m\] \w $ '
fi
'';
};
in {
# Choose the appropriate devShell based on the current platform
devShell = if isLinux then linuxDevShell else darwinDevShell;
});
}
The Host System Plays a Vital Role in Cloud Deployments
So a flake like this basically defines a whole computer, system, application, cloud deployment, what have you. There’s certain capabilities you assume that the host system brings to the picture, like internet access and some local file storage. But it takes the place of tons of other technology, and as complicated as it may look, it at least gathers all the complexity into one place. It even lets you adapt to differences in the host system, exception handling I am not trilled to do, but the exception in this case is Macs, and that is worth it because so much of the developer audience for this sort of stuff are Mac users.
Anyhow, the way Nix flakes like this are generally used are that they are a file in a git (and thus often GitHub) repo. When you clone a repo locally, which you can actually do with this flake:
git clone https://github.com/miklevin/pipulate
…you can then cd
into the folder and type:
nix develop
Hydrating a Nix Flake with Nix Develop
This “hydrates” the application on your machine, as the cool kids seem to
be over-saying these days. You can also think of it like running a recipe, which
the same Mac cool kids might know as brew install
formulas. There’s a
formula for building this or that. Same thing, but for Linux instead of Macs
in particular, and with a whole bunch of stuff to make sure it happens the same
way every time. You might also call it a build or a make system. You just don’t
have to be a genius like a C programmer.
So when you type nix develop
, it follows out all those instructions, and in
this case, you’ll see something like this output…
[mike@nixos:~/repos/pipulate]$ nix develop
warning: Git tree '/home/mike/repos/pipulate' is dirty
____ _ _ _
| _ \(_)_ __ _ _| | __ _| |_ ___
| |_) | | '_ \| | | | |/ _` | __/ _ \
| __/| | |_) | |_| | | (_| | || __/
|_| |_| .__/ \__,_|_|\__,_|\__\___|
|_|
Welcome to the Pipulate development environment on x86_64-linux!
- Checking if pip packages are installed...
- Done. 171 pip packages installed.
- Checking if numpy is importable...
- numpy is importable (good to go!)
- Start JupyterLab and FastHTML server with: start
- Stop JupyterLab and FastHTML server with: stop
- To exit the Pipulate environment, type 'exit' twice.
Checking Ollama connectivity...
Ollama says: Using model: llama3.1:latest
I think you meant "procreate"!
Here's a clever way to say it:
"Prepared to propagate the species and perpetuate the family line, one delightful descendant at a time!"
Or, if you want to make it a bit more humorous:
"It's time to multiply like rabbits... or at least, that's what I keep telling my significant other!"
Learn more at https://pipulate.com <--Ctrl+Click
Local ChatBot Access Included
I say “something like this” will output, because it will indeed be different every time because of the way I check for connectivity with a local Ollama LLM server. If you’re on a Mac and simply keep that little Llama in your menu bar because you’re one of the cool kids, this lets you connect to it.
start
and stop
Commands
So the development work is as if your own local machine was hosting the app. For
that, you need something running. In this case, it’s both JupyterLab for a Data
Science-like approach, and a very basic FastHTML app under Uvicorn. And to get
these servers started, you have to start them. I experimented with starting them
automatically when you nix develop
, but that got a little messy. I decided to
make it deliberate with the simplest, most obvious commands start
and stop
.
Running Commands in Background for Multi-Platform Environments
This is vital for my next round of work, because since these environments are
initiated from a command-line Terminal, the commands that start the various
servers like JupyterLab and FastHTML tie up the console. In Linux there’s a ton
of ways to run stuff in the background, and I explored quite a few until I
settled on the simplest I could imagine. It has to be simple because it has to
be multi-platform. I considered splitting the command-line Terminal up into
window panes with tmux
.
Making A Simple Script Easy To Understand And Use
Ultimately, I settled on just running them invisibly in the background,
outputting a local file log if you really wanted to monitor its output, you
could with the tail
command or somethinig. Bottom line is I made it as simple
as possible for the newb without turning it into a multi-day project. I can
always refine it over time.
Anatomy of a Flake
I feel like I just did this yesterday, but the flake has actually become quite a bit more sophisticated. So I will once again break it down the code step by step to explain what each part does.
1. Flake Inputs
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-24.05"; # Nix packages source
flake-utils.url = "github:numtide/flake-utils"; # Helpful utilities for flakes
};
nixpkgs
: Specifies the version of the Nix Packages collection to use (NixOS 24.05).flake-utils
: Provides utility functions for Nix flakes, such aseachDefaultSystem
.
2. Flake Outputs
outputs = inputs @ { self, nixpkgs, flake-utils, ... }:
flake-utils.lib.eachDefaultSystem (system: let
# ...
in {
# ...
});
- Purpose: Generates outputs for each supported system (e.g.,
x86_64-linux
,aarch64-darwin
). system
: Represents the target system architecture.
3. Importing Nixpkgs and Configuration
pkgs = import nixpkgs {
inherit system;
config.allowUnfree = true;
};
localConfig = if builtins.pathExists ./local.nix then import ./local.nix else {};
cudaSupport = if localConfig ? cudaSupport then localConfig.cudaSupport else false;
pkgs
: Imports the Nix packages for the current system, allowing unfree packages.localConfig
: Imports a local configuration file (local.nix
) if it exists.cudaSupport
: Determines if CUDA support is enabled based on the local configuration.
4. Platform Detection
isLinux = pkgs.stdenv.isLinux;
isDarwin = pkgs.stdenv.isDarwin;
isLinux
/isDarwin
: Flags to identify the operating system.
5. Common Packages
commonPackages = with pkgs; [
python311
python311.pkgs.pip
python311.pkgs.virtualenv
cmake
ninja
gcc
git
zlib
stdenv.cc.cc.lib
figlet
];
- Purpose: Defines a list of packages required for the development environment.
- Includes:
- Python 3.11 and tools (
pip
,virtualenv
). - Build tools:
cmake
,ninja
,gcc
. - Version control:
git
. - Libraries:
zlib
, standard C library. - Utilities:
figlet
(for ASCII art in the terminal).
- Python 3.11 and tools (
6. Run Script (runScript
)
runScript = pkgs.writeShellScriptBin "runScript" ''
# Script content
'';
- Purpose: Creates a shell script to set up the development environment.
- Actions:
- Sets environment variables (
NIXPKGS_ALLOW_UNFREE
,LD_LIBRARY_PATH
). - Displays a welcome message using
figlet
. - Checks and sets up a Python virtual environment (
.venv
). - Installs Python packages from
requirements.txt
. - Verifies that
numpy
is importable. - Checks connectivity with the
Ollama
server viaollama_check.py
. - Creates
start
andstop
scripts inside.venv/bin/
.
- Sets environment variables (
7. Start and Stop Scripts
Start Script
- Location:
.venv/bin/start
- Purpose: Starts JupyterLab and the custom server in the background.
- Actions:
- Stops any existing instances.
- Launches services and logs output to
jupyter.log
andserver.log
. - Opens the default web browser to
http://localhost:5001
based on the OS.
Stop Script
- Location:
.venv/bin/stop
- Purpose: Stops JupyterLab, Jupyter kernels, and the custom server.
- Actions:
- Terminates processes gracefully, and forcefully if necessary.
8. Shell Prompt Customization
# Override PROMPT_COMMAND and set custom PS1
export PROMPT_COMMAND=""
PS1='$(printf "\033[01;34m(%s)\033[00m \033[01;32m[%s@%s:%s]$\033[00m " "$(basename "$VIRTUAL_ENV")" "\u" "\h" "\w")'
export PS1
exec bash --norc --noprofile
- Purpose: Customizes the shell prompt to display the virtual environment and user information.
exec bash --norc --noprofile
: Starts a new bash shell without reading startup files.
9. Development Shells
Linux Development Shell (linuxDevShell
)
linuxDevShell = pkgs.mkShell {
buildInputs = commonPackages ++ (with pkgs; [
pythonManylinuxPackages.manylinux2014Package
stdenv.cc.cc.lib
]) ++ pkgs.lib.optionals (cudaSupport && system == "x86_64-linux") (with pkgs; [
cudatoolkit
cudnn
(ollama.override { acceleration = "cuda"; })
]);
shellHook = ''
${runScript}/bin/runScript
# Shell prompt setup
'';
};
buildInputs
:- Adds platform-specific packages.
- Includes CUDA packages if enabled.
shellHook
: Runs therunScript
when the shell is entered.
Darwin Development Shell (darwinDevShell
)
darwinDevShell = pkgs.mkShell {
buildInputs = commonPackages;
shellHook = ''
${runScript}/bin/runScript
# Shell prompt setup
'';
};
- Simpler setup: Only includes
commonPackages
.
10. Selecting the Appropriate Dev Shell
devShell = if isLinux then linuxDevShell else darwinDevShell;
- Purpose: Exposes the appropriate development shell based on the operating system.
Additional Details
- CUDA Support:
- Enabled through
local.nix
. - Adds CUDA libraries and adjusts
LD_LIBRARY_PATH
on Linux systems.
- Enabled through
- Ollama Server:
- Checks server connectivity using a Python script (
ollama_check.py
). - Optionally includes an overridden
ollama
package with CUDA acceleration.
- Checks server connectivity using a Python script (
- Environment Variables:
NIXPKGS_ALLOW_UNFREE=1
: Allows installation of unfree packages.LD_LIBRARY_PATH
: Includes paths to necessary libraries.
- Scripts Execution:
start
: Use this script to start development services.stop
: Use this script to stop running services.
- Shell Customization:
- Adjusts the prompt for both Bash and Zsh shells.
- Provides a consistent look and feel across different environments.
Usage Instructions
- Enter the Development Shell:
nix develop
- This command invokes the appropriate dev shell for your system.
- The
shellHook
runs automatically, setting up the environment.
- Activate the Virtual Environment:
- The
runScript
creates and activates.venv
for Python dependencies.
- The
- Install Python Packages:
- Automatically installs packages from
requirements.txt
.
- Automatically installs packages from
- Start Services:
start
- Starts JupyterLab and the custom server.
- Opens
http://localhost:5001
in your default browser if supported.
- Stop Services:
stop
- Gracefully stops all running services.
- Exit the Environment:
- Type
exit
twice to leave the virtual environment and the Nix shell.
- Type
Summary
- Purpose: This Nix flake automates the setup of a consistent development environment for the Pipulate project across different systems.
- Features:
- Manages dependencies via Nix and Python virtual environments.
- Provides scripts to control development services.
- Customizes the shell environment for ease of use.
- Supports optional CUDA acceleration on Linux.
Well, that’s nice. But what about Apple Metal support for Apple GPUs?
PerplexityAI: Unfortunately, this Nix flake does not directly support Apple Metal. Here are the key reasons:
- Platform specificity: Metal is an Apple-specific graphics API, while this flake is designed to work across multiple platforms, including Linux and macOS[1].
- No Metal-specific packages: The flake doesn’t include any Metal-specific packages or dependencies in its buildInputs[4].
- Focus on Python and CUDA: The flake is primarily set up for a Python development environment, with optional CUDA support for Linux systems. It doesn’t include any Metal-specific configuration or tools[4].
- Cross-platform design: The flake uses flake-utils to generate outputs for each default system, indicating a focus on cross-platform compatibility rather than Apple-specific technologies[4].
- No Apple-specific GPU configurations: While the flake checks for CUDA support on Linux, there are no equivalent checks or configurations for Metal on macOS[4].
To support Metal, the flake would need significant modifications, including:
- Adding Metal-specific packages and dependencies for macOS systems.
- Implementing checks for Metal support on Apple devices.
- Including Metal-specific configuration and environment variables.
- Potentially adding Metal-related development tools or SDKs.
Given the current structure, this flake is not designed to support or utilize Apple Metal. It’s more focused on providing a cross-platform Python development environment with optional CUDA support for Linux systems.
Citations:
[1] https://support.apple.com/en-us/102894
[2] https://www.anandtech.com/show/8116/some-thoughts-on-apples-metal-api
[3] https://www.reddit.com/r/GraphicsProgramming/comments/1ap6d09/is_there_a_use_formarket_for_metal/
[4] https://developer.apple.com/videos/play/tech-talks/10580/
[5] https://developer.apple.com/forums/thread/38469
[6] https://github.com/dustinlyons/nixos-config
[7] https://blog.lambdaclass.com/using-metal-and-rust-to-make-fft-even-faster/
[8] https://github.com/tpwrules/nixos-apple-silicon
Web App Development at Home
And now for the app! All this is to have a place to work experimentially on Python with the freedom that Notebooks provide, then to migrate the important bits over to a Web app. Back in the day, that day being 1998 through the 200X’s, I made those apps with Microsoft Active Server Pages (ASP), which you still see around. This was before NodeJS and the explosion of complex client-side JavaScript frameworks like React. Ruby on Rails also came along. Despite having tried, I never jumped on either of those bandwagons, client-side JS nor Ruby joyful frameworks.
The Jupyter Notebook Detour
Instead, I went the Python route, fell in love with the language, and even shied away from the Python Django framework. I like lightweight, like the Pyton Flask micro web framework. But too often, you still have to look at HTML and JavaScript as you blend Python with them using some templating system, like jinja2 (big in Flask), mako (what Reddit was made with), or other double curly-braced template languages like nunchucks or liquid templates that use those mustaches {{ foo }}. Instead, I avoided web developmenmt by using Python in Jupyter Notebooks for my various SEO tasks.
FastHTML Web App Simplification
So now I’ve got a bunch of Notebooks that are already working. But the world has changed again, and now I have to bring the utility of those notebooks to a broader range of people, sparing them the difficulty of learning how to do things in a Notebook. As easy as they are, tasks can be even easier for this audience when the Notebook is just turned into a Web app. It’s easier for the app user, but harder for the app developer. If only the complexity of Web app development wasn’t so steep. Enter FastHTML! Here is its Hello World web app…
Flask Minus the Mustache Syntax
from fasthtml.common import *
# Initialize the FastHTML application
app, rt = fast_app()
# Define a route for the home page
@rt("/")
def get():
# Return a titled page with a simple "Hello, World!" message
return Titled("Hello World", P("Hello, World!"))
# Start the server
serve()
Creating A Simple Web Page With Python
Notice the lack of a template language. Notice the lack of crosseye-inducing
double curly brackets. HTML tags are represented as Python functions with names
reflecting the element. P()
is for paragraph <p>
and Titled()
is for a
titled page with both a title tag and a headline-like page title. When you nix
develop
the pipulate flake on your machine, a webserver is set up on
localhost:5001, and you get a rudimentary page. It does use CSS and JavaScript
as well to build the page, but you don’t have to look at it.
Flask - Mustaches + HTMX = Pythonic SPA
FastHTML combines the routing decorators of the Flask-like API with a Python web framework that uses function names that reflect their HTML elements, and a method of updating things on the page called HTMX. HTMX is a way of changing page data without a page reload, enabling single-page applications (SPA) with amazingly small amounts of clear concise code to get the job done.
And still, you don’t have to actually look at the JavaScript. When the button is
pressed, it calls a page (function) called increment
and swaps the inner HTML
with the result of that function…
from fasthtml.common import *
# Initialize the FastHTML application
app, rt = fast_app()
x = 0
@app.get("/")
def home():
return Title("FastHTML HTMX Demo"), Main(
H1("Hello, FastHTML with HTMX!"),
P(f"Count: {x}", id="count"),
Button("Increment",
hx_post="/increment",
hx_target="#count",
hx_swap="innerHTML")
)
@app.post("/increment")
def increment():
global x
x += 1
return f"Count: {x}"
# Start the server
serve()
The Rise of Interpreted Languages in Web Development
Look, no JavaScript! And no CSS for that matter. And no build procedure. Just change the code in the file, go refresh the page, see your changes! This is the “build vs. no-build” or “compiled vs. interpreted” debate in web development. Compiled clearly won, but interpreted is making a comeback.
The Build vs No-Build Debate
Dem’s fightin’ words! There’s webdev religious dogmas here. The webdev community has decided to minimize the amount of work to be done on the server by shifting it to the browser using client-side JavaScript framework like React, Vue, Svelte. The result is having to “compile” JavaScript code, introducing a “build” process where there was none before, and tons of having to handle JSON instead of HTML.
JavaScript Frameworks Victims of Conway’s Law
To me, workflow-wise this loses all the wonderful quickness and easy breezy nature of webdev that used to be there. Advocates of compiled JavaScript say the benefit is worth it. But to me, it just makes it overly complex and inaccessible. It’s Conway’s Law in action. Things become more complex to reflect the people who designed them, which on the Web resulted in shutting out people like me who prefer a no-build environment.
Joyful Frameworks
I actually closely align with David Heinemeier Hansson (DHH), the creator of Ruby on Rails on this and a lot of these principles. With a little caching and the just-in-time compiling that is invisibly in the process that’s in the process anyway these days, even for the “interpreted” approach, you can keep web development easy breezy, lightweight and joyful like it used to be.
I Have a Secret
Okay, so that was FastHTML’s Hello World and HTMX usage. But there is one more building block we need. And yes, I put these examples on my “home” article because these are all new additions to my home that I have to keep reminding myself of. They are WebDev capabilities I’ve avoiding having to face for years because Notebooks got me a get-out-of-jail free card. But no more. The last building block example here is how to keep secrets when you go about trading these web apps as nix flakes.
Environment Variables
The best practice for using secrets in your code seems to have settled on environment variables. All the OSes have them. But it just shifts around where the secrets are kept, from out of your programming code and into some other file on your computer, the idea being you will somehow inherit those environment variables from your OS and never have to display them.
pip install dotenv
Guess what? Merely shifting where secrets are kept doesn’t get you off the hook
of having to manage them well. You still need some apparatus to do that! And the
Python community seems to have settled on a pip installable package called
dotenv
that reads (and now also writes) a file called .env
. So here’s a
simplest code I could come up with that 1-time prompts the user to provide a
secret (like an API key or token) and writes it into the .env file for later
use…
from fasthtml.common import *
from dotenv import load_dotenv, set_key
import os
# Initialize the FastHTML application
app, rt = fast_app()
# Load environment variables
load_dotenv()
@rt("/")
def get():
# Reload the environment variables to reflect any updates
load_dotenv()
secret = os.getenv('SECRET')
if secret:
message = P("I already know your secret")
else:
message = Form(method="post")(
Label("Enter the secret:", Input(type="password", name="secret")),
Button("Submit", type="submit")
)
return Titled("Secret Prompt", message)
@rt("/", methods=["POST"])
def post(secret: str):
# Save the secret to the .env file if it hasn't been set
if not os.getenv('SECRET'):
set_key('.env', 'SECRET', secret)
load_dotenv() # Reload the .env file after updating it
return Titled("Secret Saved", P("Your secret has been saved."))
else:
return Titled("Secret Already Set", P("A secret is already set. No changes were made."))
# Start the server
serve()
Our New Building Blocks Are Here To Be Documented Thoroughly
And THAT is our new building blocks! The whole thing is documented at the fastht.ml website. And this is where I’m making my own building-block documentation so I can start converting Notebooks over to Web Apps! Want to know a secret? So do web apps! And it’s a pain because as distributable and sharable as these things are through nix flakes, you can’t toss secrets like API keys into those git repos. And so right out of the starting gate, we are hit with that complication.
And with these 3 examples in-hand, Hello World, HTMX SPA and Secret Handling, I have everything I need to jump head-first into Web Application development again. Ugh!