Micro-SaaS — Code Generation
Niche : générateur de contrats pour freelances Généré le : 18/02/2026 à 03:01
"""
Freelance Contract Generator
----------------------------
Ce module permet de générer automatiquement un contrat de freelance à partir
d'un modèle Jinja2. Le résultat peut être exporté en fichier texte (Markdown) ou PDF.
Dépendances externes :
- jinja2
- weasyprint (optionnel, pour l'export PDF)
Installation :
pip install jinja2 weasyprint
"""
from __future__ import annotations
import pathlib
import sys
from dataclasses import dataclass, asdict, field
from datetime import date
from typing import Any, Dict, Mapping
from jinja2 import Environment, FileSystemLoader, Template, TemplateError
# --------------------------------------------------------------------------- #
# Data Model #
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class Party:
"""Informations d'une partie prenante (client ou freelance)."""
name: str
address: str
email: str
phone: str | None = None
def _as_dict(self) -> Dict[str, Any]:
"""Retourne un dict compatible Jinja2 (sans les champs None)."""
return {k: v for k, v in asdict(self).items() if v is not None}
@dataclass(frozen=True)
class ProjectDetails:
"""Détails du projet à insérer dans le contrat."""
title: str
description: str
start_date: date
end_date: date | None = None
hourly_rate: float | None = None
fixed_price: float | None = None
milestones: list[Dict[str, Any]] = field(default_factory=list)
def _as_dict(self) -> Dict[str, Any]:
d = asdict(self)
# Formatage des dates et suppression des valeurs None
d["start_date"] = self.start_date.isoformat()
if self.end_date:
d["end_date"] = self.end_date.isoformat()
d = {k: v for k, v in d.items() if v is not None}
return d
@dataclass(frozen=True)
class ContractData:
"""Enveloppe des données requises pour le rendu du contrat."""
client: Party
freelancer: Party
project: ProjectDetails
contract_date: date = field(default_factory=date.today)
def to_context(self) -> Mapping[str, Any]:
"""Construit le contexte Jinja2 à partir des dataclasses."""
return {
"client": self.client._as_dict(),
"freelancer": self.freelancer._as_dict(),
"project": self.project._as_dict(),
"contract_date": self.contract_date.isoformat(),
}
# --------------------------------------------------------------------------- #
# Contract Generation Logic #
# --------------------------------------------------------------------------- #
class TemplateNotFoundError(FileNotFoundError):
"""Exception levée lorsqu'aucun fichier de modèle n'est trouvé."""
class ContractGenerationError(RuntimeError):
"""Exception générique pour les erreurs de génération du contrat."""
class ContractGenerator:
"""
Générateur de contrat utilisant un modèle Jinja2.
Le modèle doit être fourni sous forme de fichier texte (ex. .md ou .txt).
"""
def __init__(self, template_dir: pathlib.Path | str) -> None:
self.template_dir = pathlib.Path(template_dir).expanduser().resolve()
if not self.template_dir.is_dir():
raise TemplateNotFoundError(f"Le répertoire '{self.template_dir}' n'existe pas.")
self.env = Environment(
loader=FileSystemLoader(self.template_dir),
autoescape=False, # Le contrat est du texte brut, pas du HTML
trim_blocks=True,
lstrip_blocks=True,
)
def load_template(self, template_name: str) -> Template:
"""Charge le template et gère les éventuelles erreurs."""
try:
return self.env.get_template(template_name)
except TemplateError as exc:
raise TemplateNotFoundError(f"Impossible de charger le template '{template_name}': {exc}") from exc
def render(self, contract_data: ContractData, template_name: str) -> str:
"""
Rendu du contrat à partir des données et du template.
Retourne le texte rendu (Markdown ou texte brut).
"""
template = self.load_template(template_name)
context = contract_data.to_context()
try:
return template.render(**context)
except Exception as exc: # pragma: no cover – attrapage générique pour le rendu
raise ContractGenerationError(f"Erreur lors du rendu du contrat: {exc}") from exc
@staticmethod
def save_to_file(content: str, output_path: pathlib.Path | str) -> None:
"""Écrit le contenu dans le fichier indiqué. Crée les dossiers parents si besoin."""
output_path = pathlib.Path(output_path).expanduser().resolve()
try:
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(content, encoding="utf-8")
except OSError as exc:
raise ContractGenerationError(f"Impossible d'écrire le fichier '{output_path}': {exc}") from exc
@staticmethod
def convert_markdown_to_pdf(md_content: str, pdf_path: pathlib.Path | str) -> None:
"""
Convertit le markdown en PDF à l'aide de WeasyPrint.
Nécessite l'installation de la dépendance `weasyprint`.
"""
try:
from weasyprint import HTML # import local pour éviter une dépendance obligatoire
except ImportError as exc:
raise ContractGenerationError(
"WeasyPrint n'est pas installé. Exécutez 'pip install weasyprint' pour exporter en PDF."
) from exc
# Construction d'un HTML simple à partir du Markdown.
# On utilise la fonction `markdown` du module `markdown` si disponible,
# sinon on laisse le Markdown brut (WeasyPrint ne gère pas le markdown nativement).
try:
import markdown
html_body = markdown.markdown(md_content, extensions=["extra", "smarty"])
except ImportError:
# Fallback: le markdown sera traité comme du texte brut par le navigateur PDF.
html_body = f"<pre>{md_content}</pre>"
html_doc = f"""<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>Contrat Freelance</title>
<style>
body {{ font-family: "Helvetica", "Arial", sans-serif; margin: 2cm; }}
h1, h2, h3 {{ color: #2c3e50; }}
</style>
</head>
<body>
{html_body}
</body>
</html>"""
pdf_path = pathlib.Path(pdf_path).expanduser().resolve()
try:
pdf_path.parent.mkdir(parents=True, exist_ok=True)
HTML(string=html_doc).write_pdf(target=str(pdf_path))
except Exception as exc: # pragma: no cover – gestion générique
raise ContractGenerationError(f"Échec de la génération du PDF '{pdf_path}': {exc}") from exc
# --------------------------------------------------------------------------- #
# Example Usage (comment) #
# --------------------------------------------------------------------------- #
"""
# Exemple d'utilisation du générateur de contrat :
from pathlib import Path
from datetime import date
# 1. Définir les parties et le projet
client = Party(
name="Acme Corp.",
address="123 Rue de la République, 75001 Paris, France",
email="contact@acme.example.com",
phone="+33 1 23 45 67 89"
)
freelancer = Party(
name="Jean Dupont",
address="45 Avenue des Champs, 75008 Paris, France",
email="jean.dupont@example.com",
phone="+33 6 12 34 56 78"
)
project = ProjectDetails(
title="Développement d'une application web",
description=(
"Conception et implémentation d'une application web full‑stack "
"utilisant Django et React."
),
start_date=date(2024, 5, 1),
end_date=date(2024, 8, 31),
hourly_rate=65.0,
milestones=[
{"title": "Spécifications fonctionnelles", "due": "2024‑05‑15"},
{"title": "Prototype front‑end", "due": "2024‑06‑30"},
{"title": "Version bêta", "due": "2024‑07‑31"},
{"title": "Livraison finale", "due": "2024‑08‑31"},
],
)
contract_data = ContractData(
client=client,
freelancer=freelancer,
project=project
)
# 2. Instancier le générateur (le répertoire `templates/` doit contenir `contract.md`)
generator = ContractGenerator(template_dir=Path(__file__).parent / "templates")
# 3. Rendre le texte du contrat
try:
markdown_text = generator.render(contract_data, template_name="contract.md")
except (TemplateNotFoundError, ContractGenerationError) as e:
sys.exit(f"Erreur lors de la génération du contrat : {e}")
# 4. Sauvegarder le contrat au format Markdown
output_md_path = Path("output/contrat_freelance.md")
generator.save_to_file(markdown_text, output_md_path)
print(f"Contrat sauvegardé au format Markdown : {output_md_path}")
# 5. (Optionnel) Convertir le Markdown en PDF
output_pdf_path = Path("output/contrat_freelance.pdf")
try:
generator.convert_markdown_to_pdf(markdown_text, output_pdf_path)
print(f"Contrat PDF généré : {output_pdf_path}")
except ContractGenerationError as e:
print(f"Conversion PDF échouée : {e}")
"""
# --------------------------------------------------------------------------- #
# End of Module #
# --------------------------------------------------------------------------- #