Tina4 Python - Quick Reference#
๐ฅ Hot Tips
- Routes go in
src/routes/, templates insrc/templates/, static files insrc/public/ - GET routes are public by default; POST/PUT/PATCH/DELETE require a token
- Return a
dictfromresponse()and the framework setsapplication/json - A route file whose name starts with
_is treated as private and not loaded - Run
tina4 serveto start the dev server on port 7146
Installation โข Static Websites โข Routing โข Middleware โข Templates โข Sessions โข SCSS โข Environments โข Authentication โข Forms & Tokens โข AJAX โข OpenAPI โข Databases โข Database Results โข Graph โข Migrations โข ORM โข CRUD โข REST Client โข Testing โข Services โข Websockets โข Queues โข WSDL โข GraphQL โข Localization โข HTML Builder โข Events โข Logging โข Cache โข Health โข DI Container โข Error Overlay โข Dev Admin โข CLI โข MCP โข FakeData
Installation#
# Install the tina4 CLI once. macOS: brew install tina4stack/tap/tina4# Windows (PowerShell): irm https://tina4.com/install.ps1 | iexcurl -fsSL https://tina4.com/install.sh | shโtina4 init python my-appcd my-apptina4 serveinit takes a language and a path, and the path is required. It scaffolds the project and installs the dependency -- one package, no tree, no version conflicts -- then asks Start the server now? [Y/n]. serve starts the server and opens your browser on http://localhost:7146, where the welcome page greets you.
tina4 setup is the guided alternative: it asks once where your projects live, scaffolds there, and tina4 serve my-app then finds the project from any directory.
The tina4 CLI, uv, .env variables and the framework itself behave identically on Linux, macOS and Windows.
More details on project setup and customization.
Static Websites#
Anything in ./src/public is served from / with no configuration: src/public/images/logo.png is at /images/logo.png.
Templates in ./src/templates render through a route. Return one from a handler, or use @template, which sits below the route decorator:
from tina4_python.core.router import get, templateโ@get("/about")@template("about.twig")async def about(request, response): return {"title": "About us"}More details on static website routing.
Basic Routing#
Import the route decorators from tina4_python. Each handler receives request and response. Path parameters arrive as function arguments.
from tina4_python import get, postโ@get("/")async def get_home(request, response): return response("<h1>Hello Tina4 Python</h1>")โ# POST requires a formToken in the body or Bearer auth@post("/api")async def post_api(request, response): return response({"data": request.body})โ# Redirect after a POST@post("/register")async def post_register(request, response): return response.redirect("/welcome")Follow the links for basic routing and dynamic routing with variables.
Middleware#
Middleware runs before and after your route handler. Define a class with static methods, then attach it with the @middleware decorator.
Hooks are discovered by prefix: before_* runs before the handler, after_* runs after. response.content holds bytes, so append bytes.
from tina4_python import get, middlewareโclass RunSomething:โ @staticmethod def before_something(request, response): response.content += b"Before" return request, responseโ @staticmethod def after_something(request, response): response.content += b"After" return request, responseโ@middleware(RunSomething)@get("/middleware")async def get_middleware(request, response): return response("Route")Use before_* to inspect or reject a request, and after_* to modify the outgoing body. Middleware is additive and does not change a route's auth requirement: POST, PUT, PATCH and DELETE stay token-gated. Use @noauth() to open a write route.
Follow the links for more on Middleware Declaration and Linking to Routes.
Template Rendering#
Put .twig files in ./src/templates and assets in ./src/public. The template engine reads your layout, fills in the variables, and delivers clean HTML.
<!-- src/templates/index.twig --><h1>Hello {{name}}</h1>from tina4_python import getโ@get("/")async def get_home(request, response): return response.render("index.twig", {"name": "World!"})Sessions#
The default session handler stores data on the file system. Override TINA4_SESSION_BACKEND in .env to switch backends.
The value is the backend name, not a class name, and it is normalised: leading or trailing spaces and capitals are fine, so Redis resolves.
An unrecognised value RAISES at startup, naming the bad value and the valid ones. Leave TINA4_SESSION_BACKEND unset, or set it to an empty value, for the file default.
| Value | Backend | Required package |
|---|---|---|
file (default) | File system | -- |
redis | Redis | redis |
valkey | Valkey | valkey |
mongodb | MongoDB | pymongo |
memcached | Memcached | -- |
database | Your ORM DB | -- |
Aliases are accepted: filesystem, mongo, memcache, db. The memcached handler speaks the text protocol over a socket, so it needs no client library.
TINA4_SESSION_BACKEND=mongodbTINA4_SESSION_MONGO_URI=mongodb://localhost:27017TINA4_SESSION_MONGO_DB=tina4_sessionsTINA4_SESSION_MONGO_COLLECTION=sessionsfrom tina4_python import getโ@get("/session/set")async def get_session_set(request, response): request.session.set("name", "Joe") request.session.set("info", {"info": ["one", "two", "three"]}) return response("Session Set!")โโ@get("/session/get")async def get_session_get(request, response): name = request.session.get("name") info = request.session.get("info") return response({"name": name, "info": info})โโ@get("/session/clear")async def get_session_clear(request, response): request.session.delete("name") return response("Session key removed!")SCSS Stylesheets#
Drop .scss files in ./src/scss. The framework compiles them to ./src/public/css.
// src/scss/main.scss$primary: #2c3e50;body { background: $primary; color: white;}More details on css and scss.
Environments#
The .env file holds your project configuration. The framework reads it at startup.
TINA4_DEBUG=trueTINA4_PORT=7146TINA4_DATABASE_URL=sqlite:///data/app.dbTINA4_LOG_LEVEL=ALLTINA4_API_KEY=ABC1234import osโapi_key = os.getenv("TINA4_API_KEY", "ABC1234")Access env vars programmatically:
from tina4_python.dotenv import load_env, get_env, has_env, require_env, is_truthyโload_env() # Load .env file (auto on server start)get_env("TINA4_DATABASE_URL") # Get value or Noneget_env("PORT", "7146") # Get value with defaulthas_env("TINA4_DEBUG") # True if setrequire_env("TINA4_DATABASE_URL") # Raises if missingis_truthy(get_env("TINA4_DEBUG")) # True for "true", "1", "yes"All 68 variables: Chapter 33: Environment Variables.
Authentication#
POST, PUT, PATCH, and DELETE routes require a Bearer token by default. Pass Authorization: Bearer TINA4_API_KEY in the request header. Use @noauth() to open a route to everyone. Use @secured() to lock a GET route behind authentication.
Without @noauth(), an unauthenticated POST returns {"error":"Unauthorized","message":"Valid authorization token required","status":401}.
from tina4_python import get, post, noauth, securedfrom tina4_python.auth import Authโ@post("/login")@noauth()async def login(request, response): token = Auth.get_token({"user_id": 1, "role": "admin"}) return response({"token": token})โ@get("/protected")@secured()async def secret(request, response): return response("Welcome!")โ@get("/verify")async def verify(request, response): token = request.headers.get("Authorization", "").replace("Bearer ", "") payload = Auth.valid_token(token) return response({"valid": payload is not None})Guard a route by role or permission with role() and can(). Both read the verified roles and permissions claims from the signed token. A missing token returns 401, a valid token without the role or permission returns 403.
from tina4_python import get, delete, role, canโ@role("admin")@get("/admin/stats")async def stats(request, response): return response({"active_users": 42})โ@can("posts.delete")@delete("/api/posts/{id:int}")async def remove_post(id, request, response): return response({"deleted": id})HTML Forms and Tokens#
form_token works as a function or a filter. Both emit the hidden formToken input that satisfies the POST auth check for browser form submissions.
<form method="POST" action="/register"> {{ form_token() }} <input name="email"> <button>Save</button></form>More details on posting form data, basic form handling, how to generate form tokens, dealing with file uploads, returning errors, disabling route auth and a full login example.
AJAX and frond.js#
Tina4 ships with frond.js, a small zero-dependency JavaScript library for AJAX calls, form submissions, and real-time WebSocket connections.
More details on available features.
OpenAPI and Swagger UI#
Visit http://localhost:7146/swagger. Decorated routes appear in the Swagger UI without manual annotation. The generated spec itself is at /swagger/openapi.json (OpenAPI 3.0.3; set TINA4_SWAGGER_OPENAPI=3.1 for 3.1.0).
from tina4_python import getfrom tina4_python.swagger import descriptionโ@get("/users")@description("Get all users")async def users(request, response): return response(User().select("*"))Follow the links for more on Configuration, Usage and Decorators.
Databases#
from tina4_python.database import Databaseโ# dba = Database("<driver>:<hostname>/<port>:database_name", username, password)dba = Database("sqlite3:data.db")The adapter speaks PostgreSQL, MySQL, and SQLite. It translates your queries into whichever dialect the database understands.
Follow the links for more on Available Connections, Core Methods, Usage and Full transaction control.
Database Results#
result = dba.fetch("select * from test_record order by id", limit=3, offset=1)โarray = result.to_array()paginated = result.to_paginate()csv_data = result.to_csv()json_data = result.to_json()Looking at detailed Usage will deepen your understanding.
Graph#
Graph engines work exactly like databases: one URL-selected factory, one portable node and edge surface, and a driver that loads only when you use it. The scheme picks the engine (Ultipa, Neo4j, Memgraph, or ArangoDB).
from tina4_python.graph import GraphDatabaseโgraph = GraphDatabase.create("ultipa://localhost:60061/mygraph")โalice = graph.add_node("Person", {"name": "Alice"})bob = graph.add_node("Person", {"name": "Bob"})graph.add_edge(alice.id, bob.id, "KNOWS", {"since": 2020})Follow the link for more on the Graph data layer.
Migrations#
tina4 migrate:create create_users_tableMigration files are timestamp-prefixed, so they sort in creation order. Fill it in:
-- migrations/20260812104512_create_users_table.sqlCREATE TABLE users( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT);tina4 migrateMore on writing and running them: Migrations.
ORM#
from tina4_python.orm import ORM, IntegerField, StringFieldโclass User(ORM): id = IntegerField(primary_key=True, auto_increment=True) name = StringField()โUser({"name": "Alice"}).save()โuser = User()user.load("id = ?", [1])The table has to exist first. Run a migration before the first save() -- Databases covers writing one.
ORM covers more ground than this snippet shows. Study the Advanced Detail to get the full value.
CRUD#
Set auto_crud on a model and the framework registers the CRUD routes for it:
from tina4_python.orm import ORM, IntegerField, StringFieldโclass User(ORM): auto_crud = Trueโ id = IntegerField(primary_key=True, auto_increment=True) name = StringField()On boot it logs AutoCrud: registered 5 routes for User (/api/user):
GET /api/user listPOST /api/user createGET /api/user/{id} readPUT /api/user/{id} updateDELETE /api/user/{id} deleteOr scaffold the files and edit them:
tina4 generate crud UserMore details on how CRUD generates its files and where they live.
Consuming REST APIs#
from tina4_python import Apiโapi = Api("https://api.example.com", auth_header="Bearer xyz")result = api.get("/users/42")print(result["body"])More details on sending POST data, authorization headers, and other controls for outbound API requests.
Inline Testing#
Each assertion is built from the arguments to call with and the expected result. Import all three names from tina4_python.Testing:
from tina4_python.Testing import assert_equal, assert_raises, testsโ@tests( assert_equal((7, 7), 1), assert_equal((-1, 1), -1), assert_raises(ZeroDivisionError, (5, 0)),)def divide(a: int, b: int) -> float: if b == 0: raise ZeroDivisionError("division by zero") return a / bRun them with tina4 test. Add a test runner to the project first:
uv add pytesttina4 testServices#
Background work that outlives a request runs under the service runner, which starts each one in a background thread so the web server keeps serving. Use register_service() for a class-based service and register() for a callable:
from tina4_python.service import Service, ServiceRunnerโclass Heartbeat(Service): def run(self): while not self.should_stop(): ...โrunner = ServiceRunner()runner.register_service("heartbeat", Heartbeat())runner.register("cleanup", lambda ctx: purge_old_rows(), interval=300)runner.start()More details on the start/stop lifecycle and graceful shutdown.
Websockets#
WebSocket support is built in. No extra dependencies. Define a handler with the @websocket decorator, and the framework manages the connection alongside your HTTP routes on the same port.
Import websocket from tina4_python.core.router. Events are open, message and close.
from tina4_python.core.router import websocketโ@websocket("/ws/chat")async def chat_ws(connection, event, data): if event == "message": await connection.send(f"Echo: {data}")Have a look at the PubSub example under Websockets.
Queues#
Supports litequeue (default/SQLite), RabbitMQ, Kafka, and MongoDB backends. The queue system uses produce() and consume() directly, with no separate Producer or Consumer classes.
from tina4_python.queue import Queueโ# Produce a messagequeue = Queue(topic="emails")queue.produce("emails", {"to": "alice@example.com", "subject": "Welcome"})โ# Consume messages - a worker loop, it does not returnfor job in queue.consume("emails"): print(job.payload)โ# Drain a fixed number of times instead of looping foreverfor job in queue.consume("emails", iterations=1): print(job.payload)Full details on backend configuration, batching, multi-queue consumers, and error handling.
WSDL#
Subclass WSDL and decorate each operation with @wsdl_operation, giving the return shape. Hand the request to it from a route: handle() answers both the WSDL document (GET) and the SOAP call (POST).
from typing import Listโfrom tina4_python.core.router import get, noauth, postfrom tina4_python.wsdl import WSDL, wsdl_operationโโclass Calculator(WSDL):โ @wsdl_operation({"Result": int}) def Add(self, a: int, b: int): return {"Result": a + b}โ @wsdl_operation({"Numbers": List[int], "Total": int}) def SumList(self, Numbers: List[int]): return {"Numbers": Numbers, "Total": sum(Numbers)}โโ@get("/calculator")async def calculator_wsdl(request, response): return response(Calculator(request).handle())โโ@post("/calculator")@noauth()async def calculator_soap(request, response): return response(Calculator(request).handle())More Details on WSDL configuration and usage.
GraphQL#
GraphQL() takes no arguments. Build the schema on the instance: add_type for object types, add_query for a field plus its resolver. A resolver receives (root, args, ctx).
from tina4_python.graphql import GraphQLโgql = GraphQL()gql.schema.add_type("User", {"id": "ID", "name": "String", "email": "String"})gql.schema.add_query("hello", {"name": "String!"}, "String", lambda root, args, ctx: f"Hello, {args['name']}!")โgql.execute('{ hello(name: "Ada") }')# {'data': {'hello': 'Hello, Ada!'}}Register the endpoint:
from tina4_python import post, noauthโ@post("/graphql")@noauth()async def handle_graphql(request, response): return response(gql.execute(request.body.get("query", "")))execute_json, introspect and schema_sdl are also available, and auto_register generates a schema from your ORM models.
Localization (i18n)#
Translation files live in src/locales/ as JSON. Create an I18n instance with a locale directory and a default locale, switch languages at runtime, and translate keys with t().
from tina4_python.i18n import I18nโi18n = I18n(locale_dir="src/locales", default_locale="en")โi18n.set_locale("af") # switch languagei18n.t("welcome_message") # translated string for the active localei18n.t("greeting", name="Ada") # with interpolationMissing keys fall back to the default locale.
HTML Builder#
from tina4_python.HtmlElement import HTMLElement, add_html_helpersโel = HTMLElement("div", {"class": "card"}, ["Hello"])str(el) # <div class="card">Hello</div>โ# Nestingpage = HTMLElement("div")( HTMLElement("h1")("Title"), HTMLElement("p")("Content"),)โ# Helper functionsadd_html_helpers(globals())html = _div({"class": "card"}, _h1("Title"), _p("Description"), _a({"href": "/more"}, "Read more"),)Events#
from tina4_python.core.events import on, emit, once, offโ@on("user.created")def send_welcome(user): print(f"Welcome {user['name']}!")โ@once("app.ready")def on_ready(): print("Started!")โemit("user.created", {"name": "Alice"})Logging#
from tina4_python.debug import LogโLog.info("Server started")Log.debug("Request received", path="/api/users")Log.warning("Slow query", duration_ms=450)Log.error("Connection failed", host="db.example.com")Set TINA4_LOG_LEVEL in .env: ALL, DEBUG, INFO, WARNING, ERROR.
Response Cache#
from tina4_python.core.router import get, cachedโ@cached(max_age=120)@get("/api/products")async def products(request, response): return response(expensive_query())Health Endpoint#
Built-in at /__health, with /health always registered too. Returns {"status": "ok", "version": "3.x.x", "uptime": 123.4, "framework": "tina4-python"}. Configure the path with TINA4_HEALTH_PATH. It is a liveness probe: it reports on the process, never on a database or cache.
DI Container#
from tina4_python.container import Containerโcontainer = Container()container.singleton("db", lambda: Database("sqlite:///app.db"))container.register("mailer", lambda: MailService())db = container.get("db")Error Overlay#
Automatic in debug mode. Shows syntax-highlighted stack trace with source context. Set TINA4_DEBUG=true in .env.
Dev Admin#
Available at /__dev in debug mode. Includes route inspector, database tab, request capture, metrics bubble chart, gallery examples, dev mailbox.
CLI Commands#
tina4 setup # Guided setup: install what is missing, scaffold a projecttina4 init python my-app # Scaffold a project at a path you choosetina4 serve # Start dev servertina4 serve --production # Production modetina4 doctor # Check environmenttina4 env # Configure .envtina4 scss # Compile src/scss/ to src/public/css/tina4 docs # Download documentationtina4 generate model User # Generate scaffoldingtina4 migrate # Run migrationstina4 test # Run teststina4 ai # Install AI contexttina4 update # Self-update the CLIMCP Server#
Mounted at /__dev/mcp in debug mode. Exposes 49 dev tools via JSON-RPC 2.0 -- database_query, route_list, route_test and friends. Works with Claude Code, Cursor, and other MCP clients.
curl -X POST http://localhost:7146/__dev/mcp \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'Set TINA4_MCP to force it on or off; remote access additionally needs TINA4_MCP_REMOTE=true and a valid token.
FakeData#
from tina4_python.seeder import FakeDataโfake = FakeData()fake.name() # "Alice Johnson"fake.email() # "alice@example.com"fake.phone() # "+1-555-0123"fake.sentence() # "The quick brown fox..."fake.integer() # 4821OpenID Connect SSO#
Configure any standard OIDC issuer, then use the normal secured-route gate and Tina4 Session. See OpenID Connect SSO for the complete client API.
GIS and PostGIS#
Declare a PointField, query metres, and return GeoJSON. Coordinates are longitude then latitude. See GIS and PostGIS.
IoT and MQTT#
Use the MQTT 3.1.1 client for QoS 0/1 telemetry, retained state, Last Will, and verified TLS. See IoT and MQTT.
๐ Download the book#
Tina4 for Python Developers (PDF): full reference, printable, with clickable table of contents and PDF outline. Regenerated with every release.