Les 11 concepts Python qu'il faut maîtriser AVANT de toucher au code Odoo. Refresh ciblé, pas un cours complet.
Vous connaissez déjà Python (Django, Flask, scripts, data). Ce module est un refresh des concepts massivement utilisés en Odoo, pour ne pas être bloqué sur la syntaxe au moment d'aborder l'ORM. Si vous êtes vraiment débutant Python, faites d'abord un MOOC type "Python for Everybody" avant.
Odoo 19 exige Python 3.10 minimum. Profitez-en pour utiliser les nouveautés modernes.
|# AVANT (Python < 3.10)
from typing import Union, Optional
def get_value(x: Union[int, str]) -> Optional[str]: ...
# MAINTENANT (Python ≥ 3.10) — bien plus lisible
def get_value(x: int | str) -> str | None: ...
match/case)def handle_state(state: str):
match state:
case 'draft':
return "Pas encore validé"
case 'available' | 'reserved': # OR explicite
return "En catalogue"
case 'borrowed' if overdue: # avec garde
return "En retard !"
case _:
return "Inconnu"
matchPour rester homogène avec les modules officiels, préférez if/elif classique. Mais c'est bon de connaître match pour ses scripts perso.
Tout modèle Odoo est une classe Python qui hérite de models.Model. Maîtriser l'héritage Python = comprendre Odoo.
class Animal:
def __init__(self, name: str):
self.name = name
def speak(self) -> str:
return "..."
class Dog(Animal): # héritage simple
def speak(self) -> str:
return "Woof"
class RobotDog(Dog): # chaîne d'héritage
def speak(self) -> str:
original = super().speak() # appel parent
return f"[BEEP] {original}"
Odoo utilise massivement le multi-inheritance via _inherit = [...]. Sous le capot, c'est du multi-inheritance Python classique avec son Method Resolution Order.
class MailMixin:
def notify(self):
return "email envoyé"
class Activity:
def notify(self):
return "activité créée"
class Order(MailMixin, Activity):
def confirm(self):
return self.notify()
# Order.__mro__ = [Order, MailMixin, Activity, object]
# → MailMixin.notify() est appelé en premier
@classmethod vs @staticmethodclass Book:
def read(self): # méthode d'instance (self)
return self.title
@classmethod
def from_isbn(cls, isbn): # constructeur alternatif (cls)
return cls(title=fetch_title(isbn))
@staticmethod
def is_valid_isbn(isbn): # utilitaire pur (ni self ni cls)
return len(isbn) in (10, 13)
@api.model remplace @classmethodUne méthode Odoo qui ne dépend pas d'un recordset spécifique se décore avec @api.model (et non @classmethod). Sémantiquement c'est proche, techniquement c'est différent (la méthode reçoit self, mais self est vide).
Un décorateur, c'est juste une fonction qui modifie une autre fonction. Vu comme ça, plus de mystère.
def timing(func):
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
print(f"{func.__name__} a pris {time.time() - start:.2f}s")
return result
return wrapper
@timing
def slow_function():
time.sleep(1)
# @timing est équivalent à : slow_function = timing(slow_function)
En Odoo, les décorateurs sont tous des fonctions de odoo.api :
from odoo import api
@api.depends('price', 'quantity') # déclencheurs recompute
def _compute_total(self): ...
@api.constrains('isbn') # validation côté serveur
def _check_isbn(self): ...
@api.onchange('partner_id') # réaction UI form
def _onchange_partner(self): ...
@api.model_create_multi # override create batch
def create(self, vals_list): ...
Vous verrez ça à chaque ligne dans le code Odoo. À maîtriser absolument.
# List comprehension : pythonique, rapide, lisible
prices = [order.amount_total for order in orders]
big_orders = [o for o in orders if o.amount_total > 1000]
formatted = [f"{o.name}: {o.amount_total}€" for o in orders]
# Dict comprehension : transformer une liste en dict
order_by_id = {order.id: order for order in orders}
prices_per_partner = {
order.partner_id.id: order.amount_total
for order in orders
if order.state == 'done'
}
# Set comprehension : dédupliquer + filtrer
unique_emails = {p.email for p in partners if p.email}
# Generator (paresseux, économise la mémoire)
total = sum(o.amount_total for o in orders if o.state == 'done')
# ← pas de [], donc générateur — pas de liste intermédiaire en mémoire
Si une compréhension dépasse 2 lignes ou imbrique plusieurs for, c'est trop complexe : repassez en boucle for classique. La lisibilité prime sur la concision.
Pas obligatoire mais utilisé partout sur les recordsets Odoo : .filtered(lambda r: ...).
# Lambda = fonction anonyme
square = lambda x: x ** 2
add = lambda a, b: a + b
is_premium = lambda partner: partner.category_id.name == 'Premium'
# En Odoo : presque toujours via .filtered() et .mapped()
active_orders = orders.filtered(lambda o: o.state not in ['cancel', 'draft'])
big_orders = orders.filtered(lambda o: o.amount_total > 1000)
partner_names = orders.mapped(lambda o: o.partner_id.name)
# équivalent court : orders.mapped('partner_id.name')
# Tri custom
sorted_orders = orders.sorted(lambda o: (o.priority, o.date_order))
# Astuce : retourner un tuple = tri multi-critères
try:
risky_operation()
except ValueError as e:
_logger.warning("Valeur invalide: %s", e)
except (KeyError, TypeError) as e: # plusieurs types
_logger.error("Erreur: %s", e)
except Exception as e:
_logger.exception("Erreur inattendue") # log avec traceback
raise # relancer
else:
_logger.info("Succès") # si pas d'exception
finally:
cleanup() # toujours exécuté
from odoo.exceptions import UserError, ValidationError, AccessError, MissingError
if book.state == 'archived':
raise UserError(_("Ce livre est archivé."))
# UserError = erreur métier, message à l'utilisateur (rouge dans UI)
# ValidationError = échec de validation @api.constrains
# AccessError = pas le droit (sécurité)
# MissingError = record n'existe pas / supprimé
except: passVous masquez des bugs critiques. Toujours capturer une exception spécifique et au minimum la logger. Si vous ne savez pas quoi capturer : utilisez Exception + _logger.exception() + raise.
L'un des sujets les plus piégeux. Odoo a son propre wrapper pour gérer les timezones proprement.
from datetime import date, datetime, timedelta, timezone
from dateutil.relativedelta import relativedelta
from odoo import fields
# Date du jour
today = date.today() # date Python pure
today = fields.Date.today() # équivalent Odoo (préférable)
# Datetime — TOUJOURS en UTC en base Odoo
now = datetime.now(timezone.utc)
now = fields.Datetime.now() # équivalent Odoo (préférable)
# Manipulation
tomorrow = today + timedelta(days=1)
in_3_months = today + relativedelta(months=3) # gère bien les mois
last_year = today + relativedelta(years=-1)
# Parsing depuis une string Odoo (format ISO)
d = fields.Date.from_string('2026-05-19')
dt = fields.Datetime.from_string('2026-05-19 14:30:00')
# Formatage
formatted = fields.Date.to_string(today) # '2026-05-19'
# Conversion UTC → timezone user
local_dt = fields.Datetime.context_timestamp(self, fields.Datetime.now())
date(2026, 5, 19) < datetime(2026, 5, 19, 0, 0) lève une TypeError. Toujours comparer des objets du même type. Préférez fields.Date partout sauf besoin précis d'heure.
name = "Victor"
qty = 3
price = 12.456
# f-string : la méthode moderne (≥ Python 3.6)
msg = f"Client {name} a commandé {qty} articles à {price:.2f} €"
# → "Client Victor a commandé 3 articles à 12.46 €"
# Expressions Python dans la f-string
total = f"Total: {qty * price:.2f} €"
# Spécificateurs de format utiles
f"{n:,}" # 1,234,567 (séparateur milliers)
f"{n:.2%}" # 12.34% (pourcentage)
f"{d:%Y-%m-%d}" # 2026-05-19
f"{text:>20}" # aligné à droite sur 20 colonnes
Utilisez % ou %s avec la fonction _() pour que les traducteurs puissent extraire les chaînes :
raise UserError(_("Marge faible: %.2f %%") % margin)
# ou avec format()
raise UserError(_("Hello {name}").format(name=user.name))
Les f-strings sont OK pour les logs et les chaînes internes non-affichées à l'utilisateur final.
printimport logging
_logger = logging.getLogger(__name__)
_logger.debug("Détail trace, dev seulement")
_logger.info("Action OK, traçable en prod")
_logger.warning("Attention, comportement inhabituel")
_logger.error("Erreur, mais on continue")
_logger.critical("Erreur grave, données potentiellement corrompues")
# Format style %s (ne calcule pas le message si niveau pas activé)
_logger.info("Création de %s livres pour %s", len(books), partner.name)
# ↑ mieux que _logger.info(f"Création de {len(books)}...") car lazy
# Logger une exception (avec traceback complet)
try:
...
except Exception:
_logger.exception("Échec du traitement de %s", record.id)
Optionnels mais fortement encouragés. Le code Odoo officiel les utilise de plus en plus depuis la v17.
from typing import Optional # pré-3.10
def find_book(isbn: str) -> str | None: # 3.10+
if not isbn:
return None
return fetch_title(isbn)
def total_price(orders: list, currency: str = "EUR") -> float:
return sum(o.amount for o in orders)
# Types complexes
from typing import Iterable, Callable
def apply_to_all(items: Iterable[int], func: Callable[[int], int]) -> list[int]:
return [func(x) for x in items]
| Module | Usage Odoo |
|---|---|
collections.defaultdict | Aggregations : compter / grouper sans pré-init |
collections.OrderedDict | Préserver ordre (rarement utile depuis Py3.7) |
base64 | Décoder les Binary fields uploadés |
csv / io.StringIO | Parser des CSV (imports en masse) |
json | Stocker des structures complexes en Text field |
hashlib | Hash de fichiers (déduplication attachments) |
re | Regex pour valider emails, ISBN, formats custom |
dateutil (3rd party) | relativedelta (mois, années arithmétique) |
pytz (3rd party) | Timezones |
from collections import defaultdict
# Compter les commandes par partenaire
counts = defaultdict(int)
for order in orders:
counts[order.partner_id.id] += 1
# Grouper par état
groups = defaultdict(list)
for order in orders:
groups[order.state].append(order)
# JSON stocké en Text field Odoo
import json
order.extra_data = json.dumps({'meta': 'x', 'tags': ['a', 'b']})
data = json.loads(order.extra_data or '{}')
🧪 5 questions rapides
list comprehension et generator expression ?@classmethod vs @staticmethod ?_logger.info("msg %s", x) à _logger.info(f"msg {x}") ?_("...") traduisible ?timedelta(months=1) et relativedelta(months=1) ?Réponses brèves :
[...] = matérialise en mémoire ; Generator (...) = paresseux, calcule à la demande, mémoire constante.classmethod = besoin de cls (héritage, constructeur alternatif) ; staticmethod = utilitaire pur sans état.%s n'est exécuté QUE si le niveau de log est actif (lazy). Avec f-string, le formatage a toujours lieu, même si le log est filtré._("..."). Une f-string contient des expressions Python que l'outil ne peut pas extraire proprement.timedelta(months=...) n'existe pas (impossible : les mois ont des durées variables). relativedelta(months=1) ajoute proprement 1 mois calendaire (29 février → 31 mars selon contexte).