Topic 1 of 15 · Foundations

Python for Kotlin developers.

Every later topic in this series — LLM APIs, RAG, agents, LangGraph — is written in Python. This lesson is not "learn Python from scratch." It is a fast, precise bridge: for each Kotlin concept you already trust, here is its Python equivalent, where it behaves the same, and where it quietly doesn't.

The mental model

Same ideas, different compiler guarantees.

Kotlin and Python are both high-level, garbage-collected, multi-paradigm languages. The big difference isn't features — it's when mistakes get caught. Kotlin's compiler catches type errors, null misuse, and unreachable branches before the app runs. Python catches almost all of that at runtime, or not at all, unless you add type hints and a checker. Keep that one fact in mind through this whole lesson.

KotlinStatically typed. The compiler is your first reviewer. Null safety is enforced in the type system.
PythonDynamically typed by default. Errors surface when the line actually executes. Type hints are optional and advisory.
Consequence for youWrite more tests, run the script often, and lean on type hints (Section 10) once your code gets past throwaway size.
Section 0

Setup & Tooling

Kotlin equivalent: Gradle + the JVM, managed mostly by Android Studio. Python equivalent: a Python interpreter + a virtual environment, managed mostly by the command line.

Install Python 3.12+
Create a venv
per project
Activate it in
your shell
pip install
dependencies

A virtual environment (venv) is Python's answer to "which dependencies belong to which project" — the same problem Gradle's per-module dependency graph solves for you automatically. Without one, every project on your machine shares one global package list, which breaks fast.

# create a project folder and a virtual environment
mkdir intent-ai && cd intent-ai
python3 -m venv .venv

# activate it (macOS/Linux) — do this every new terminal session
source .venv/bin/activate

# your prompt now shows (.venv) — installs are scoped to this project
pip install requests

# run a script
python main.py

# leave the venv
deactivate
Checkpoint: run python3 --version and confirm 3.12 or newer, then create and activate a venv before touching any code below. Every later topic in this series assumes an active venv.
TaskKotlin / AndroidPython
Isolate dependencies per projectGradle module + version catalogvenv + pip / uv
Declare dependenciesbuild.gradle.ktsrequirements.txt or pyproject.toml
Run codeBuild + install APKpython file.py — no build step
REPLKotlin scratch file / kotlincpython interactive shell
Section 1

Syntax & Types

No braces, no semicolons — Python uses indentation to define blocks. This is not a style choice you can ignore; wrong indentation is a syntax error.

Kotlin
fun greet(name: String, loud: Boolean = false): String {
    val message = "Hello, $name"
    return if (loud) message.uppercase() else message
}

val x: Int = 5
var y = 10.5   // inferred Double
Python
def greet(name: str, loud: bool = False) -> str:
    message = f"Hello, {name}"
    return message.upper() if loud else message

x: int = 5
y = 10.5   # inferred float, but never enforced
ConceptKotlinPython
Block delimiter{ }Indentation (4 spaces, be consistent)
Statement end; optionalNewline, no semicolon needed
String templates"$name is $age"f"{name} is {age}"
val / varEnforced immutability with valNo val. Everything is reassignable; convention is UPPER_CASE for constants
Type declarationRequired or inferred, always checkedOptional hint, not enforced at runtime
if as expressionval m = if (x) a else bm = a if x else b (ternary is an expression suffix)
There is no real val in Python. A variable you never intend to reassign is just a variable you don't reassign — the interpreter won't stop you. This is one of the biggest silent-bug sources coming from Kotlin; Section 9 (type hints) narrows but doesn't close this gap.
Section 2

Collections & Comprehensions

Python's list/dict/set map directly to Kotlin's List/Map/Set, but Python collections are mutable by default — there's no separate MutableList vs List distinction.

Kotlin
val names = listOf("Rag", "Agent", "Tool")
val upper = names.map { it.uppercase() }
val long = names.filter { it.length > 4 }

val scores = mapOf("rag" to 9, "agent" to 8)
for ((k, v) in scores) println("$k -> $v")
Python
names = ["Rag", "Agent", "Tool"]
upper = [n.upper() for n in names]        # list comprehension
long = [n for n in names if len(n) > 4]   # filter, inline

