11 Jun 2026
Planet Python
Talk Python to Me: #551: Stroll Down Startup Lane - 2026
If you've ever been to PyCon, you know one of the best parts of the expo hall is Startup Row, a stretch of booths where early-stage companies built on Python show off what they're creating. But only attendees get to walk that lane, so let's bring it to everyone. In this episode, we stroll down Startup Row together. We kick things off with the organizers, Jason and Shay, who share the program's origin story going back to Paul Graham and the PSF, plus some surprising stats, including two unicorns among the alumni. Then we meet five startups: Tetrix, bringing AI to institutional investing in private markets. Arcjet, security that lives inside your app as an SDK. Phemeral.dev, serverless hosting built for Python web apps. CapiscIO, an identity and authority layer for AI agents. And Pixeltable, a multimodal database from Marcel Kornacker, co-creator of Apache Parquet. See if you can spot the theme running through them all. Let's go for a walk.<br/> <br/> <strong>Episode sponsors</strong><br/> <br/> <a href='https://talkpython.fm/agentfield-page'>AgentField AI</a><br> <a href='https://talkpython.fm/training'>Talk Python Courses</a><br/> <br/> <h2 class="links-heading mb-4">Links from the show</h2> <div><strong>Guests</strong><br/> <strong>Naunidh Bhalla</strong>: <a href="https://www.linkedin.com/in/naunidhbhalla?featured_on=talkpython" target="_blank" >linkedin.com</a><br/> <strong>Grant Gittes</strong>: <a href="https://www.linkedin.com/in/grantgittes/?featured_on=talkpython" target="_blank" >linkedin.com</a><br/> <strong>Marcel Kornacker</strong>: <a href="https://www.linkedin.com/in/marcelkornacker/?featured_on=talkpython" target="_blank" >linkedin.com</a><br/> <strong>Beon de Nood</strong>: <a href="https://www.linkedin.com/in/beondenood/?featured_on=talkpython" target="_blank" >linkedin.com</a><br/> <strong>Chinmaya Joshi</strong>: <a href="https://www.linkedin.com/in/cshjoshi/?featured_on=talkpython" target="_blank" >linkedin.com</a><br/> <strong>David Mytton</strong>: <a href="https://www.linkedin.com/in/davidmytton/?featured_on=talkpython" target="_blank" >linkedin.com</a><br/> <strong>Shea Tate-Di Donna</strong>: <a href="https://www.linkedin.com/in/sheatatedidonna/?featured_on=talkpython" target="_blank" >linkedin.com</a><br/> <strong>Jason Rowley</strong>: <a href="https://www.linkedin.com/in/jasondrowley/?featured_on=talkpython" target="_blank" >linkedin.com</a><br/> <strong>Azul Garza</strong>: <a href="https://github.com/AzulGarza?featured_on=talkpython" target="_blank" >github.com</a><br/> <strong>RenΓ©e Rosillo</strong>: <a href="https://www.linkedin.com/in/reneerosillo/?featured_on=talkpython" target="_blank" >linkedin.com</a><br/> <br/> <strong>Tetrix</strong>: <a href="https://www.tetrix.co/?featured_on=talkpython" target="_blank" >tetrix.co</a><br/> <strong>Tetrix Jobs</strong>: <a href="https://www.tetrix.co/careers?featured_on=talkpython" target="_blank" >tetrix.co</a><br/> <strong>Arcjet</strong>: <a href="https://arcjet.com/?featured_on=talkpython" target="_blank" >arcjet.com</a><br/> <strong>Pixeltable</strong>: <a href="https://www.pixeltable.com/?featured_on=talkpython" target="_blank" >pixeltable.com</a><br/> <strong>Phemeral.dev</strong>: <a href="https://phemeral.dev/?featured_on=talkpython" target="_blank" >phemeral.dev</a><br/> <strong>CapiscIO</strong>: <a href="https://capisc.io/?featured_on=talkpython" target="_blank" >capisc.io</a><br/> <br/> <strong>Episode #551 deep-dive</strong>: <a href="https://talkpython.fm/episodes/show/551/stroll-down-startup-lane-2026#takeaways-anchor" target="_blank" >talkpython.fm/551</a><br/> <strong>Episode transcripts</strong>: <a href="https://talkpython.fm/episodes/transcript/551/stroll-down-startup-lane-2026" target="_blank" >talkpython.fm</a><br/> <br/> <strong>Theme Song: Developer Rap</strong><br/> <strong>π₯ Served in a Flask πΈ</strong>: <a href="https://talkpython.fm/flasksong" target="_blank" >talkpython.fm/flasksong</a><br/> <br/> <strong>---== Don't be a stranger ==---</strong><br/> <strong>YouTube</strong>: <a href="https://talkpython.fm/youtube" target="_blank" ><i class="fa-brands fa-youtube"></i> youtube.com/@talkpython</a><br/> <br/> <strong>Bluesky</strong>: <a href="https://bsky.app/profile/talkpython.fm" target="_blank" >@talkpython.fm</a><br/> <strong>Mastodon</strong>: <a href="https://fosstodon.org/web/@talkpython" target="_blank" ><i class="fa-brands fa-mastodon"></i> @talkpython@fosstodon.org</a><br/> <strong>X.com</strong>: <a href="https://x.com/talkpython" target="_blank" ><i class="fa-brands fa-twitter"></i> @talkpython</a><br/> <br/> <strong>Michael on Bluesky</strong>: <a href="https://bsky.app/profile/mkennedy.codes?featured_on=talkpython" target="_blank" >@mkennedy.codes</a><br/> <strong>Michael on Mastodon</strong>: <a href="https://fosstodon.org/web/@mkennedy" target="_blank" ><i class="fa-brands fa-mastodon"></i> @mkennedy@fosstodon.org</a><br/> <strong>Michael on X.com</strong>: <a href="https://x.com/mkennedy?featured_on=talkpython" target="_blank" ><i class="fa-brands fa-twitter"></i> @mkennedy</a><br/></div>
11 Jun 2026 8:16pm GMT
Real Python: Quiz: Serialize Your Data With Python
In this quiz, you'll test your understanding of Serialize Your Data With Python.
By working through this quiz, you'll revisit how to choose between textual and binary formats, when to use schemas, and how to apply tools like pickle, json, the csv module, Parquet, and Protocol Buffers safely and effectively.
[ Improve Your Python With π Python Tricks π - Get a short & sweet Python Trick delivered to your inbox every couple of days. >> Click here to learn more and see examples ]
11 Jun 2026 12:00pm GMT
PyCharm: Best Python AI Frameworks in 2026

