Python for the HCPI Platform
By the end of this page you'll be able to read any Python file in HCPI and follow what it's doing. We move slowly, build everything in small files, and write code rather than just talk about it.
What Python is
Python is a programming language. A Python program is a plain text file (ending in .py) containing instructions you want the computer to follow. You run the file by giving it to the Python interpreter, which reads it top-to-bottom and does what it says.
That's the whole loop you'll repeat dozens of times today:
HCPI is written in Python, so every back-end file you'll touch is one of these .py files. The same skills that work on a 10-line practice file work on a 10,000-line HCPI module.
Setting up where you'll write code
Everything in this page happens outside HCPI, in a throwaway folder so you can't accidentally break your install.
Open WSL in VS Code
If you're on Windows, open a WSL terminal (the same Ubuntu shell you used during installation) and create a practice folder:
The last command, code ., opens VS Code in WSL pointing at the current folder. The first time, VS Code will install a small helper into WSL and ask you to install the Remote – WSL extension if you don't have it. Accept.
You'll know VS Code is in WSL mode when the bottom-left corner reads WSL: Ubuntu (or similar). Files you create here live in the WSL filesystem at /home/<your-user>/python-practice/ and can be edited from VS Code as if they were native.
If you're on Linux, the same code . works directly.
Open the integrated terminal
Inside VS Code, hit Ctrl + ` (the backtick key, top-left of most keyboards) to open the integrated terminal. It opens in your current folder, in WSL. This is where you'll run your Python files.
Verify Python is installed
In that terminal:
You should see something like Python 3.11.x. If not, install it — Day 1's Installing Dependencies covers this.
Your first file
In VS Code's file panel, create a new file called hello.py (Ctrl + N, then Ctrl + S to save with a name). Type one line:
Save with Ctrl + S. Then in the terminal:
You should see hello print out. That's the loop. We'll repeat it.
Always save before running
Running a file runs the saved version on disk, not whatever's currently on screen in the editor. Ctrl+S is muscle memory you'll want to develop.
1. The print function and comments
print(...) displays text in the terminal. It's how you make a program "say something" you can see. Edit hello.py to:
print("Hello, HCPI.")
print("This is line two.")
# This line is a comment — Python ignores it.
print("This is line three.") # comments can also sit at the end of a line
Save and run again:
Two things to notice:
- Lines run top to bottom. Python reads line 1, then line 2, then line 3.
#starts a comment. Everything from#to the end of the line is ignored when the program runs. Use comments to leave notes for yourself or other readers.
Whitespace around print matters — see the indentation in the next section.
2. Variables
A variable is a name that stands for a value. You create a variable by writing a name, an equals sign, and the value:
Now any time you write greeting later in the file, Python substitutes "Hello". Create variables.py:
greeting = "Hello"
name = "Alice"
print(greeting)
print(name)
print(greeting, name) # print can take multiple values separated by commas
Run it:
Re-assigning
A variable doesn't keep its first value forever. Assigning again replaces it:
count = 1
print(count) # 1
count = count + 1
print(count) # 2
count = "now I'm a string"
print(count) # now I'm a string
In some languages a variable's type is fixed when you declare it. Not in Python — a variable just holds whatever value you last assigned. You shouldn't flip between types like the example above in real code (it confuses readers), but Python won't stop you.
Naming rules
A variable name:
- Can contain letters, digits, and underscores (
_). - Cannot start with a digit.
total2 = 5is fine;2total = 5is an error. - Is case-sensitive:
totalandTotalare different variables. - Should be lowercase with underscores between words (
outlet_count, notoutletCountorOutletCount). This is the Python convention used everywhere in HCPI. - Cannot be a Python reserved word:
if,for,class, etc.
Exercise 1
Create intro.py with three variables: your name, your age, the current year. Print a line that uses all three. (Don't worry yet about combining them into one nice sentence — just print them comma-separated.)
3. The basic types
Python tracks what kind of value each variable holds. The most common types you'll meet:
| Type | What it is | Example |
|---|---|---|
int |
Whole number | x = 5 |
float |
Decimal number | y = 3.14 |
str |
Text (a "string") | name = "Alice" |
bool |
True or false | flag = True |
NoneType |
"No value" / "missing" | nothing = None |
A few things that surprise newcomers:
1. No separate "double" or "long". Python has just one integer type (int) and one decimal type (float). Don't worry about the difference between them most of the time.
2. Booleans are capitalised: True and False. Not true/TRUE/false.
3. None is special. It's Python's word for "nothing here yet" or "no value." Different languages call this null, nil, or undefined. It is itself a value — nothing = None is a real assignment.
You can ask Python what type something is:
x = 5
print(type(x)) # <class 'int'>
print(type(3.14)) # <class 'float'>
print(type("hi")) # <class 'str'>
print(type(True)) # <class 'bool'>
print(type(None)) # <class 'NoneType'>
You won't write type(...) often in real code — it's just a way to check what Python thinks something is when you're debugging.
4. Numbers and math
a = 10
b = 3
print(a + b) # 13
print(a - b) # 7
print(a * b) # 30
print(a / b) # 3.3333... — division always returns a float
print(a // b) # 3 — floor division (integer result, rounded down)
print(a % b) # 1 — modulo (remainder)
print(a ** b) # 1000 — a to the power of b
Operator precedence is what you'd expect — parentheses force grouping:
You can mix integers and floats freely; the result is a float:
Exercise 2
Create math.py. Compute and print:
- The area of a circle of radius 5. (Area = π × r². Use
pi = 3.14159.) - How many whole hours and how many leftover minutes are in 200 minutes. (Hint:
//and%.)
5. Strings
Strings hold text. Single quotes and double quotes are interchangeable — pick one and be consistent inside a project:
Joining strings
Use + to glue strings together (this is called concatenation):
You can only glue strings to strings. "Alice" + 5 is an error — convert the number first with str(5):
f-strings — the better way
The modern, readable way is an f-string — a string with f in front and {...} placeholders:
name = "Alice"
n = 5
print(f"Hello {name}, you have {n} reports.")
# Hello Alice, you have 5 reports.
Anything between { and } is evaluated as a Python expression and dropped in:
String basics
name = "Alice"
print(len(name)) # 5 — number of characters
print(name.upper()) # ALICE
print(name.lower()) # alice
print(name + "!") # Alice!
print("a" in name) # False — case-sensitive
print("A" in name) # True
quote = "Bread"
print(quote[0]) # B — strings can be indexed like a list, starting at 0
print(quote[-1]) # d — negative index counts from the end
You'll see f-strings constantly in HCPI code. Old code uses string concatenation or the older %s style — recognise them, but write f-strings yourself.
Exercise 3
Create strings.py. Make variables for an outlet name, a price (a float), and a date (a string). Print an f-string like "At Kikuubo Market on 2026-05-15, bread cost 1500.00 UGX." using your three variables.
6. Booleans, comparisons, and truthiness
Booleans hold True or False. They come up whenever you compare things:
print(5 > 3) # True
print(5 == 5) # True — note: == compares, = assigns. Different!
print(5 != 6) # True
print("a" == "A") # False — string comparison is case-sensitive
Combine with and, or, not:
Truthiness — the part to remember
In Python, any value can be used as a true/false test, not just booleans. The values that count as "false" (the falsy values) are:
FalseNone- The number
0(or0.0) - The empty string
"" - An empty list
[], dict{}, or setset()
Everything else is "true" (truthy). So:
name = "Alice"
if name:
print("There's a name") # runs — non-empty string is truthy
items = []
if items:
print("There are items") # doesn't run — empty list is falsy
This shows up everywhere in HCPI code. if record.outlet_id: means "if this record has an outlet linked" — because an empty Many2one link evaluates as falsy.
None and is
None deserves special treatment. To check "is this missing?" use is None, not == None:
value = None
if value is None:
print("missing") # this is the idiomatic check
if value == None:
print("missing") # also works, but Python style guides discourage it
Exercise 4
Create booleans.py. Make a variable score = 75. Print whether the score is a pass (≥ 50) using a comparison. Then make name = "" and print whether it's empty using only truthiness (no ==).
7. Lists
A list is an ordered collection of values. Square brackets, comma-separated:
items = ["bread", "milk", "eggs"]
prices = [1500, 3000, 4500]
mixed = ["bread", 1500, True] # legal but unusual — usually lists hold one kind of thing
Reading from a list
Items have positions (indices) starting at 0:
items = ["bread", "milk", "eggs"]
print(items[0]) # bread — the first item
print(items[1]) # milk
print(items[-1]) # eggs — negative index counts from the end
print(items[-2]) # milk
print(len(items)) # 3 — number of items
You can grab a slice — a sub-list:
print(items[0:2]) # ['bread', 'milk'] — from index 0 up to (not including) 2
print(items[1:]) # ['milk', 'eggs'] — from 1 to the end
print(items[:2]) # ['bread', 'milk'] — from start to 2
Modifying a list
items = ["bread", "milk", "eggs"]
items.append("rice") # add to the end → ['bread', 'milk', 'eggs', 'rice']
items.insert(0, "salt") # insert at index 0 → ['salt', 'bread', 'milk', 'eggs', 'rice']
items.remove("milk") # remove first match → ['salt', 'bread', 'eggs', 'rice']
del items[0] # remove by index → ['bread', 'eggs', 'rice']
items[1] = "yoghurt" # replace by index → ['bread', 'yoghurt', 'rice']
print("bread" in items) # True — membership test
print("milk" in items) # False
Lists share their contents
This catches people. Assigning a list to another name doesn't copy it — both names point at the same list:
a = [1, 2, 3]
b = a
b.append(4)
print(a) # [1, 2, 3, 4] — the same list! a and b are two names for one thing
If you want a real copy, use a.copy() or list(a).
Exercise 5
Create lists.py. Make a list of three outlet names. Add a fourth. Remove the second. Print the final list and its length.
8. Dictionaries
A dictionary (dict) maps keys to values. Curly braces, key: value pairs:
Think of it like a row in a spreadsheet where columns have names instead of positions.
Reading and writing
print(outlet["name"]) # Kikuubo Market
print(outlet["code"]) # K01
outlet["address"] = "Plot 4" # add a new key
outlet["active"] = False # update existing
del outlet["code"] # remove a key
Accessing a key that doesn't exist crashes:
The safe alternative is .get(), which returns None (or a default you supply) when the key is missing:
Looping over a dict
outlet = {"name": "Kikuubo", "code": "K01", "active": True}
for key in outlet:
print(key) # name, code, active
for key, value in outlet.items():
print(key, "=", value) # name = Kikuubo, code = K01, active = True
"name" in outlet checks whether a key exists (not a value).
Dicts are everywhere in Odoo. Every time you create a record you pass a dict of field names to values:
Exercise 6
Create dicts.py. Make a dict for a person with three keys: name, age, email. Print just the email. Use .get() to safely look up a key that doesn't exist. Add a fourth key. Loop over the dict and print each key/value pair.
9. Tuples and sets
Two other collection types, used less often than lists and dicts but worth recognising.
Tuples — "fixed lists"
A tuple is like a list but immutable — once created, you can't change it:
You can unpack a tuple (or list) into multiple variables:
In Odoo, domains are lists of tuples:
Each ('state', '=', 'active') is a 3-tuple — a fixed (field, operator, value) triple.
Sets — uniqueness, fast lookup
A set holds unique values with no duplicates. Curly braces with no key-value structure:
tags = {"food", "drink", "food"} # duplicates auto-removed → {"food", "drink"}
tags.add("snack")
tags.discard("missing") # no error if absent
print("food" in tags) # True
Use a set when you want "is this in the collection?" to be very fast (much faster than in list for thousands of items), or to deduplicate things.
10. if, elif, else
Programs need to make decisions. if lets you run a block of code only when a condition is true:
score = 75
if score >= 90:
print("A")
elif score >= 80:
print("B")
elif score >= 50:
print("Pass")
else:
print("Fail")
Three rules:
- No parentheses around the condition.
if score >= 90:notif (score >= 90):. - The colon at the end is required.
- Indentation defines which lines belong to the
if. Convention is 4 spaces. The block ends when you stop indenting.
if score >= 50:
print("pass") # part of the if block (indented)
print("well done") # part of the if block (still indented)
print("done") # NOT part of the if block (back to no indent) — always runs
VS Code converts your Tab key to 4 spaces by default — let it. Don't mix tabs and spaces in the same file or Python will refuse to run it.
elif and else
elif(short for "else if") — checked only if the previousifwas false.else— runs when none of theif/elifconditions were true. Optional.
Exercise 7
Create grade.py. Set score = 67. Use if/elif/else to print one of: "A" (≥90), "B" (≥80), "C" (≥70), "Pass" (≥50), "Fail" otherwise.
11. Loops — for and while
for
for walks through the items in a collection one at a time:
The variable item takes the value of each list element in turn. The body is indented just like with if.
You'll often want a counted loop. range(n) produces 0, 1, 2, ..., n-1:
for i in range(5):
print(i) # 0, 1, 2, 3, 4
for i in range(2, 8):
print(i) # 2, 3, 4, 5, 6, 7
for i in range(0, 10, 2):
print(i) # 0, 2, 4, 6, 8 — third argument is the step
enumerate — when you need both index and value
items = ["bread", "milk", "eggs"]
for index, item in enumerate(items):
print(index, item)
# 0 bread
# 1 milk
# 2 eggs
Looping over a dict
outlet = {"name": "Kikuubo", "code": "K01"}
for key, value in outlet.items():
print(key, "=", value)
while
while repeats as long as a condition stays true:
break exits a loop early; continue skips to the next iteration:
for i in range(10):
if i == 5:
break # stop the loop entirely when i hits 5
if i % 2 == 0:
continue # skip the print for even numbers
print(i) # only odd numbers below 5: 1, 3
You'll write for loops far more often than while. Reach for while only when you genuinely don't know how many times to repeat.
Exercise 8
Create loops.py. Make a list of five outlet names. Loop over them and print "1. Kikuubo", "2. Owino", etc. — number each line. (Hint: enumerate, and remember it starts at 0 so add 1.)
12. Functions
A function is a reusable block of code with a name. You define it once and call it whenever you want it to run.
Breaking that down:
def greet(name):— defines a function calledgreetthat takes one parameter,name.- The indented block is the function's body — what runs when it's called.
greet("Alice")— calls the function with the value"Alice". Inside the function,nameis now"Alice".
Returning a value
Functions can give a value back with return:
def double(x):
return x * 2
result = double(5)
print(result) # 10
print(double(7)) # 14 — you can pass the return straight to print
A function without an explicit return returns None.
Multiple parameters; default values
def greet(name, greeting="Hello"):
return f"{greeting}, {name}"
print(greet("Alice")) # Hello, Alice
print(greet("Alice", greeting="Hi")) # Hi, Alice
print(greet("Alice", "Hey")) # Hey, Alice — positional also fine
Parameters with defaults must come after parameters without defaults.
Why functions matter
Functions let you:
- Name a piece of behaviour — so the reader knows what it does without reading every line.
- Avoid repeating code — write once, call from many places.
- Test in isolation — a small function is easier to reason about than a big script.
Every Odoo model method is a function. When you write def action_submit(self): you're defining a function that the framework calls when a user clicks a button.
Exercise 9
Create functions.py:
- Write
def total(prices):that takes a list of prices and returns the sum. (Hint: there's a built-insum()you can use.) - Write
def total_with_tax(prices, tax_rate=0.18):that adds tax. The default rate is 18%. - Call both with the list
[1500, 3000, 4500]and print the results.
13. Modules and imports
A module is a .py file. You can use code from one module inside another by importing it.
Create helpers.py:
In the same folder, create main.py:
Or pull specific names out:
Python also ships with built-in modules you can import. For example, math:
In HCPI you'll see imports like:
That's "from the odoo package, get fields, models, and api." Once you've read those, you understand half the line count of every HCPI model file.
Exercise 10
Create calc.py with two functions: add(a, b) returning their sum, and multiply(a, b) returning their product. Create use_calc.py that imports both and prints add(3, 4) and multiply(3, 4).
14. Classes — just enough for Odoo
A class is a blueprint for an object — a thing that bundles some data and some behaviour together. You'll write classes whenever you build a model in HCPI.
class Outlet:
def __init__(self, name, code):
self.name = name
self.code = code
def label(self):
return f"{self.code}: {self.name}"
Then use it:
Reading the class line by line:
class Outlet:— define a new class calledOutlet.def __init__(self, name, code):— the constructor, called automatically when you writeOutlet(...). The double underscores make this name special.self— every method takesselfas its first parameter.selfis the object the method is being called on. You don't pass it when calling (o.label(), noto.label(o)) — Python fills it in.self.name = name— store the parameter on the object so other methods can read it.def label(self):— a regular method. Can accessself.nameandself.code.
Why self?
If you have two outlets:
a = Outlet("Kikuubo", "K01")
b = Outlet("Owino", "OW1")
print(a.label()) # K01: Kikuubo
print(b.label()) # OW1: Owino
Both call the same label method, but each gets a different result. self is how label knows which object it's working with — when you call a.label(), Python passes a in as self; when you call b.label(), it passes b.
What HCPI models look like
from odoo import fields, models
class Outlet(models.Model):
_name = 'hcpi.outlet'
_description = "Outlet"
name = fields.Char(required=True)
def label(self):
return f"{self.name}"
Now you can read this. It says: define a class Outlet that inherits from models.Model (a class Odoo provides). Give it two settings (_name, _description). Declare a field called name. Give it a method called label.
Two extra things specific to Odoo:
name = fields.Char(...)assigns a Field object to a class attribute. Odoo reads these on install and creates database columns from them.selfin an Odoo method is a recordset — a list-like collection of records, not a single object. Soself.nameonly makes sense ifselfcontains exactly one record. You'll learn the recordset patterns in Part 1 of the module tutorial.
That's all the class theory you need to start.
Exercise 11
Create classes.py. Define a class Report with __init__ that takes a name and a date, and a method summary() that returns f"Report {self.name} on {self.date}". Create two instances and print each one's summary.
15. Errors and exceptions
When something goes wrong — a missing key, a type mismatch, a division by zero — Python raises an exception. By default, an exception stops the program.
You can handle an exception with try / except:
You can catch specific types:
Raising your own
You can deliberately raise an exception when something is wrong:
def set_price(p):
if p < 0:
raise ValueError("Price cannot be negative")
return p
set_price(10) # fine
set_price(-3) # raises ValueError
In HCPI you'll see two Odoo-specific exception types:
from odoo.exceptions import UserError, ValidationError
if price < 0:
raise UserError("Price must be non-negative")
UserError and ValidationError show their message to the user as a dialog. You usually raise these, not catch them — let them bubble up to the UI.
16. List comprehensions — the most "Pythonic" thing
A list comprehension is a one-line way to build a list from another collection. It's the single biggest syntax difference from most other languages and it's all over Odoo code, so spend a couple of minutes here.
The long way:
numbers = [1, 2, 3, 4, 5]
doubled = []
for n in numbers:
doubled.append(n * 2)
print(doubled) # [2, 4, 6, 8, 10]
The short way — a list comprehension:
Read it left-to-right: "[take n * 2, for each n in numbers]".
You can also filter:
"[take n, for each n in numbers, if n is even]"
Dict comprehensions
Same idea but for dicts:
numbers = [1, 2, 3, 4]
squared = {n: n * n for n in numbers}
print(squared) # {1: 1, 2: 4, 3: 9, 4: 16}
Where you'll see them in HCPI
All the time:
# Pull all the names out of a recordset
names = [outlet.name for outlet in outlets]
# Map outlets by id
by_id = {outlet.id: outlet for outlet in outlets}
# Keep only active ones
active = [o for o in outlets if o.active]
The first time these look strange. After 20 of them they feel normal.
Exercise 12
Create comprehensions.py:
- Given
nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], build a list of the squares of the even numbers using one comprehension. - Given
names = ["alice", "BOB", "Charlie"], build a list with each name lower-cased.
17. Lambdas — tiny anonymous functions
A lambda is a one-line function with no name. Use it when you need a small function once, on the spot:
square = lambda x: x * x
print(square(5)) # 25
# More commonly, used inline as an argument:
items = [(1, "apple"), (3, "banana"), (2, "cherry")]
items_sorted = sorted(items, key=lambda pair: pair[0])
print(items_sorted) # [(1, 'apple'), (2, 'cherry'), (3, 'banana')]
You'll see lambdas in Odoo for two things:
Field defaults that need to be computed at create time:
That lambda self: is a function Odoo calls each time a new record is created. It runs fields.Date.today() then, not when the file is first loaded.
Filtering recordsets:
18. Decorators — recognising @api.depends
A decorator is a @something line just above a function definition. It modifies how the function behaves. You don't need to write your own decorators yet — but you need to recognise them, because Odoo uses them on every computed field.
@api.depends('name', 'code')
def _compute_full_label(self):
for record in self:
record.full_label = f"{record.code}: {record.name}"
The decorator @api.depends('name', 'code') is Odoo's way of saying "this method computes a field; re-run it whenever name or code changes."
Other Odoo decorators:
| Decorator | Means |
|---|---|
@api.depends(...) |
Re-run this compute when listed fields change. |
@api.constrains(...) |
Validate on save; raise if invalid. |
@api.onchange(...) |
Update the UI as the user types — doesn't save anything. |
@api.model |
This method operates on the model class itself, not on records. |
You'll see all four in the module tutorial.
19. A few idioms HCPI code uses
A grab-bag of patterns that look strange the first time:
Ternary expression (one-line if/else for an assignment):
Same as:
Safe dict lookup with default:
# Instead of:
if "name" in record:
name = record["name"]
else:
name = "unknown"
# Pythonic:
name = record.get("name", "unknown")
with for resources that need cleanup:
with open("file.txt") as f:
content = f.read()
# file is automatically closed when the with-block exits
zip to pair up two lists:
names = ["a", "b", "c"]
prices = [10, 20, 30]
for name, price in zip(names, prices):
print(name, price)
What you should be able to do now
After working through this page you should be able to:
- Create, save, and run a Python file from VS Code's integrated terminal in WSL.
- Use variables, basic types, lists, dicts, tuples, sets.
- Write
if/elif/elseandfor/whileloops. - Define functions with parameters and return values.
- Import code from one file into another.
- Read a small class with
__init__and methods. - Read a list or dict comprehension without translating it line-by-line in your head.
- Spot decorators, lambdas, and f-strings as you go.
If any of these still feels shaky, the cure is practice, not more reading. The exercises take ~30 minutes if you do them in order, and they're cumulative — each builds slightly on the last.
What's next
The next step applies all of this to a real Odoo module — you'll write models, views, security rules, and reports.
➡️ Building HCPI Field Reports — Part 1 — start with models, fields, and the ORM.
Cheat sheet
| You want to… | You write… |
|---|---|
| Show something | print(x) |
| Make a comment | # like this |
| Assign a variable | x = 5 |
| Compare for equality | == (not =) |
| Check missing value | if value is None: |
| Build a string with values inside | f"Hello {name}" |
| Get length | len(x) |
| Sum a list | sum(my_list) |
| Walk a list | for item in items: |
| Counted loop | for i in range(n): |
| Walk a dict | for k, v in my_dict.items(): |
| Define a function | def f(x): then indented body |
| Default argument | def f(x, n=1): |
| Make a class | class Name: |
| Constructor | def __init__(self, ...): |
| Inside a method | always use self.field to access attributes |
| Build a list from another | [x * 2 for x in items] |
| Filter a list | [x for x in items if x > 0] |
| Anonymous function | lambda x: x * 2 |
| Import a file | from helpers import func |
| Catch an error | try: ... except SomeError: ... |
| Raise an error | raise ValueError("bad") |