scores = {"rag": 9, "agent": 8}
for k, v in scores.items():
    print(f"{k} -> {v}")

The Python list/dict comprehension is the idiomatic replacement for Kotlin's chained .map { }.filter { }. It reads right-to-left inside out: [expression for item in iterable if condition].

KotlinPython
List<T> / MutableList<T>list (always mutable)
Map<K, V>dict
Set<T>set
listOf(1,2,3) (immutable)(1, 2, 3) — a tuple, Python's actual immutable sequence
names.map { it.upper() }[n.upper() for n in names]
names.firstOrNull { it == "x" }next((n for n in names if n == "x"), None)
Section 3

Classes & Data Classes

Python's @dataclass is the closest sibling to Kotlin's data class: both auto-generate __init__/constructor, __repr__/toString, and equality. This pattern shows up constantly once you're modeling LLM messages, tool calls, and API payloads later in this series.

Kotlin
data class ChatMessage(
    val role: String,
    val content: String,
    val tokens: Int = 0
)

val m = ChatMessage(role = "user", content = "hi")
val m2 = m.copy(content = "hello")
println(m)  // ChatMessage(role=user, content=hi, tokens=0)
Python
from dataclasses import dataclass, replace

@dataclass
class ChatMessage:
    role: str
    content: str
    tokens: int = 0

m = ChatMessage(role="user", content="hi")
m2 = replace(m, content="hello")
print(m)  # ChatMessage(role='user', content='hi', tokens=0)

Regular classes look familiar too, with one habit to unlearn: every method's first parameter is an explicit self — Python never implies "this".

Kotlin
class Wallet(private var balance: Double) {
    fun deposit(amount: Double) {
        balance += amount
    }
    fun getBalance() = balance
}
Python
class Wallet:
    def __init__(self, balance: float):
        self._balance = balance

    def deposit(self, amount: float) -> None:
        self._balance += amount

    def get_balance(self) -> float:
        return self._balance
Forgetting selfEvery instance method needs self as the first parameter. Miss it and Python raises a confusing TypeError about argument counts.
No real privateA leading underscore (_balance) is convention only, not enforcement — unlike Kotlin's private. A double underscore triggers name-mangling, which is a workaround, not true privacy.
Section 4

None vs Kotlin Null Safety

This is the section to read twice. Kotlin's ?/?:/!! system makes null a compile-time concern. Python has None, but nothing in the language stops you from calling a method on it — you find out at runtime, via AttributeError.

Kotlin
fun findUser(id: String): User? = repo.get(id)

val user = findUser("42")
val name = user?.name ?: "unknown"

// compiler refuses this unless user is smart-cast or null-checked:
// println(user.name)
Python
from typing import Optional

def find_user(id: str) -> Optional[User]:
    return repo.get(id)

user = find_user("42")
name = user.name if user is not None else "unknown"

# this compiles and runs fine until it doesn't:
# print(user.name)  # AttributeError if user is None
KotlinPythonNote
String?Optional[str] or str | NoneAdvisory only — a type checker like mypy enforces it, the interpreter does not
user?.nameuser.name if user else NoneNo built-in safe-call operator
user ?: defaultuser or default / value if value is not None else defaultor also treats 0, "", [] as falsy — a common bug; prefer explicit is not None checks
!!assert user is not None, or just let it raisePython has no non-null assertion operator; failures surface as exceptions
The most common bug importing Kotlin habits into Python: using or for a default value. tokens = data.get("tokens") or 0 looks like Kotlin's ?:, but if tokens is legitimately 0, this expression still falls through to the default — because 0 is falsy in Python. Use data.get("tokens", 0) or an explicit is None check instead.
Section 5

Error Handling

Structurally identical to Kotlin's try/catch/finally — the difference is Python has no checked exceptions at all, not even the soft convention Kotlin inherited from Java. Nothing in a function signature tells you what it might throw.

Kotlin
try {
    val result = callLlmApi(prompt)
    println(result)
} catch (e: IOException) {
    println("network error: ${e.message}")
} catch (e: Exception) {
    println("unexpected: ${e.message}")
} finally {
    closeClient()
}
Python
try:
    result = call_llm_api(prompt)
    print(result)
except ConnectionError as e:
    print(f"network error: {e}")
except Exception as e:
    print(f"unexpected: {e}")
