Tina4

Tina4 Python - Quick Reference#

๐Ÿ”ฅ Hot Tips

  • Routes go in src/routes/, templates in src/templates/, static files in src/public/
  • GET routes are public by default; POST/PUT/PATCH/DELETE require a token
  • Return a dict from response() and the framework sets application/json
  • A route file whose name starts with _ is treated as private and not loaded
  • Run tina4 serve to 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#

bash
# 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 serve

init 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:

python
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.

python
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.

python
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.

twig
<!-- src/templates/index.twig --><h1>Hello {{name}}</h1>
python
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.

ValueBackendRequired package
file (default)File system--
redisRedisredis
valkeyValkeyvalkey
mongodbMongoDBpymongo
memcachedMemcached--
databaseYour 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.

bash
TINA4_SESSION_BACKEND=mongodbTINA4_SESSION_MONGO_URI=mongodb://localhost:27017TINA4_SESSION_MONGO_DB=tina4_sessionsTINA4_SESSION_MONGO_COLLECTION=sessions
python
from 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.

scss
// 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=ABC1234
python
import osโ€‹api_key = os.getenv("TINA4_API_KEY", "ABC1234")

Access env vars programmatically:

python
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}.

python
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.

python
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.

twig
<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).

python
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#

python
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#

python
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).

python
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#

bash
tina4 migrate:create create_users_table

Migration files are timestamp-prefixed, so they sort in creation order. Fill it in:

sql
-- migrations/20260812104512_create_users_table.sqlCREATE TABLE users(    id   INTEGER PRIMARY KEY AUTOINCREMENT,    name TEXT);
bash
tina4 migrate

More on writing and running them: Migrations.

ORM#

python
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:

python
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}    delete

Or scaffold the files and edit them:

bash
tina4 generate crud User

More details on how CRUD generates its files and where they live.

Consuming REST APIs#

python
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:

python
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 / b

Run them with tina4 test. Add a test runner to the project first:

bash
uv add pytesttina4 test

Services#

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:

python
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.

python
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.

python
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).

python
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).

python
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:

python
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().

python
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 interpolation

Missing keys fall back to the default locale.

Back to top

HTML Builder#

python
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#

python
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#

python
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#

python
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#

python
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#

bash
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 CLI

MCP 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.

bash
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#

python
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()   # 4821

OpenID 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.