Whether you're building chatbots, training computer vision models, or analyzing business data, choosing the right AI framework can make or break your project. Python has become the dominant language for AI and machine learning development, and the ecosystem of frameworks supporting this work has matured significantly.
The right framework choice depends on what you're building. A production recommendation system has different requirements than a research prototype. A chatbot powered by large language models (LLMs) needs different tools than a fraud detection system analyzing tabular data.
Let's explore seven essential frameworks and where each excels so you can find the best AI framework for your specific project.
What is an AI framework?
AI frameworks are pre-built libraries and tools that handle the complex mathematics, data structures, and computational operations underlying AI and machine learning models. Rather than implementing neural networks or gradient descent from scratch, AI frameworks provide abstractions that let you focus on model architecture, data preparation, and business logic.
These frameworks generally fall into three categories:
- Deep learning frameworks like TensorFlow, PyTorch, and Keras specialize in neural networks and GPU acceleration for tasks involving images, text, and audio.
- Classical and tabular machine learning frameworks like scikit-learn and XGBoost focus on statistical and tree-based models for structured data, powering many real-world AI systems, including forecasting, risk-scoring, and decision-automation solutions.
- LLM and AI agent frameworks like LangChain and Hugging Face provide tools for building applications powered by large language models.
Why do AI frameworks matter?
AI frameworks dramatically accelerate your development by providing tested, optimized implementations of complex algorithms. They offer strong community support with extensive documentation, tutorials, and troubleshooting resources. They provide production-ready tooling for deployment, monitoring, and scaling. They're optimized for specific hardware like GPUs and TPUs, delivering performance that would be difficult to achieve with custom implementations.
Open-source vs. commercial AI frameworks
Open-source AI frameworks are the dominant model in AI development today. And they offer compelling advantages, from community-driven innovation for rapid feature development and bug fixes to transparency that enables auditing and algorithm customization. There's also no vendor lock-in or licensing fees, making them cost-effective for both experimentation and production deployment.
Commercial AI platforms also exist, with AWS SageMaker, Google Vertex AI, and Azure Machine Learning among the prominent examples. However, these platforms often use open-source frameworks underneath rather than competing with them directly. They provide managed infrastructure, automated workflows, and enterprise features on top of tools like TensorFlow and PyTorch.
If you're thinking open source means they're unsupported, think again. All seven frameworks below have robust ecosystems, and many are backed by major tech companies. Google supports TensorFlow, Meta backs PyTorch, and organizations like Microsoft contribute significantly to various projects in the ecosystem.
Top Python AI frameworks
These seven frameworks represent the essential toolkit for Python AI development in 2026. Each performs strongly in specific domains, and many developers use multiple frameworks depending on project requirements.
TensorFlow
TensorFlow is an open-source deep learning framework developed by Google for building and deploying machine learning models at enterprise scale. With a 37% market share in data science and machine learning and adoption by 25,000 companies globally, TensorFlow has proven itself in high-stakes production environments.
The framework evolved significantly from TensorFlow 1.x to 2.x, with Keras integration making it far more accessible while maintaining its enterprise-grade capabilities. If you're building large-scale image recognition systems or natural language processing pipelines, or you need to deploy across web, mobile, and edge devices through TensorFlow Lite and TensorFlow.js, TensorFlow can help.
If you're just getting started with TensorFlow, follow our step-by-step tutorial on how to train your first TensorFlow model using PyCharm.
Advantages of TensorFlow
- Enterprise-grade scalability: Built for production from day one, TensorFlow handles massive datasets and distributed training across multiple GPUs and TPUs seamlessly. You can scale from experimentation to serving millions of predictions without switching tools.
- Comprehensive deployment ecosystem: TensorFlow Serving handles model deployment, TensorFlow Lite optimizes for mobile and edge devices, and TensorFlow.js brings models to browsers. This complete deployment story reduces friction when moving from development to production.
- TPU optimization: Native support for Google's Tensor Processing Units delivers superior performance for large-scale training workloads, offering significantly better performance per watt than traditional hardware.
- Strong industry adoption: Companies like Airbnb, Twitter, and Intel rely on TensorFlow for critical applications, giving you confidence in its production readiness and long-term viability.
Disadvantages of TensorFlow
- Steeper learning curve: Despite Keras integration, TensorFlow's complexity can overwhelm beginners, especially when you move beyond high-level APIs to custom implementations.
- Verbose syntax for custom models: Building custom training loops or novel architectures requires significantly more code compared with PyTorch's more Pythonic approach.
- Debugging challenges: Static graph optimization, while beneficial for performance, can make runtime errors harder to trace than in frameworks with dynamic computation graphs.
scikit-learn
scikit-learn is an open-source Python library for classical machine learning, providing simple and efficient tools for classification, regression, clustering, and dimensionality reduction. With adoption by over 16,000 companies worldwide, it's your essential first stop for structured and tabular data before considering deep learning approaches.
The framework supports a wide range of supervised and unsupervised learning on structured business data, along with feature engineering and data preprocessing pipelines. Companies like J.P. Morgan use scikit-learn extensively for classification tasks and predictive analytics in financial decision-making.
Advantages of scikit-learn
- Beginner-friendly API: Consistent, intuitive syntax across all algorithms makes learning and switching between models effortless. The fit/predict pattern works the same whether you're using linear regression or random forests.
- Comprehensive algorithm library: Its library covers virtually every classical ML algorithm - regression, classification, clustering, dimensionality reduction - with well-tested implementations ready for your projects.
- Excellent for tabular data: On structured data, traditional algorithms often outperform deep learning, and scikit-learn gives you the tools to maximize this advantage.
- Fast prototyping: Its simple syntax means you can build and test models in minutes, not hours, making it ideal for rapid experimentation.
- Seamless integration: scikit-learn works perfectly with NumPy, pandas, and Matplotlib, fitting naturally into your data science workflows.
Disadvantages of scikit-learn
- No deep learning support: scikit-learn is not designed for neural networks - you'll need to switch to TensorFlow or PyTorch for complex deep learning architectures.
- Limited GPU acceleration: The framework is CPU-bound and struggles with very large datasets where GPU-accelerated frameworks perform better.
- Not suited for unstructured data: Images, text, and audio require deep learning frameworks that can handle high-dimensional, unstructured inputs.
PyTorch
PyTorch is an open-source deep learning framework developed by Meta that prioritizes flexibility and a natural Python coding experience. It's used in approximately 85% of deep learning research papers and has a 55% adoption rate in the research community. From its academic roots, PyTorch has evolved into a production-ready powerhouse.
The framework excels at cutting-edge research and experimentation with novel architectures. It supports natural language processing and generative AI models such as GPT, Llama, and Stable Diffusion, and enables computer vision research with custom model development. Its Pythonic philosophy makes it feel natural if you're already comfortable with Python, reducing cognitive load and accelerating your development.
Advantages of PyTorch
- Dynamic computation graphs: The define-by-run approach allows runtime model modifications, making debugging and experimentation intuitive. You can use standard Python control flow and debugging tools you already know.
- Pythonic and readable: PyTorch code feels like native Python, not a separate language. This flattens your learning curve and makes code more maintainable.
- Research-first innovation: Latest techniques and models appear in PyTorch first, driven by its dominance in academic research.
- Strong ecosystem: Hugging Face Transformers, PyTorch Lightning, and extensive community packages provide specialized tools for virtually any task you'll encounter.
Disadvantages of PyTorch
- Deployment complexity: While TorchServe has improved the situation, PyTorch historically has had weaker production tooling compared to TensorFlow's mature deployment ecosystem.
- Manual training loops: Greater control means more boilerplate code for standard training patterns, though libraries like PyTorch Lightning address this.
Keras
Keras is a high-level deep learning API designed for fast experimentation with neural networks. With over 60,000 GitHub stars and integration as TensorFlow's default interface, Keras has become synonymous with rapid prototyping and ease of use. The release of Keras 3.0 changed the game by adding multi-backend support for TensorFlow, JAX, and PyTorch.
The framework is ideal for rapidly prototyping neural network architectures, working on educational projects to learn deep learning fundamentals, or tackling deep learning tasks that don't require low-level customization.
Advantages of Keras
- Simplest API in deep learning: You can build sophisticated models in just a few lines of code with the Sequential or Functional API, offering the lowest barrier to entry in deep learning.
- Multi-backend flexibility: Keras 3.0 runs on TensorFlow, JAX, or PyTorch - write once, run anywhere. This future-proofs your code and lets you switch backends as your needs change.
- Built-in best practices: The API guides you toward sound model architecture decisions and incorporates best practices by default.
- Fast experimentation: You can iterate quickly without wrestling with framework complexity, focusing on model design rather than implementation details.
Disadvantages of Keras
- Limited low-level control: The abstraction layer sacrifices fine-grained control needed for cutting-edge research or novel architectures.
- Performance overhead: The additional abstraction can introduce latency compared to native framework calls, though this is often negligible for most applications.
- Less suitable for custom architectures: Highly novel model designs may require you to drop down to the underlying framework.
LangChain
LangChain is an open-source framework that helps you build applications powered by large language models, providing core components for prompt management, chains, memory, and agent orchestration. It acts as an abstraction layer to easily connect LLMs to external data sources and computational tools. With over 120,000 GitHub stars, the framework has become essential infrastructure for the AI agent revolution.
LangChain is most commonly used for building conversational AI and chatbots with memory and context, retrieval-augmented generation (RAG) systems for enterprise knowledge bases, and multi-agent systems with autonomous workflows.
If you want to go beyond the basics, read our LangChain Python Tutorial: A Complete Guide for 2026. It takes a deeper look at what LangChain offers and walks through real-world use cases for building AI agents in Python.
Advantages of LangChain
- Comprehensive LLM orchestration: Handles everything from prompt management to chains, memory, and tool use, giving you a complete infrastructure for LLM applications in one package.
- Provider-agnostic: Works seamlessly with OpenAI, Anthropic, Hugging Face, and local models, letting you avoid vendor lock-in and switch providers as your needs change.
- Rich agent capabilities: LangGraph enables complex, stateful workflows with human-in-the-loop patterns, supporting sophisticated agentic behaviors.
- Production-ready tooling: LangSmith provides monitoring, debugging, and tracing specifically designed for LLM applications, addressing the unique challenges you'll face in production.
Disadvantages of LangChain
- Learning curve for abstractions: LangChain Expression Language (LCEL) and framework-specific concepts take time to master, especially if you're new to LLM orchestration.
- Abstraction overhead: Additional layers between you and LLM APIs can sometimes obscure what's happening, making debugging more challenging.
- Fast-moving target: Frequent updates mean your code can become outdated quickly, requiring ongoing maintenance to stay current.
Hugging Face
Hugging Face is an open-source platform and library ecosystem for natural language processing and machine learning, with over one million models and 250,000 datasets to power your next project. It's become a central hub for the AI community, with its Transformers library earning 150,000+ GitHub stars.
The platform is particularly effective at accessing and fine-tuning pre-trained transformer models like BERT, GPT, and Llama, building NLP applications without training models from scratch, and sharing and deploying custom models to the community.
For a practical example, read A Practical Guide to Fine-Tuning and Deploying GPT Models Using Hugging Face Transformers. It walks through using a pre-trained GPT model, fine-tuning it on custom data, and deploying the result with FastAPI.
Advantages of Hugging Face
- Massive model repository: With hundreds of thousands of pre-trained models available, you rarely need to train from scratch. Models for virtually every task and language are ready for you to use.
- Transformers library dominance: This is the de facto standard for NLP, computer vision, and multimodal models, with support for the latest architectures as soon as they're published.
- Framework interoperability: Models work with PyTorch, TensorFlow, and JAX, giving you maximum flexibility in your development workflow.
- Inference infrastructure: Hosted inference APIs and Spaces make deployment straightforward without managing your own infrastructure.
Disadvantages of Hugging Face
- Dependency complexity: The large dependency tree can lead to version conflicts and package management challenges, especially in complex environments.
- Model quality variance: Community-contributed models vary in quality and may not be production-ready without thorough vetting and testing on your part.
- Platform dependency: Heavy reliance on Hugging Face Hub creates some platform lock-in, though you can download models and host them independently.
XGBoost
XGBoost is an optimized gradient boosting library designed for speed and performance on structured data. The algorithm continues to dominate machine learning competitions alongside other gradient-boosted decision tree libraries, earning its reputation through battle-tested performance on real-world problems.
You can use the framework for predictive modeling on structured business data, including sales forecasting, risk assessment, and feature importance analysis for model interpretability. Its gradient-boosting approach achieves outstanding precision on structured data, powering reliable insights for business applications.
Advantages of XGBoost
- Superior accuracy on tabular data: XGBoost consistently outperforms deep learning on structured datasets, making it your default choice for business analytics and forecasting.
- Built-in regularization: L1 and L2 regularization prevents overfitting better than basic gradient boosting, producing more robust models for your production systems.
- Efficient computation: Handles large datasets efficiently with parallel processing and intelligent tree pruning, making it practical for production use.
- Missing value handling: Automatically learns optimal strategies for missing data, reducing your preprocessing burden.
- Feature importance scores: Built-in interpretability helps you understand model decisions, crucial for business applications and regulatory compliance.
Disadvantages of XGBoost
- Not suitable for unstructured data: Images, text, and audio require deep learning approaches. XGBoost is designed specifically for tabular data.
- Hyperparameter complexity: There are many parameters to tune for optimal performance, though tools like Optuna can automate this process for you.
- Limited interpretability compared with simple models: While more explainable than deep neural networks, XGBoost's ensemble structure is harder to interpret than linear or rule-based models, even with feature importance and SHAP analysis.
How to choose an AI framework
Selecting the best AI framework depends on your specific project characteristics, but in practice, the choice is rarely binary. Many successful teams use multiple frameworks together. A common and effective pattern is to use scikit-learn for preprocessing and feature engineering, PyTorch for research and model development, TensorFlow for production deployment, and LangChain for LLM-powered features.
Your decision will likely come down to data type, team expertise, and where your model needs to run. Use this table as a starting point:
| Decision factor | Suitable Frameworks |
| By modeling approach and prediction type | |
| Single-value or label prediction (regression or classification using classical ML) | scikit-learn, XGBoost |
| Image and video modeling with neural networks | TensorFlow, PyTorch, Keras |
| Text and NLP with transformer models | Hugging Face, PyTorch, TensorFlow |
| LLM-powered and agent-based applications | LangChain, Hugging Face |
| By level of abstraction and control required | |
| High-level APIs and rapid iteration | Keras, scikit-learn |
| Fine-grained control over training and architectures | PyTorch, TensorFlow |
| Research-driven experimentation and custom workflows | PyTorch |
| Managed LLM orchestration and tooling | LangChain |
| By deployment target | |
| Production at scale | TensorFlow |
| Research/Experimentation | PyTorch |
| Mobile/Edge | TensorFlow Lite |
| Web applications | TensorFlow.js |
| LLM applications | LangChain |
| By task and project objective | |
| Classical prediction and forecasting systems | scikit-learn, XGBoost |
| Neural network-based modelling | TensorFlow, PyTorch, Keras |
| Building and training novel architectures | PyTorch |
| Scalable production deployment | TensorFlow |
| LLM-powered features and workflows | LangChain, Hugging Face |
If your choice comes down to PyTorch or TensorFlow, read our dedicated PyTorch vs. TensorFlow: Choosing the Right Framework in 2026 guide, where we compare learning curves, deployment options, and use cases to help you choose the right deep learning framework.
11 Jun 2026 11:28am GMT
10 Jun 2026
Django community aggregator: Community blog posts
Running Fallout London on Bazzite
I'm a huge Fallout fan, and Fallout London is one of the most impressive mods I've seen in years: a full DLC-sized expansion set in post-apocalyptic London, made by a community team. Running it on Bazzite (my gaming OS of choice) wasn't completely straightforward, so here's what actually worked for me. Consider this a note to future me, but hopefully it saves someone else an afternoon of trial and error.
What you'll need
- Bazzite installed on your machine
- Heroic Games Launcher
- Fallout 4 (the mod requires it as a base)
- The "Fallout London One Click Mod" (available through Heroic)
The steps
1. Install Heroic Games Launcher
If you don't have it yet, install Heroic from the Bazzite app store or via Flatpak. It's a fantastic open-source launcher that handles GOG, Epic, and Amazon games, and it plays very nicely with Proton.
After you install it, login with your GOG account