finally:
    close_client()
KotlinPython
try / catch / finallytry / except / finally
throw IllegalStateException(msg)raise ValueError(msg)
Custom exception: class ApiError(msg: String) : Exception(msg)class ApiError(Exception): pass
Result<T> / runCatching { }No stdlib equivalent — catch explicitly, or return a small result wrapper yourself
Checked exceptions (Java interop)None — every exception is effectively unchecked; read the docs or the source to know what a call can raise
Habit to build now: catch the specific exception type, not a bare except:. A bare except: also swallows KeyboardInterrupt and SystemExit — the Python equivalent of catching Throwable in Kotlin.
Section 6

Functions, Lambdas & Decorators

Higher-order functions work the same way conceptually. The one genuinely new idea is the decorator — you'll see this constantly in FastAPI (Topic 11) and LangGraph (Topic 9), so it's worth internalizing now.

Kotlin
fun retry(times: Int, block: () -> String): String {
    repeat(times - 1) {
        try { return block() } catch (_: Exception) {}
    }
    return block()
}

val square: (Int) -> Int = { it * it }
val doubled = listOf(1, 2, 3).map { it * 2 }
Python
def retry(times: int, block):
    for _ in range(times - 1):
        try:
            return block()
        except Exception:
            pass
    return block()

square = lambda x: x * x          # single-expression only
doubled = [x * 2 for x in [1, 2, 3]]

