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.
per project
your shell
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
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.| Task | Kotlin / Android | Python |
|---|---|---|
| Isolate dependencies per project | Gradle module + version catalog | venv + pip / uv |
| Declare dependencies | build.gradle.kts | requirements.txt or pyproject.toml |
| Run code | Build + install APK | python file.py — no build step |
| REPL | Kotlin scratch file / kotlinc | python interactive shell |
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.
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
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
| Concept | Kotlin | Python |
|---|---|---|
| Block delimiter | { } | Indentation (4 spaces, be consistent) |
| Statement end | ; optional | Newline, no semicolon needed |
| String templates | "$name is $age" | f"{name} is {age}" |
| val / var | Enforced immutability with val | No val. Everything is reassignable; convention is UPPER_CASE for constants |
| Type declaration | Required or inferred, always checked | Optional hint, not enforced at runtime |
if as expression | val m = if (x) a else b | m = a if x else b (ternary is an expression suffix) |
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.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.
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")
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].
| Kotlin | Python |
|---|---|
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) |
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.
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)
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".
class Wallet(private var balance: Double) {
fun deposit(amount: Double) {
balance += amount
}
fun getBalance() = balance
}
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
self as the first parameter. Miss it and Python raises a confusing TypeError about argument counts._balance) is convention only, not enforcement — unlike Kotlin's private. A double underscore triggers name-mangling, which is a workaround, not true privacy.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.
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)
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
| Kotlin | Python | Note |
|---|---|---|
String? | Optional[str] or str | None | Advisory only — a type checker like mypy enforces it, the interpreter does not |
user?.name | user.name if user else None | No built-in safe-call operator |
user ?: default | user or default / value if value is not None else default | or also treats 0, "", [] as falsy — a common bug; prefer explicit is not None checks |
!! | assert user is not None, or just let it raise | Python has no non-null assertion operator; failures surface as exceptions |
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.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.
try {
val result = callLlmApi(prompt)
println(result)
} catch (e: IOException) {
println("network error: ${e.message}")
} catch (e: Exception) {
println("unexpected: ${e.message}")
} finally {
closeClient()
}
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()
| Kotlin | Python |
|---|---|
try / catch / finally | try / 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 |
except:. A bare except: also swallows KeyboardInterrupt and SystemExit — the Python equivalent of catching Throwable in Kotlin.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.
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 }
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)
@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.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.
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")))
}
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"])))
| Kotlin | Python |
|---|---|
suspend fun | async def |
| calling a suspend fun | await the coroutine call |
async { }.await() | asyncio.gather(...) / asyncio.create_task(...) |
runBlocking { } entry point | asyncio.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 |
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.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).
// build.gradle.kts
dependencies {
implementation("com.squareup.retrofit2:retrofit:2.11.0")
}
// import
import com.squareup.retrofit2.Retrofit
# pyproject.toml [project] dependencies = ["openai>=1.0.0"] # import from llm.client import call_model import openai
| Kotlin | Python |
|---|---|
build.gradle.kts | pyproject.toml (modern) or requirements.txt (simple) |
| Maven Central | PyPI |
implementation("group:artifact:version") | pip install package==version, or listed in pyproject.toml |
| Gradle module | A folder with __init__.py (a package) |
import com.foo.Bar | from foo import Bar |
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.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"
| Kotlin | Python |
|---|---|
| Compiler-enforced types | mypy 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 / when | Union[A, B] or A | B, narrowed with isinstance |
mypy before trusting a script. It's the closest thing to getting your Kotlin-trained instincts back.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.
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())
}
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())
Summarize my week then 34 — run it with python conversation.py inside your activated venv from Section 0.python3 -m venv .venv and activated itChatMessage dataclass and Conversation class from scratch, without copyingmypy conversation.py and fixed any reported issuesor was safe to use for last_user_message() here, but wouldn't be safe for a numeric default