2. Install the Fallout London One Click Mod
Search for "Fallout London" in Heroic and install the One Click Mod version. This bundles everything together so you don't have to manually manage mod files. Let it do its thing.

3. Disable UMU (yes, it needs to be disabled)
This is the counterintuitive part. Once the mod is installed, go to its settings in Heroic, then the Advanced tab. You'll see an option called "Disable UMU". Enable it (meaning: check the checkbox to disable UMU). I know, "enable the disable" is a confusing way to phrase it, but that's what it says.
Without this, the game won't launch correctly on Bazzite.

4. Run it once in Desktop Mode
Before adding it to Steam, launch the game once directly from Heroic while you're in Desktop Mode. This lets everything install and configure properly: shaders, redistributables, the works. Wait until you're actually in the game and confirmed it runs without issues, then close it.
5. Add to Steam
Click the three-dot menu on the game in Heroic and select "Add to Steam". From this point on you can launch it from Game Mode like any other game in your library.

Play!
That's it. Boot into Game Mode, find Fallout London in your library, and enjoy one of the best Fallout experiences made outside of Bethesda.


See you in the next one!
10 Jun 2026 5:00am GMT
09 Jun 2026
Django community aggregator: Community blog posts
Logical optimizations
The second article in the series. The first was about control flow; this one stays with the same tactic - reshaping code - one layer down, at the condition. Here: merging ifs, factoring shared decisions, and dropping checks that earn nothing. The Boolean algebra of conditions - De Morgan and friends - is a different lever, and gets its own installment next time.