A decorator wraps a function with extra behavior — the closest Kotlin analogue is an annotation-driven wrapper (like Retrofit's @GET) or manually wrapping a lambda, except in Python you can write the wrapping logic yourself in plain code.

import time
from functools import wraps

def timed(fn):
    @wraps(fn)
    def wrapper(*args, **kwargs):
        start = time.time()
        result = fn(*args, **kwargs)
        print(f"{fn.__name__} took {time.time() - start:.3f}s")
        return result
    return wrapper

@timed
def call_llm_api(prompt: str) -> str:
    ...
    return "response"

# equivalent to: call_llm_api = timed(call_llm_api)
You will see @app.get("/health") (FastAPI) and @tool (agent frameworks) constantly from Topic 5 onward. They are ordinary decorators — a function that takes your function and returns a new one, run once when the module loads.
Section 7

Async & Concurrency

This is the section that matters most for the rest of the series — every LLM API call is I/O-bound, and Python's LLM SDKs offer both sync and async clients. asyncio's async def / await maps almost one-to-one onto Kotlin's suspend fun and coroutines, with one structural difference: Python needs an explicit event loop entry point.

Kotlin
suspend fun fetchAnswer(prompt: String): String {
    delay(200)               // suspends, doesn't block
    return "answer for $prompt"
}

suspend fun fetchAll(prompts: List<String>): List<String> =
    coroutineScope {
        prompts.map { async { fetchAnswer(it) } }
                .awaitAll()
    }

fun main() = runBlocking {
    println(fetchAll(listOf("a", "b")))
}
Python
import asyncio

async def fetch_answer(prompt: str) -> str:
    await asyncio.sleep(0.2)   # suspends, doesn't block
    return f"answer for {prompt}"

async def fetch_all(prompts: list[str]) -> list[str]:
    tasks = [fetch_answer(p) for p in prompts]
    return await asyncio.gather(*tasks)

if __name__ == "__main__":
    print(asyncio.run(fetch_all(["a", "b"])))
KotlinPython
suspend funasync def
calling a suspend funawait the coroutine call
async { }.await()asyncio.gather(...) / asyncio.create_task(...)
runBlocking { } entry pointasyncio.run(main()) entry point
Dispatchers (IO, Default)One event loop by default; CPU-bound work needs multiprocessing or a thread pool, because of the GIL
Why this matters immediately: Topic 2 (LLM APIs) uses these SDK clients directly — client.chat.completions.create(...) synchronously, or await client.chat.completions.create(...) with the async client when you're calling multiple models or handling concurrent requests in a FastAPI service (Topic 11). Mixing sync and async code incorrectly is the single most common Python bug for developers new to the language.
Section 8

Packaging & Imports

Gradle's module + dependency-catalog system has no single Python equivalent — Python's story is simpler but less structured. A module is just a .py file; a package is a folder containing an __init__.py (optional since Python 3.3, but still good practice for clarity).

intent-ai/
pyproject.toml # like build.gradle.kts
main.py
llm/
__init__.py
client.py
prompts.py
Kotlin
// build.gradle.kts
dependencies {
    implementation("com.squareup.retrofit2:retrofit:2.11.0")
}

// import
import com.squareup.retrofit2.Retrofit
Python
# pyproject.toml
[project]
dependencies = ["openai>=1.0.0"]

# import
from llm.client import call_model
import openai
KotlinPython
build.gradle.ktspyproject.toml (modern) or requirements.txt (simple)
Maven CentralPyPI
implementation("group:artifact:version")pip install package==version, or listed in pyproject.toml
Gradle moduleA folder with __init__.py (a package)
import com.foo.Barfrom foo import Bar
Tooling note: pip ships with Python and works fine for this series. If you want Gradle-like speed and lockfiles later, uv is the modern, much faster drop-in — same commands, just prefix with uv.
Section 9

Type Hints & mypy

Type hints are Python's opt-in way to recover some of what Kotlin's compiler gives you for free. They do nothing at runtime — def f(x: int) happily accepts a string unless you run a separate checker.

def add(a: int, b: int) -> int:
    return a + b

add(2, "3")  # runs, then crashes inside + with a TypeError — Python never checked the hint

# catch it before running, the way Kotlin's compiler would:
#   pip install mypy
#   mypy main.py
#   main.py:4: error: Argument 2 to "add" has incompatible type "str"; expected "int"
KotlinPython
Compiler-enforced typesmypy or pyright, run as a separate step (often in CI)
List<String>list[str]
Map<String, Int>dict[str, int]
String?str | None
Sealed class / whenUnion[A, B] or A | B, narrowed with isinstance
Recommendation for this series: write type hints on every function you author from here on, and run mypy before trusting a script. It's the closest thing to getting your Kotlin-trained instincts back.
Section 10 · Checkpoint

Capstone: Rewrite This Kotlin Model in Python

This exact ChatMessage shape reappears in Topic 2 (LLM APIs), so it's worth getting comfortable with now. Rewrite the Kotlin below in Python before checking the answer.

Given: Kotlin
data class ChatMessage(
    val role: String,
    val content: String
)

class Conversation(private val history: MutableList<ChatMessage> = mutableListOf()) {

    fun addMessage(role: String, content: String) {
        history.add(ChatMessage(role, content))
    }

    fun lastUserMessage(): String? =
        history.lastOrNull { it.role == "user" }?.content

    fun totalChars(): Int =
        history.sumOf { it.content.length }
}

fun main() {
    val convo = Conversation()
    convo.addMessage("user", "Summarize my week")
    convo.addMessage("assistant", "Sure, one moment")
    println(convo.lastUserMessage() ?: "no user message yet")
    println(convo.totalChars())
}
Your target: Python
from dataclasses import dataclass, field

@dataclass
class ChatMessage:
    role: str
    content: str

class Conversation:
    def __init__(self) -> None:
        self.history: list[ChatMessage] = []

    def add_message(self, role: str, content: str) -> None:
        self.history.append(ChatMessage(role, content))

    def last_user_message(self) -> str | None:
        for msg in reversed(self.history):
            if msg.role == "user":
                return msg.content
        return None

    def total_chars(self) -> int:
        return sum(len(m.content) for m in self.history)


if __name__ == "__main__":
    convo = Conversation()
    convo.add_message("user", "Summarize my week")
    convo.add_message("assistant", "Sure, one moment")
    print(convo.last_user_message() or "no user message yet")
    print(convo.total_chars())
Expected output: Summarize my week then 34 — run it with python conversation.py inside your activated venv from Section 0.
  • Ran python3 -m venv .venv and activated it
  • Wrote the ChatMessage dataclass and Conversation class from scratch, without copying
  • Used a list comprehension or generator expression for at least one method
  • Ran mypy conversation.py and fixed any reported issues
  • Can explain out loud why or was safe to use for last_user_message() here, but wouldn't be safe for a numeric default
  • Next up

    Topic 2: LLM APIs Fundamentals

    Gemini, OpenAI, and Claude clients — calling a model, streaming responses, and understanding tokens and cost.