09 Jun 2026 11:00am GMT
Planet Twisted
Hynek Schlawack: How to Ditch Codecov for Python Projects
Codecov's unreliability breaking CI on my open source projects has been a constant source of frustration for me for years. I have found a way to enforce coverage over a whole GitHub Actions build matrix that doesn't rely on third-party services.
09 Jun 2026 12:00am GMT
05 Jun 2026
Django community aggregator: Community blog posts
Browser Push Notifications for a Django Website

For DjangoTricks, and some other websites, I intentionally didn't set email notifications when a feedback message arrived - I didn't want to pay for an email server or spam my inbox. While checking the messages in the database from time to time, sometimes I found out about them too late.
Last weekend, I decided to implement Web Push notifications to get notified about the feedback in my OS, just like in this example:

This tutorial walks through adding Web Push notifications to a Django project from scratch. When a visitor submits a feedback form the site owner receives a native browser notification - even if the admin tab is closed - thanks to a service worker and a Huey background task.
How it works
Push Notifications work in such a way: at first, people who want to get notifications need to subscribe to the notifications in their browser. The subscribers are stored on the push notification servers and also their identifiers are stored in Django website database. Whenever we need to send the messages to those subscribers, we send them to push notification server that passes the message to all subscribers if their browsers are open at the moment. If the browsers are closed at that moment, the message's TTL (time to live) in seconds set long enough, and the message is not expired yet, they will get the message later.
The push service depends on the browser:
- Chrome - Google's FCM (Firebase Cloud Messaging) -
fcm.googleapis.com - Firefox - Mozilla Autopush -
updates.push.services.mozilla.com - Safari - Apple Push Notification Service (APNs)

The two standard Web Push prerequisites are a VAPID key pair (identifies your server to the push service) and a service worker (a background JS script that receives the push and shows the notification even when the tab is closed).
The workflow would be as follows:
- I as a superuser visit the Django administration page that has sw.js file with a service worker and a JavaScript to subscribe to notidications.
- JavaScript request Notification permission. I accept it.
- JS calls
pushManager.subscribe()with the VAPID public key. - JS POSTs the subscription to
/notifications/push/subscribe/. - Django stores it in PushSubscription model.
Visitor submits feedback form
- A view saves
FeedbackMessageto the database. transaction.on_commitqueues a Huey task.- Huey task calls
pywebpush- browser's push service (FCM / Mozilla). - Push service wakes the service worker.
- Service worker shows a native OS notification.
- Clicking it opens the Django admin change page.
Prerequisites
- Django project using Huey for background tasks
- Python virtual environment
Install two dependencies:
(.venv)$ pip install pywebpush py-vapid
pywebpushis the library to communicate with the Push Notification server.py-vapidwill only be needed once to generate keys.
You'll need HTTPS to test the Web Push notifications if your host is not 127.0.0.1. If you use a custom domain in your /etc/hosts, such as djangotricks.localhost, you will also need to set up HTTPS with mkcert.
Step 1. The Feedback App
Create feedback app with FeedbackMessage model that will store submitted feedback messages:
# myproject/apps/feedback/models.py
from django.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as _
class FeedbackMessage(models.Model):
created_at = models.DateTimeField(_("Created at"), auto_now_add=True)
submitter_name = models.CharField(_("Submitter name"), max_length=200)
submitter_email = models.EmailField(_("Submitter email")
content = models.TextField(_("Content")
class Meta:
verbose_name = _("Feedback Message")
verbose_name_plural = _("Feedback Messages")
ordering = ("-created_at",)
def __str__(self):
return _("Feedback message from {}").format(self.submitter_name)
Create the form:
# myproject/apps/feedback/forms.py
from django import forms
from django.utils.translation import gettext_lazy as _
from .models import FeedbackMessage
class FeedbackMessageForm(forms.ModelForm):
class Meta:
model = FeedbackMessage
fields = [
"content",
"submitter_name",
"submitter_email",
]
widgets = {
"content": forms.Textarea(
attrs={
"rows": 5,
"placeholder": _("Your message")
}
),
}
labels = {
"submitter_name": _("Your name"),
"submitter_email": _("Your email"),
}
Create a view to handle that form:
# myproject/apps/feedback/views.py
from django.db import transaction
from django.shortcuts import render, redirect
from django.urls import reverse
from .forms import FeedbackMessageForm
from .tasks import send_feedback_push_notification
def feedback_form(request):
if request.method == "POST":
form = FeedbackMessageForm(data=request.POST)
if form.is_valid():
message = form.save(commit=False)
if request.user.is_authenticated:
message.user = request.user
message.save()
transaction.on_commit(
lambda: send_feedback_push_notification(message.pk)
)
return redirect(reverse("feedback:complete"))
else:
form = FeedbackMessageForm()
return render(request, "feedback/form.html", {"form": form})
def feedback_complete(request):
return render(request, "feedback/complete.html")
Create the app config, migrations, templates, URLs, and Django administration for it.
Step 2. VAPID keys
Generate the key pair once. These stay on the server and are never committed to version control.
(.venv)$ vapid --gen
(.venv)$ vapid --applicationServerKey --private-key private_key.pem
--gen writes private_key.pem and public_key.pem to the current directory.
The private_key.pem file will contain the key like:
-----BEGIN PRIVATE KEY-----
<Multiline private key data>
-----END PRIVATE KEY-----
--applicationServerKey prints the base64url-encoded public key the browser needs, such as:
Application Server Key = <Public key data as base64url>
For the secrets.json or .env file where you store your secrets, you will need the content of <Private key data with newlines removed> and <Public key data as base64url>.
{
...
"VAPID_PRIVATE_KEY": "<Private key data with newlines removed>",
"VAPID_PUBLIC_KEY": "<Public key data as base64url>"
}
Don't commit the *.pem files or the secrets to the Git repo!
Step 3. Django settings
# myproject/settings.py
### WEB PUSH ###
VAPID_PRIVATE_KEY = get_secret("VAPID_PRIVATE_KEY")
VAPID_PUBLIC_KEY = get_secret("VAPID_PUBLIC_KEY")
VAPID_ADMIN_EMAIL = "admin@mydomain.com"
Step 4. The Notifications App
Create notifications app with the PushSubscription model to track the Push notification subscribers:
# myproject/apps/notifications/models.py
from django.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as _
class PushSubscription(models.Model):
"""One browser push subscription for one device."""
created_at = models.DateTimeField(_("Created at"), auto_now_add=True)
user = models.ForeignKey(
_("User"),
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="push_subscriptions",
)
endpoint = models.TextField(_("Endpoint"), unique=True)
p256dh = models.TextField(_("Browser ECDH public key"))
auth = models.TextField(_("16-byte auth secret"))
class Meta:
verbose_name = _("Push Subscription")
verbose_name_plural = _("Push Subscriptions")
ordering = ("-created_at",)
def __str__(self):
return f"{self.user} - {self.endpoint[:60]}"
Create app configuration, migrations, and Django administration for it.
Step 5. Subscribe / unsubscribe views
Create the views that will be called after the user subscribes or unsubscribes to Push notifications. Also a view for the service worker sw.js:
# myproject/apps/notifications/views.py
import json
from django.contrib.auth.decorators import login_required
from django.contrib.staticfiles import finders
from django.http import HttpResponse, JsonResponse
from django.views.decorators.http import require_POST
from .models import PushSubscription
@login_required
@require_POST
def push_subscribe(request):
try:
data = json.loads(request.body)
endpoint = data["endpoint"]
p256dh = data["keys"]["p256dh"]
auth = data["keys"]["auth"]
except (KeyError, json.JSONDecodeError):
return JsonResponse({"error": "Invalid subscription data"}, status=400)
PushSubscription.objects.update_or_create(
endpoint=endpoint,
defaults={"user": request.user, "p256dh": p256dh, "auth": auth},
)
return JsonResponse({"status": "subscribed"})
@login_required
@require_POST
def push_unsubscribe(request):
try:
endpoint = json.loads(request.body)["endpoint"]
except (KeyError, json.JSONDecodeError):
return JsonResponse(
{"error": "Invalid data"},
status=400
)
PushSubscription.objects.filter(
user=request.user,
endpoint=endpoint,
).delete()
return JsonResponse({"status": "unsubscribed"})
def service_worker(request):
"""Serve sw-feedback.js as sw.js at the admin root"""
path = finders.find("admin/js/sw-feedback.js")
with open(path) as fh:
content = fh.read()
return HttpResponse(
content,
content_type="application/javascript"
)
Step 6. Wire URLs into myproject/urls.py
Plug the notification views into URLs:
# myproject/apps/notifications/urls.py
from django.urls import path
from . import views
app_name = "notifications"
urlpatterns = [
path("push/subscribe/", views.push_subscribe, name="push_subscribe"),
path("push/unsubscribe/", views.push_unsubscribe, name="push_unsubscribe"),
]
The service worker URL must be mounted at the same prefix as ADMIN_URL so its scope covers the admin. Add both patterns before the admin.site.urls line:
# myproject/urls.py
from notifications import views as notifications_views
urlpatterns = [
# ...
path(
f"{settings.ADMIN_URL}sw.js",
notifications_views.service_worker,
name="admin_service_worker",
),
path(
"notifications/",
include("notifications.urls", namespace="notifications"),
),
path(settings.ADMIN_URL, admin.site.urls),
# ...
]
Step 7. Service worker JavaScript
Save as myproject/static/admin/js/sw-feedback.js:
self.addEventListener("push", function (event) {
const data = event.data ? event.data.json() : {};
const title = data.title || "New feedback message";
const options = {
body: data.body || "",
icon: "/static/admin/img/icon-yes.svg",
data: { url: data.url || "/" },
};
event.waitUntil(
self.registration.showNotification(title, options)
);
});
self.addEventListener("notificationclick", function (event) {
event.notification.close();
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
});
Step 8. Admin subscription JavaScript
Save as myproject/static/admin/js/push-subscribe.js:
(function () {
"use strict";
// Injected by the Django template override in Part 9.
const VAPID_PUBLIC_KEY = window.VAPID_PUBLIC_KEY;
const SUBSCRIBE_URL = window.PUSH_SUBSCRIBE_URL;
const UNSUBSCRIBE_URL = window.PUSH_UNSUBSCRIBE_URL;
const ADMIN_URL = window.ADMIN_URL;
// Tracks the endpoint last registered on the server so we can delete it even
// when the browser subscription has already been silently revoked (e.g. user
// cleared site data or the push subscription expired without blocking).
const STORAGE_KEY = "pushSubscriptionEndpoint";
// Read the CSRF token from the hidden input injected by {% csrf_token %}.
// Do not use the cookie: this project has CSRF_USE_SESSIONS = True.
const CSRF_TOKEN = document.querySelector("[name=csrfmiddlewaretoken]")?.value || "";
function urlBase64ToUint8Array(base64String) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const rawData = atob(base64);
return Uint8Array.from([...rawData].map((c) => c.charCodeAt(0)));
}
function post(url, body) {
return fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", "X-CSRFToken": CSRF_TOKEN },
body: JSON.stringify(body),
});
}
async function syncSubscription() {
if (!VAPID_PUBLIC_KEY) return;
if (!("serviceWorker" in navigator) || !("PushManager" in window)) return;
const registration = await navigator.serviceWorker.register(
"/" + ADMIN_URL + "sw.js",
{ scope: "/" + ADMIN_URL }
);
await navigator.serviceWorker.ready;
const storedEndpoint = localStorage.getItem(STORAGE_KEY);
const existing = await registration.pushManager.getSubscription();
// The browser subscription was revoked (user blocked notifications, cleared
// site data, or the subscription expired) but the server record still exists
// - delete it using the endpoint we stored at subscription time.
if (storedEndpoint && (!existing || existing.endpoint !== storedEndpoint)) {
await post(UNSUBSCRIBE_URL, { endpoint: storedEndpoint });
localStorage.removeItem(STORAGE_KEY);
}
// Already subscribed and the server already knows about this endpoint.
if (existing && existing.endpoint === storedEndpoint) return;
const permission = await Notification.requestPermission();
if (permission !== "granted") return;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
await post(SUBSCRIBE_URL, subscription.toJSON());
localStorage.setItem(STORAGE_KEY, subscription.endpoint);
}
document.addEventListener("DOMContentLoaded", syncSubscription);
})();
localStorage is the key to reliable unsubscription. Notification.permission alone cannot detect silent revocations - when a user clears site data or a push subscription expires, the permission may still read "granted" while the browser-side subscription is gone. By storing the endpoint at subscribe time and comparing it on every page load, the script can call UNSUBSCRIBE_URL with the old endpoint even after the browser subscription object has disappeared.
Step 9. Context processor
A context processor injects the VAPID globals into every admin template response.
Create myproject/apps/notifications/context_processors.py:
from django.conf import settings
from django.urls import reverse
def push_notifications(request):
"""Inject push-notification globals into every admin page."""
if not request.path.startswith(f"/{settings.ADMIN_URL}"):
return {}
if not settings.VAPID_PUBLIC_KEY:
return {}
return {
"push_vapid_public_key": settings.VAPID_PUBLIC_KEY,
"push_subscribe_url": reverse("notifications:push_subscribe"),
"push_unsubscribe_url": reverse("notifications:push_unsubscribe"),
"push_admin_url": settings.ADMIN_URL,
}
Register it in the template settings:
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [
os.path.join(BASE_DIR, "myproject", "templates"),
],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
# ...
"myproject.apps.notifications.context_processors.push_notification_settings",
],
},
},
]
Step 10. Inject globals via admin/base_site.html
Override myproject/templates/admin/base_site.html. If one already exists, add the extrahead block; otherwise create the file extending Django's built-in template:
{% extends "admin/base_site.html" %}
{% load static %}
{% block extrahead %}
{# ... any existing content such as a favicon include ... #}
{% if push_vapid_public_key %}
{% csrf_token %}
<script nonce="{{ request.csp_nonce }}">
window.VAPID_PUBLIC_KEY = "{{ push_vapid_public_key }}";
window.PUSH_SUBSCRIBE_URL = "{{ push_subscribe_url }}";
window.PUSH_UNSUBSCRIBE_URL = "{{ push_unsubscribe_url }}";
window.ADMIN_URL = "{{ push_admin_url }}";
</script>
<script src="{% static 'admin/js/push-subscribe.js' %}"></script>
{% endif %}
{% endblock %}
Step 11. Huey task
# myproject/apps/feedback/tasks.py
import json
from django.conf import settings
from django.urls import reverse
from huey.contrib.djhuey import db_task
@db_task()
def send_feedback_push_notification(feedback_message_id):
from pywebpush import webpush, WebPushException
from feedback.models import FeedbackMessage
from notifications.models import PushSubscription
private_key = settings.VAPID_PRIVATE_KEY
if not private_key:
return
if not (message := FeedbackMessage.objects.filter(
pk=feedback_message_id
).first()):
return
admin_url = settings.WEBSITE_URL + reverse(
"admin:feedback_feedbackmessage_change",
args=(message.pk,)
)
content = message.content
body = content[:120] + (
"..." if len(content) > 120 else ""
)
payload = json.dumps({
"title": f"{message.submitter_name}:",
"body": body,
"url": admin_url,
})
dead_endpoints = []
for sub in PushSubscription.objects.all():
try:
webpush(
subscription_info={
"endpoint": sub.endpoint,
"keys": {"p256dh": sub.p256dh, "auth": sub.auth},
},
data=payload,
vapid_private_key=private_key,
vapid_claims={"sub": f"mailto:{settings.VAPID_ADMIN_EMAIL}"},
)
except WebPushException as exc:
# 410 Gone / 404 Not Found means the subscription has expired
if exc.response is not None and exc.response.status_code in (404, 410):
dead_endpoints.append(sub.endpoint)
if dead_endpoints:
PushSubscription.objects.filter(
endpoint__in=dead_endpoints,
).delete()
The Huey task is already called from the view in feedback app via transaction.on_commit. Using on_commit ensures the task is only queued after the database row is fully committed, so the task always finds the message when it runs.
Step 12. Content Security Policy
If the project uses Django CSP, two directives need adjusting:
CSP_WORKER_SRC = ["'self'"] # allows service worker registration from this origin
CSP_CONNECT_SRC = ["'self'"] # allows the subscribe POST fetch
The pywebpush HTTP call to the external push service (FCM, Mozilla) runs server-side inside the Huey worker process and is not subject to the browser's CSP.
User experience
It' safest to keep the body of the message up to 120 characters long - the rest will likely be cut on different OSes or browsers.
You can check the current status of notification permissions by:
Notification.permission // should be "granted", not "default" or "denied"
Based on that you can write a specific message in the User Interface what to do if the permission has been denied.
If you have many different types of notifications, you would set the configuration in a Django website. And let the visitor subscribe to the notifications in the browser once. Then your Huey tasks would check the notification settings and trigger send according messages to the subscribers.
For example, for DjangoTricks website, I would allow subscribing to new tricks, blog posts, and goodies in the notification configuration, and the visitors would grant permission to Web Push notifications just once.
Privacy and security
Messages themselves are stored on the Web Push servers encrypted, but back in the OS they are shown in plain text, and can be seen by people standing behind the user or possibly read out by other apps (or viruses) which have permissions to access OS notifications.
Practical recommendations for sensitive content:
- Send a generic notification ("You have a new message") and make the user open the app to see the actual content - this is what most banking/healthcare apps do.
- If you do include content, keep it minimal - avoid full message text, Personally Identifiable Information (PII), medical info, etc.
- Make sure your VAPID keys are securely stored and rotated if compromised.
- Set a short TTL (time-to-live) on the push message so it doesn't sit on Google's servers long if the device is offline.
For anything regulated (HIPAA, GDPR, financial data), the safest approach is the generic notification pattern, since you have no control over how the OS handles notification display and history once it leaves the browser.
OS permissions
For notification receivers, the permissions can be denied for the Browser globally as well.
One can set or unset them on MacOS at Settings β Notifications β Google Chrome (or another browser analogically) or on Windows at Focus Assist / Notification settings.
Marketing perspective
Opt-in: When asking for push notifications without context, only 5-25% would grant the permission. If the permission is asked in the UI at first and the reason is given, about 60% would grant the permission.
Opt-out: On average, nearly 8-10% of subscribers opt out from web push notifications per year. Even just 1 push notification per week leads to 10% of users disabling notifications. 46% of users disable notifications if they receive more than 6 notifications.
Final words
The technique of Web Push is not trivial, but with the help of py-vapid and pywebpush, it becomes manageable. The best use cases for push notifications are those where a SaaS project or a web platform suggests to use this technique intentionally: when waiting for something to happen, such as a new comment, a reply to a message, a new task to do from another person, or a new post of a favorite author who writes irregularly.
Cover picture by cottonbro studio
05 Jun 2026 5:00pm GMT
22 May 2026
Planet Twisted
Glyph Lefkowitz: Opaque Types in Python
Let's say you're writing a Python library.
In this library, you have some collection of state that represents "options" or "configuration" for a bunch of operations. Such a set of options is a bundle of potentially ever-increasing complexity. Thus, you will want it to have an extremely minimal compatibility surface, with a very carefully chosen public interface, that is either small, or perhaps nothing at all. Such an object conveys state and might have some private behavior, but all you want consumers to be able to do is build it in very constrained, specific ways, and then pass it along as a parameter to your own APIs.
By way of example, imagine that you're wrapping a library that handles shipping physical packages.
There are a zillion ways to do it ship a package. There are different carriers who can ship it for you. There's air freight, and ground freight, and sea freight. There's overnight shipping. There's the option to require a signature. There's package tracking and certified mail. Suffice it to say, lots of stuff.
If you are starting out to implement such a library, you might need an object called something like ShippingOptions that encapsulates some of this. At the core of your library you might have a function like this:
1 2 3 4 5 |
|
If you are starting out implementing such a library, you know that you're going to get the initial implementation of ShippingOptions wrong; or, at the very least, if not "wrong", then "incomplete". You should not want to commit to an expansive public API with a ton of different attributes until you really understand the problem domain pretty well.
Yet, ShippingOptions is absolutely vital to the rest of your library. You'll need to construct it and pass it to various methods like estimateShippingCost and shipPackage. So you're not going to want a ton of complexity and churn as you evolve it to be more complex.
Worse yet, this object has to hold a ton of state. It's got attributes, maybe even quite complex internal attributes that relate to different shipping services.
Right now, today, you need to add something so you can have "no rush", "standard" and "expedited" options. You can't just put off implementing that indefinitely until you can come up with the perfect shape. What to do?
The tool you want here is the opaque data type design pattern. C is lousy with such things (FILE, pthread_*_t, fd_set, etc). A typedef in a header file can easily achieve this.
But in Python, if you expose a dataclass - or any class, really - even if you keep all your fields private, the constructor is still, inherently, public. You can make it raise an exception or something, but your type checker still won't help your users; it'll still look like it's a normal class.
Luckily, Python typing provides a tool for this: typing.NewType.
Let's review our requirements:
- We need a type that our client code can use in its type annotations; it needs to be public.
- They need to be able to consruct it somehow, even if they shouldn't be able to see its attributes or its internal constructor arguments.
- To express high-level things (like "ship fast") that should stay supported as we add more nuanced and complex configurations in the future (like "ship with the fastest possible option provided by the lowest-cost carrier that supports signature verification").
In order to solve these problems respectively, we will use:
- a public
NewType, which gives us our public name... - which wraps a private class with entirely private attributes, to give us an actual data structure, while not exposing the constructor,
- a set of public constructor functions, which returns our
NewType.
When we put that all together, it looks like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
As a snapshot in time, this is not all that interesting; we could have just exposed _RealShipOpts as a public class and saved ourselves some time. The fact that this exposes a constructor that takes a string is not a big deal for the present moment. For an initial quick and dirty implementation, we can just do checks like if options._speed == "fast" in our shipping and estimation code.
However, the main thing we are doing here is preserving our flexibility to evolve the related APIs into the future, so let's see how we might do that. For example, let's allow the shipping options to contain a concrete and specific carrier and freight method:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
|
As a NewType, our public ShippingOptions type doesn't have a constructor. Since _RealShipOpts is private, and all its attributes are private, we can completely remove the old versions.
Anything within our shipping library can still access the private variables on ShippingOptions; as a NewType, it's the same type as its base at runtime, so it presents minimal1 overhead.
Clients outside our shipping library can still call all of our public constructors: shipFast, shipNormal, and shipSlow all still work with the same (as far as calling code knows) signature and behavior.
If you need to build and convey some state within your public API, while avoiding breakages associated with compatibility churn, hopefully this technique can help you do that!
Acknowledgments
Thanks for reading, and thank you to my patrons who are supporting my writing on this blog. If you like what you've read here and you'd like to read more of it, or you'd like to support my various open-source endeavors, you can support my work as a sponsor.
-
The overhead is minimal, but it is not completely zero. The suggested idiom for converting to a
NewTypeis to call it like a function, as I've done in these examples, but if you are wanting to use this pattern inside of a hot loop, you can use# type: ignore[return-value]comments to avoid that small cost. β©
22 May 2026 12:33am GMT
04 Apr 2026
Planet Twisted
Donovan Preston: Using osascript with terminal agents on macOS
Here is a useful trick that is unreasonably effective for simple computer use goals using modern terminal agents. On macOS, there has been a terminal osascript command since the original release of Mac OS X. All you have to do is suggest your agent use it and it can perform any application control action available in any AppleScript dictionary for any Mac app. No MCP set up or tools required at all. Agents are much more adapt at using rod terminal commands, especially ones that haven't changed in 30 years. Having a computer control interface that hasn't changed in 30 years and has extensive examples in the Internet corpus makes modern models understand how to use these tools basically Effortlessly. macOS locks down these permissions pretty heavily nowadays though, so you will have to grant the application control permission to terminal. But once you have done that, the range of possibilities for commanding applications using natural language is quite extensive. Also, for both Safari and chrome on Mac, you are going to want to turn on JavaScript over AppleScript permission. This basically allows claude or another agent to debug your web applications live for you as you are using them.In chrome, go to the view menu, developer submenu, and choose "Allow JavaScript from Apple events". In Safari, it's under the safari menu, settings, developer, "Allow JavaScript from Apple events". Then you can do something like "Hey Claude, would you Please use osascript to navigate the front chrome tab to hacker news". Once you suggest using OSA script in a session it will figure out pretty quickly what it can do with it. Of course you can ask it to do casual things like open your mail app or whatever. Then you can figure out what other things will work like please click around my web app or check the JavaScript Console for errors. Another very important tips for using modern agents is to try to practice using speech to text. I think speaking might be something like five times faster than typing. It takes a lot of time to get used to, especially after a lifetime of programming by typing, but it's a very interesting and a different experience and once you have a lot of practice It starts to to feel effortless.
04 Apr 2026 1:31pm GMT