
Frappe Errors Hooks
- 25 installs
- 159 repo stars
- Updated July 8, 2026
- openaec-foundation/frappe_claude_skill_package
Helps with ai & agent building tasks.
About
frappe-errors-hooks is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- frappe-errors-hooks
- AI & Agent Building
- AI-coding skill
Frappe Errors Hooks by the numbers
- 25 all-time installs (skills.sh)
- Ranked #9,764 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/openaec-foundation/frappe_claude_skill_package --skill frappe-errors-hooksAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 25 |
|---|---|
| repo stars | ★ 159 |
| Last updated | July 8, 2026 |
| Repository | openaec-foundation/frappe_claude_skill_package ↗ |
What it does
Helps with ai & agent building tasks.
Files
Frappe Hooks Error Diagnosis & Resolution
Cross-ref: frappe-syntax-hooks (syntax), frappe-impl-hooks (workflows), frappe-errors-controllers (controller errors).
---
Error-to-Fix Mapping Table
| Error / Symptom | Cause | Fix |
|---|---|---|
| Hook not firing at all | Typo in dotted path | Verify module path matches actual file location |
ImportError on bench start | Wrong module path or circular import | Fix import path; break circular dependency |
AttributeError: module has no attribute | Function name typo in hooks.py | Match function name exactly to Python definition |
app_include_js not loading | Path missing assets/ prefix or wrong extension | Use "assets/myapp/js/file.js" format |
| scheduler_events not running | Scheduler disabled or workers down | bench scheduler enable, check bench doctor |
| doc_events handler never called | DocType name misspelled in dict key | Use exact DocType name with spaces: "Sales Invoice" |
permission_query_conditions breaks list view | SQL syntax error or frappe.throw() in handler | Return valid SQL string; NEVER throw |
override_doctype_class import failure | Parent class import path changed between versions | Pin import to correct module path for target version |
extend_doctype_class [v16+] method conflict | Two extensions define same method name | Rename conflicting methods; check hook resolution order |
| Fixtures not loading on install | Wrong dt key or DocType doesn't exist on target | Verify DocType exists before export; check filter syntax |
extend_bootinfo breaks login | Unhandled exception in boot handler | Wrap ALL bootinfo code in try/except |
Wildcard "*" handler breaks all saves | Unhandled exception in wildcard doc_events | ALWAYS wrap wildcard handlers in try/except |
| Hook fires but changes lost | Missing frappe.db.commit() in scheduler | Add explicit commit in scheduler/background tasks |
| Multiple handler chain broken | First handler throws, others never run | Isolate non-critical ops in try/except |
---
Hook Registration Errors
Hook Not Firing: Diagnosis Checklist
IS YOUR HOOK NOT FIRING?
│
├─► Check 1: Is the dotted path correct?
│ hooks.py: "myapp.events.sales.validate"
│ File: myapp/events/sales.py → def validate(doc, method=None):
│ COMMON MISTAKE: "myapp.events.sales_invoice.validate" when file is sales.py
│
├─► Check 2: Is the dict structure correct?
│ doc_events uses NESTED dict: {"Sales Invoice": {"validate": "path"}}
│ scheduler_events uses LIST: {"daily": ["path1", "path2"]}
│ permission_query uses FLAT dict: {"Sales Invoice": "path"}
│
├─► Check 3: Is bench restarted after hooks.py change?
│ ALWAYS run: bench restart (or bench clear-cache for dev)
│
├─► Check 4: Is the DocType name exact?
│ "Sales Invoice" NOT "SalesInvoice" NOT "sales_invoice"
│ Use exact DocType name as shown in Frappe UI
│
└─► Check 5: Is the app installed on the site?
bench --site mysite list-appsCircular Import Errors
# ❌ CAUSES ImportError — circular dependency
# myapp/hooks.py imports from myapp.events
# myapp/events/sales.py imports from myapp.hooks
# ✅ CORRECT — break the cycle
# Move shared constants to myapp/constants.py
# Import from constants in both hooks.py and events/Rule: NEVER import from hooks.py in your event handlers. hooks.py is read by the framework, not imported by your code.
Wrong Dict Structure by Hook Type
# ❌ WRONG — doc_events needs nested dict, not flat
doc_events = {
"Sales Invoice": "myapp.events.validate" # WRONG: string, not dict
}
# ✅ CORRECT
doc_events = {
"Sales Invoice": {
"validate": "myapp.events.sales.validate"
}
}
# ❌ WRONG — scheduler_events daily needs list
scheduler_events = {
"daily": "myapp.tasks.daily_sync" # WRONG: string, not list
}
# ✅ CORRECT
scheduler_events = {
"daily": ["myapp.tasks.daily_sync"]
}
# ❌ WRONG — cron needs nested dict with list values
scheduler_events = {
"cron": ["0 9 * * *", "myapp.tasks.morning"] # WRONG structure
}
# ✅ CORRECT
scheduler_events = {
"cron": {
"0 9 * * 1-5": ["myapp.tasks.morning_report"]
}
}---
app_include_js / app_include_css Errors
# ❌ WRONG — missing assets/ prefix
app_include_js = "js/myapp.js"
# ❌ WRONG — using Python module path instead of file path
app_include_js = "myapp.public.js.myapp"
# ✅ CORRECT — full asset path
app_include_js = "assets/myapp/js/myapp.js"
# ✅ CORRECT — multiple files as list
app_include_js = ["assets/myapp/js/app.js", "assets/myapp/js/utils.js"]
app_include_css = "assets/myapp/css/myapp.css"Diagnosis: If JS/CSS not loading, check browser DevTools Network tab for 404. Run bench build after adding new files. ALWAYS verify the file exists at myapp/public/js/myapp.js.
---
scheduler_events Not Running
Diagnosis Steps
# Step 1: Is scheduler enabled?
bench scheduler status
# If disabled: bench scheduler enable
# Step 2: Are workers running?
bench doctor
# Look for: "Workers online: X"
# If 0: bench start (dev) or supervisorctl restart all (prod)
# Step 3: Check Scheduled Job Log
# In Frappe UI: /api/method/frappe.client.get_list?doctype=Scheduled Job Log&limit=5
# Step 4: Check Error Log for task failures
# In Frappe UI: /app/error-log
# Step 5: Is the task registered?
bench execute frappe.utils.scheduler.get_all_tasksCommon Scheduler Failures
# ❌ PROBLEM: Task runs but changes not persisted
def daily_sync():
for item in frappe.get_all("Item", limit=100):
frappe.db.set_value("Item", item.name, "synced", 1)
# MISSING: frappe.db.commit() — ALL changes lost!
# ✅ FIX: ALWAYS commit in scheduler tasks
def daily_sync():
for item in frappe.get_all("Item", limit=100):
frappe.db.set_value("Item", item.name, "synced", 1)
frappe.db.commit()
# ❌ PROBLEM: Task fails silently — no debugging possible
def daily_task():
try:
process_records()
except Exception:
pass # Silent death
# ✅ FIX: ALWAYS log errors in scheduler
def daily_task():
try:
process_records()
frappe.db.commit()
except Exception:
frappe.log_error(frappe.get_traceback(), "Daily Task Error")---
doc_events Errors
Error Handling by Event Phase
| Event | Throw Effect | Transaction | Pattern |
|---|---|---|---|
validate | Prevents save, full rollback | Pre-write | Collect errors, throw once |
before_save | Prevents save, full rollback | Pre-write | Same as validate |
on_update | Doc already saved, error shown | Post-write | Isolate non-critical ops |
after_insert | Doc already saved, error shown | Post-write | Isolate non-critical ops |
on_submit | Doc already submitted | Post-write | Isolate non-critical ops |
on_cancel | Doc already cancelled | Post-write | Isolate non-critical ops |
Multiple Handler Chain Problem
# If App A and App B both register validate for Sales Invoice:
# App A's handler throws → App B's handler NEVER runs
# ✅ ALWAYS be aware: your handler is not alone
def validate(doc, method=None):
"""Collect errors, throw once at end."""
errors = []
if doc.grand_total < 0:
errors.append(_("Total cannot be negative"))
if errors:
frappe.throw("<br>".join(errors))
# ✅ For on_update: isolate independent operations
def on_update(doc, method=None):
try:
send_notification(doc)
except Exception:
frappe.log_error(frappe.get_traceback(), f"Notify error: {doc.name}")
try:
sync_external(doc)
except Exception:
frappe.log_error(frappe.get_traceback(), f"Sync error: {doc.name}")NEVER Commit in doc_events
# ❌ BREAKS transaction management
def on_update(doc, method=None):
frappe.db.set_value("Counter", "main", "count", 100)
frappe.db.commit() # Partial commit — dangerous!
# ✅ Framework handles commits automatically
def on_update(doc, method=None):
frappe.db.set_value("Counter", "main", "count", 100)---
Permission Hook Errors
permission_query_conditions: NEVER Throw
# ❌ BREAKS list view entirely
def query_conditions(user):
if "Sales User" not in frappe.get_roles(user):
frappe.throw("Access denied") # LIST VIEW CRASHES
return f"owner = '{user}'" # Also: SQL injection!
# ✅ CORRECT — safe fallback, escaped values
def query_conditions(user):
try:
user = user or frappe.session.user
if "System Manager" in frappe.get_roles(user):
return ""
return f"`tabSales Invoice`.owner = {frappe.db.escape(user)}"
except Exception:
frappe.log_error(frappe.get_traceback(), "Query Conditions Error")
return f"`tabSales Invoice`.owner = {frappe.db.escape(frappe.session.user)}"Note: permission_query_conditions only affects frappe.db.get_list(), NOT frappe.db.get_all().
has_permission: NEVER Throw
# ❌ BREAKS document access
def has_permission(doc, user=None, permission_type=None):
if doc.status == "Locked":
frappe.throw("Locked") # DOCUMENT INACCESSIBLE
# ✅ Return False to deny, None to defer
def has_permission(doc, user=None, permission_type=None):
try:
user = user or frappe.session.user
if doc.status == "Locked" and permission_type == "write":
return False
return None # Defer to default permission system
except Exception:
frappe.log_error(frappe.get_traceback(), "Permission Error")
return None---
Override & Extend Errors
override_doctype_class: Import Failures
# ❌ COMMON: Import path changes between ERPNext versions
# v14 path:
override_doctype_class = {
"Sales Invoice": "myapp.overrides.CustomSI"
}
# myapp/overrides.py:
from erpnext.accounts.doctype.sales_invoice.sales_invoice import SalesInvoice
# This path may change in v15/v16!
# ✅ ALWAYS call super(), re-raise validation errors
class CustomSalesInvoice(SalesInvoice):
def validate(self):
try:
super().validate()
except frappe.ValidationError:
raise # ALWAYS re-raise validation errors
except Exception:
frappe.log_error(frappe.get_traceback(), "Parent validate error")
raise
self.custom_validation()Warning: Only ONE app's override_doctype_class is active per DocType ("last writer wins"). Use extend_doctype_class [v16+] for multi-app compatibility.
extend_doctype_class [v16+]: Conflicts
# hooks.py
extend_doctype_class = {
"Sales Invoice": ["myapp.extensions.si.SalesInvoiceMixin"]
}
# ❌ CONFLICT: Two extensions define same method
# App A: class Mixin: def custom_calc(self): ...
# App B: class Mixin: def custom_calc(self): ...
# Result: Last app's method wins silently
# ✅ ALWAYS prefix method names with app name
class SalesInvoiceMixin:
def myapp_custom_calc(self):
"""Prefixed to avoid conflicts with other extensions."""
pass---
extend_bootinfo Errors
# ❌ BREAKS LOGIN — unhandled error prevents desk from loading
def extend_boot(bootinfo):
settings = frappe.get_single("My Settings") # DoesNotExistError!
bootinfo.config = settings.config
# ✅ ALWAYS wrap in try/except with safe defaults
def extend_boot(bootinfo):
bootinfo.myapp_config = {}
try:
if frappe.db.exists("My Settings", "My Settings"):
settings = frappe.get_single("My Settings")
bootinfo.myapp_config = {"feature": settings.feature or False}
except Exception:
frappe.log_error(frappe.get_traceback(), "Bootinfo Error")---
Fixtures Not Loading
# ❌ WRONG — dt key misspelled
fixtures = [{"doctype": "Custom Field", "filters": [...]}] # "doctype" not "dt"!
# ✅ CORRECT — use "dt" key
fixtures = [{"dt": "Custom Field", "filters": [["module", "=", "My App"]]}]
# ❌ PROBLEM: DocType doesn't exist on target site
fixtures = [{"dt": "My Custom DocType"}] # If not created yet → install fails
# ✅ FIX: Ensure DocType is created before fixtures are imported
# Order: DocType JSON → fixtures JSON (install order matters)Export command: bench --site mysite export-fixtures Import: Automatic during bench --site mysite install-app myapp
---
Critical Rules
ALWAYS
1. Restart bench after changing hooks.py 2. Use try/except in scheduler tasks — no user sees errors 3. Call frappe.db.commit() in scheduler — no auto-commit 4. Return safe fallbacks in permission hooks — NEVER throw 5. Call super() in override classes — re-raise ValidationError 6. Wrap extend_bootinfo in try/except — errors break login 7. Wrap wildcard "*" doc_events in try/except — errors break ALL saves 8. Prefix extend_doctype_class [v16+] methods with app name
NEVER
1. Throw in permission_query_conditions — breaks list views 2. Throw in has_permission — breaks document access 3. Commit in doc_events — breaks transaction management 4. Import from hooks.py in event handlers — causes circular imports 5. Assume single handler — multiple apps register doc_events 6. Use string formatting in permission SQL — SQL injection risk 7. Ignore scheduler errors — they fail completely silently
---
Quick Reference: Error Handling by Hook Type
| Hook Type | Can Throw? | Commit? | Error Strategy |
|---|---|---|---|
| doc_events (validate) | YES | NEVER | Collect errors, throw once |
| doc_events (on_update+) | Careful | NEVER | Isolate non-critical ops |
| scheduler_events | Pointless | ALWAYS | try/except + log_error |
| permission_query_conditions | NEVER | NEVER | Return "" or owner filter |
| has_permission | NEVER | NEVER | Return None on error |
| extend_bootinfo | NEVER | NEVER | try/except + safe defaults |
| override_doctype_class | YES | NEVER | super() + re-raise |
| extend_doctype_class [v16+] | YES | NEVER | Prefix methods, avoid conflicts |
| fixtures | N/A | N/A | Verify dt key and DocType existence |
| app_include_js/css | N/A | N/A | Check assets/ prefix, run bench build |
---
Reference Files
| File | Contents |
|---|---|
references/patterns.md | Complete error handling patterns by hook type |
references/examples.md | Full working examples with error handling |
references/anti-patterns.md | Common mistakes with wrong/correct pairs |
---
See Also
frappe-syntax-hooks— Hook syntax and dict structuresfrappe-impl-hooks— Implementation workflowsfrappe-errors-controllers— Controller error handlingfrappe-errors-database— Database error handlingfrappe-errors-serverscripts— Server Script error handling
Anti-Patterns — Hooks Error Handling
Common mistakes to avoid when handling errors in Frappe/ERPNext hooks.py.
---
1. Typo in Hook Dotted Path
Problem
# hooks.py — function name doesn't match
doc_events = {
"Sales Invoice": {
"validate": "myapp.events.sales.validate_invoice"
}
}
# But actual function is named validate_si()Fix
# ALWAYS verify: module path + function name match the actual file
# myapp/events/sales.py must contain:
def validate_invoice(doc, method=None):
passWhy: Mismatched dotted paths cause silent failures or ImportError on bench restart.
---
2. Wrong Dict Structure for Hook Type
Problem
# doc_events needs nested dict — string value is WRONG
doc_events = {
"Sales Invoice": "myapp.events.validate"
}
# scheduler_events daily needs list — string is WRONG
scheduler_events = {
"daily": "myapp.tasks.daily_sync"
}Fix
doc_events = {
"Sales Invoice": {
"validate": "myapp.events.sales.validate"
}
}
scheduler_events = {
"daily": ["myapp.tasks.daily_sync"]
}Why: Framework expects specific data structures. Wrong structure causes silent failure.
---
3. Throwing in permission_query_conditions
Problem
def query_conditions(user):
if not user:
frappe.throw("User required") # BREAKS LIST VIEW!
return f"owner = '{user}'" # Also: SQL injection!Fix
def query_conditions(user):
try:
user = user or frappe.session.user
if "System Manager" in frappe.get_roles(user):
return ""
return f"owner = {frappe.db.escape(user)}"
except Exception:
frappe.log_error(frappe.get_traceback(), "Query Conditions Error")
return f"owner = {frappe.db.escape(frappe.session.user)}"Why: Throwing in permission_query_conditions breaks list views completely.
---
4. Throwing in has_permission
Problem
def has_permission(doc, user=None, permission_type=None):
if doc.status == "Locked":
frappe.throw("Document is locked") # BREAKS DOCUMENT ACCESSFix
def has_permission(doc, user=None, permission_type=None):
try:
if doc.status == "Locked" and permission_type == "write":
return False
return None
except Exception:
return NoneWhy: Throwing in has_permission breaks document access entirely.
---
5. Missing frappe.db.commit() in Scheduler
Problem
def daily_task():
for item in frappe.get_all("Item", limit=100):
frappe.db.set_value("Item", item.name, "synced", 1)
# ALL CHANGES LOST — no commit!Fix
def daily_task():
for item in frappe.get_all("Item", limit=100):
frappe.db.set_value("Item", item.name, "synced", 1)
frappe.db.commit() # REQUIREDWhy: Scheduler tasks have no auto-commit. Without explicit commit, all changes are lost.
---
6. Silent Error Swallowing in Scheduler
Problem
def daily_sync():
try:
sync_records()
except Exception:
pass # Silent death — impossible to debugFix
def daily_sync():
try:
sync_records()
frappe.db.commit()
except Exception:
frappe.log_error(frappe.get_traceback(), "Daily Sync Error")Why: Scheduler has no user feedback. frappe.log_error() is your ONLY debugging tool.
---
7. Not Calling super() in Override Class
Problem
class CustomSalesInvoice(SalesInvoice):
def validate(self):
self.custom_validation() # Parent validation SKIPPED!Fix
class CustomSalesInvoice(SalesInvoice):
def validate(self):
super().validate() # ALWAYS call parent first
self.custom_validation()Why: Skipping super() bypasses all parent validation, permissions, and business logic.
---
8. Swallowing Parent Errors in Override
Problem
class CustomDoc(OriginalDoc):
def validate(self):
try:
super().validate()
except Exception:
pass # Parent validation errors HIDDEN from user!Fix
class CustomDoc(OriginalDoc):
def validate(self):
try:
super().validate()
except frappe.ValidationError:
raise # ALWAYS re-raise validation errors
except Exception:
frappe.log_error(frappe.get_traceback(), "Parent error")
raiseWhy: Parent validation errors MUST reach the user. Swallowing them causes data corruption.
---
9. Unprotected extend_bootinfo
Problem
def extend_boot(bootinfo):
settings = frappe.get_single("My Settings") # DoesNotExistError!
bootinfo.config = settings.configFix
def extend_boot(bootinfo):
bootinfo.config = {}
try:
if frappe.db.exists("My Settings", "My Settings"):
settings = frappe.get_single("My Settings")
bootinfo.config = settings.config or {}
except Exception:
frappe.log_error(frappe.get_traceback(), "Bootinfo Error")Why: Errors in extend_bootinfo break the entire desk/login page.
---
10. Committing in doc_events
Problem
def on_update(doc, method=None):
frappe.db.set_value("Counter", "main", "count", 100)
frappe.db.commit() # BREAKS TRANSACTIONFix
def on_update(doc, method=None):
frappe.db.set_value("Counter", "main", "count", 100)
# Framework handles commit automaticallyWhy: Manual commits in doc_events break transaction management and cause partial saves.
---
11. Not Isolating Non-Critical Operations
Problem
def on_submit(doc, method=None):
send_notification_email(doc) # If this fails...
sync_to_external_system(doc) # ...this never runs!
update_dashboard(doc)Fix
def on_submit(doc, method=None):
try:
send_notification_email(doc)
except Exception:
frappe.log_error(frappe.get_traceback(), "Email Error")
try:
sync_to_external_system(doc)
except Exception:
frappe.log_error(frappe.get_traceback(), "Sync Error")
try:
update_dashboard(doc)
except Exception:
frappe.log_error(frappe.get_traceback(), "Dashboard Error")Why: Independent operations MUST NOT block each other.
---
12. Breaking Other Apps in Wildcard Handler
Problem
doc_events = {"*": {"on_update": "myapp.audit.log_all"}}
def log_all(doc, method=None):
frappe.get_doc({"doctype": "Audit Log", "doc": doc.name}).insert()
# Error here breaks ALL saves system-wide!Fix
def log_all(doc, method=None):
try:
if doc.doctype in ["Audit Log", "Error Log"]:
return
frappe.get_doc({"doctype": "Audit Log", "doc": doc.name}).insert(ignore_permissions=True)
except Exception:
frappe.log_error(frappe.get_traceback(), f"Audit: {doc.doctype}/{doc.name}")Why: Wildcard handlers run on ALL documents. Unhandled errors break the entire system.
---
13. SQL Injection in Permission Query
Problem
def query_conditions(user):
return f"owner = '{user}'" # SQL INJECTION!Fix
def query_conditions(user):
return f"owner = {frappe.db.escape(user)}"Why: Unescaped user input allows SQL injection attacks.
---
14. No Limit in Scheduler Queries
Problem
def daily_sync():
records = frappe.get_all("Item") # Could return millions!Fix
def daily_sync():
records = frappe.get_all("Item", limit=1000)Why: Unbounded queries cause memory exhaustion and timeouts in scheduler tasks.
---
15. Circular Import from hooks.py
Problem
# myapp/events/sales.py
from myapp.hooks import doc_events # CIRCULAR IMPORT!Fix
# NEVER import from hooks.py in event handlers
# hooks.py is read by the framework, not by your code
# Move shared config to myapp/constants.py insteadWhy: hooks.py is a configuration file read by the framework. Importing from it creates circular dependencies.
---
16. Wrong Fixtures dt Key
Problem
fixtures = [
{"doctype": "Custom Field", "filters": [...]} # "doctype" is WRONG
]Fix
fixtures = [
{"dt": "Custom Field", "filters": [["module", "=", "My App"]]}
]Why: Fixtures use the dt key, not doctype. Wrong key causes silent failure during install.
---
17. extend_doctype_class [v16+] Method Name Collision
Problem
# App A extension:
class InvoiceMixin:
def calculate_tax(self): ...
# App B extension:
class InvoiceMixin:
def calculate_tax(self): ... # SILENTLY OVERRIDES App A!Fix
# App A:
class InvoiceMixin:
def appa_calculate_tax(self): ...
# App B:
class InvoiceMixin:
def appb_calculate_tax(self): ...Why: With extend_doctype_class, the last extension's method wins silently. Prefix to avoid collisions.
---
Quick Checklist: Hook Review
Before deploying hooks:
- [ ] Dotted paths match actual module + function names
- [ ] Dict structure correct for each hook type
- [ ] No
frappe.throw()in permission hooks - [ ]
frappe.db.commit()in scheduler tasks - [ ]
frappe.log_error()for all caught exceptions - [ ]
super()called in override classes with re-raise - [ ]
try/exceptwrapper in extend_bootinfo - [ ] No
frappe.db.commit()in doc_events - [ ] Non-critical operations isolated in try/except
- [ ] Wildcard handlers wrapped in try/except
- [ ] Queries have limits in scheduler
- [ ] User input escaped in SQL (permission hooks)
- [ ] No circular imports from hooks.py
- [ ] Fixtures use
dtkey (notdoctype) - [ ] extend_doctype_class methods prefixed with app name
- [ ] bench restarted after hooks.py changes
Examples — Hooks Error Handling
Complete working examples of error handling in Frappe/ERPNext hooks.py configurations.
---
Example 1: Complete hooks.py with Error-Safe Handlers
# myapp/hooks.py
app_name = "myapp"
app_title = "My App"
app_publisher = "My Company"
# Asset includes — ALWAYS use assets/ prefix
app_include_js = "assets/myapp/js/myapp.js"
app_include_css = "assets/myapp/css/myapp.css"
# Document Events — nested dict with dotted paths
doc_events = {
"*": {
"on_update": "myapp.events.audit.log_change",
"on_trash": "myapp.events.audit.log_delete"
},
"Sales Invoice": {
"validate": "myapp.events.sales_invoice.validate",
"on_submit": "myapp.events.sales_invoice.on_submit",
"on_cancel": "myapp.events.sales_invoice.on_cancel"
}
}
# Scheduler Events — list values, cron uses nested dict
scheduler_events = {
"daily": [
"myapp.tasks.daily_cleanup"
],
"daily_long": [
"myapp.tasks.sync_inventory"
],
"cron": {
"0 9 * * 1-5": ["myapp.tasks.weekday_morning_report"]
}
}
# Permission Hooks — flat dict, doctype → dotted path
permission_query_conditions = {
"Sales Invoice": "myapp.permissions.si_query_conditions"
}
has_permission = {
"Sales Invoice": "myapp.permissions.si_has_permission"
}
# Boot Extension — single dotted path
extend_bootinfo = "myapp.boot.extend_boot"
# Override — one app per DocType (last writer wins)
override_doctype_class = {
"Sales Invoice": "myapp.overrides.sales_invoice.CustomSalesInvoice"
}
# Extend [v16+] — multiple extensions, list of dotted paths
# extend_doctype_class = {
# "Sales Invoice": ["myapp.extensions.si.SalesInvoiceMixin"]
# }
# Fixtures — use "dt" key, NOT "doctype"
fixtures = [
{"dt": "Custom Field", "filters": [["module", "=", "My App"]]},
{"dt": "Property Setter", "filters": [["module", "=", "My App"]]}
]---
Example 2: Sales Invoice Event Handlers
# myapp/events/sales_invoice.py
import frappe
from frappe import _
def validate(doc, method=None):
"""
Validate handler — errors prevent save.
Runs AFTER controller validate.
"""
errors = []
warnings = []
# Custom field validation
if doc.custom_requires_approval:
if not doc.custom_approver:
errors.append(_("Approver required when approval is enabled"))
elif not frappe.db.exists("User", doc.custom_approver):
errors.append(_("Approver '{0}' not found").format(doc.custom_approver))
# External validation (wrapped — non-blocking)
try:
validate_with_external_system(doc)
except Exception:
frappe.log_error(frappe.get_traceback(), f"External validation: {doc.name}")
warnings.append(_("External validation unavailable"))
if warnings:
frappe.msgprint("<br>".join(warnings), title=_("Warnings"), indicator="orange")
if errors:
frappe.throw("<br>".join(errors), title=_("Validation Error"))
def on_submit(doc, method=None):
"""Post-submit — document already submitted. Isolate operations."""
# Critical operation
try:
create_custom_gl_entries(doc)
except Exception:
frappe.log_error(frappe.get_traceback(), f"GL entries: {doc.name}")
frappe.throw(_("Accounting entries failed. Contact support."))
# Non-critical — queue for reliability
frappe.enqueue(
"myapp.tasks.sync_invoice",
invoice=doc.name,
queue="short",
job_id=f"sync_invoice_{doc.name}"
)
def on_cancel(doc, method=None):
"""Cancel handler — reverse all operations, continue on partial failure."""
cleanup_errors = []
try:
reverse_custom_gl_entries(doc)
except Exception as e:
cleanup_errors.append(f"GL reversal: {str(e)}")
frappe.log_error(frappe.get_traceback(), f"GL reversal: {doc.name}")
try:
cancel_external_sync(doc)
except Exception as e:
cleanup_errors.append(f"Sync cancel: {str(e)}")
frappe.log_error(frappe.get_traceback(), f"Sync cancel: {doc.name}")
if cleanup_errors:
frappe.msgprint(
_("Cancelled with errors:<br>{0}").format("<br>".join(cleanup_errors)),
indicator="orange"
)
# Helper stubs
def validate_with_external_system(doc): pass
def create_custom_gl_entries(doc): pass
def reverse_custom_gl_entries(doc): pass
def cancel_external_sync(doc): pass---
Example 3: Scheduler Task with Full Error Tracking
# myapp/tasks.py
import frappe
from frappe.utils import now_datetime, add_days, today
def daily_cleanup():
"""
hooks.py: scheduler_events = {"daily": ["myapp.tasks.daily_cleanup"]}
"""
results = {"deleted_logs": 0, "deleted_files": 0, "errors": []}
# Task 1: Clean old error logs
try:
cutoff = add_days(today(), -30)
count = frappe.db.count("Error Log", {"creation": ["<", cutoff]})
if count > 0:
frappe.db.delete("Error Log", {"creation": ["<", cutoff]})
results["deleted_logs"] = count
frappe.db.commit()
except Exception as e:
results["errors"].append(f"Error logs: {str(e)}")
frappe.log_error(frappe.get_traceback(), "Cleanup: Error Log")
frappe.db.rollback()
# Task 2: Clean orphan temp files
try:
temp_files = frappe.get_all(
"File",
filters={
"is_private": 1,
"attached_to_doctype": "",
"creation": ["<", add_days(today(), -7)]
},
limit=500
)
for f in temp_files:
try:
frappe.delete_doc("File", f.name, ignore_permissions=True)
results["deleted_files"] += 1
except Exception:
pass # Individual file errors are non-critical
frappe.db.commit()
except Exception as e:
results["errors"].append(f"Temp files: {str(e)}")
frappe.log_error(frappe.get_traceback(), "Cleanup: Temp Files")
# Log summary if errors occurred
if results["errors"]:
frappe.log_error(frappe.as_json(results), "Daily Cleanup — With Errors")
def weekday_morning_report():
"""
hooks.py: scheduler_events = {"cron": {"0 9 * * 1-5": ["myapp.tasks.weekday_morning_report"]}}
"""
try:
recipients = get_report_recipients()
if not recipients:
frappe.log_error("No recipients configured", "Morning Report")
return
report_data = compile_morning_report()
for recipient in recipients:
try:
send_report_email(recipient, report_data)
except Exception:
frappe.log_error(frappe.get_traceback(), f"Report email: {recipient}")
frappe.db.commit()
except Exception:
frappe.log_error(frappe.get_traceback(), "Morning Report Fatal Error")
def get_report_recipients(): return []
def compile_morning_report(): return {}
def send_report_email(r, d): pass---
Example 4: Permission Hooks (Full Implementation)
# myapp/permissions.py
import frappe
def si_query_conditions(user):
"""Sales Invoice list filter. NEVER throw."""
try:
if not user:
user = frappe.session.user
roles = frappe.get_roles(user)
if "System Manager" in roles or "Accounts Manager" in roles:
return ""
if "Sales Manager" in roles:
return get_team_condition(user, "Sales Invoice")
return f"`tabSales Invoice`.owner = {frappe.db.escape(user)}"
except Exception:
frappe.log_error(frappe.get_traceback(), "SI Query Error")
return f"`tabSales Invoice`.owner = {frappe.db.escape(frappe.session.user)}"
def si_has_permission(doc, user=None, permission_type=None):
"""Sales Invoice document-level permission. NEVER throw."""
try:
user = user or frappe.session.user
roles = frappe.get_roles(user)
if "System Manager" in roles:
return None
if doc.docstatus == 2 and permission_type != "read":
return False
if doc.grand_total and doc.grand_total > 100000:
if permission_type == "submit" and "Invoice Approver" not in roles:
return False
return None
except Exception:
frappe.log_error(frappe.get_traceback(), f"SI Permission: {getattr(doc, 'name', '?')}")
return None
def get_team_condition(user, doctype):
"""Build team-based SQL condition. Returns owner filter on error."""
try:
dept = frappe.db.get_value("User", user, "department")
if not dept:
return f"`tab{doctype}`.owner = {frappe.db.escape(user)}"
team = frappe.get_all("User", filters={"department": dept, "enabled": 1}, pluck="name") or [user]
escaped = ", ".join([frappe.db.escape(u) for u in team])
return f"`tab{doctype}`.owner IN ({escaped})"
except Exception:
return f"`tab{doctype}`.owner = {frappe.db.escape(user)}"---
Example 5: Boot Extension (Error-Safe)
# myapp/boot.py
import frappe
def extend_boot(bootinfo):
"""NEVER let errors break page load."""
bootinfo.myapp = {"settings": {}, "user_config": {}, "feature_flags": {}}
try:
if frappe.db.exists("My App Settings", "My App Settings"):
s = frappe.get_single("My App Settings")
bootinfo.myapp["settings"] = {
"default_view": s.default_view or "list",
"items_per_page": s.items_per_page or 20
}
except Exception:
frappe.log_error(frappe.get_traceback(), "Bootinfo: Settings")
try:
user = frappe.session.user
if user != "Guest":
bootinfo.myapp["user_config"] = {
"can_approve": "Approver" in frappe.get_roles(user),
"can_export": frappe.has_permission("Sales Invoice", "export", user=user)
}
except Exception:
frappe.log_error(frappe.get_traceback(), "Bootinfo: User Config")---
Example 6: Hook Registration Debugging
# Run in bench console to diagnose hook issues:
# bench --site mysite console
import frappe
# Check if a specific hook is registered
print(frappe.get_hooks("doc_events"))
# Check scheduler tasks
print(frappe.get_hooks("scheduler_events"))
# Verify a dotted path resolves
try:
module_path = "myapp.events.sales_invoice"
func_name = "validate"
module = frappe.get_module(module_path)
func = getattr(module, func_name)
print(f"Found: {func}")
except ImportError as e:
print(f"Module not found: {e}")
except AttributeError:
print(f"Function '{func_name}' not in module")
# Check installed apps
print(frappe.get_installed_apps())
# Check scheduler status
from frappe.utils.scheduler import is_scheduler_inactive
print(f"Scheduler inactive: {is_scheduler_inactive()}")---
Quick Reference: Hook Error Patterns
# doc_events validate — collect and throw
def validate(doc, method=None):
errors = []
if not doc.field:
errors.append(_("Field required"))
if errors:
frappe.throw("<br>".join(errors))
# doc_events on_update — isolate operations
def on_update(doc, method=None):
try:
non_critical_operation()
except Exception:
frappe.log_error(frappe.get_traceback(), "Error")
# scheduler — always try/except and commit
def scheduled_task():
try:
do_work()
frappe.db.commit()
except Exception:
frappe.log_error(frappe.get_traceback(), "Task Error")
# permission_query_conditions — never throw
def query_conditions(user):
try:
return build_condition(user)
except Exception:
return f"owner = {frappe.db.escape(frappe.session.user)}"
# has_permission — never throw
def has_permission(doc, user=None, permission_type=None):
try:
return check_permission(doc, user)
except Exception:
return None
# extend_bootinfo — never throw
def extend_boot(bootinfo):
bootinfo.data = {}
try:
bootinfo.data = load_data()
except Exception:
passError Handling Patterns — Hooks
Complete error handling patterns for Frappe/ERPNext hooks.py configurations.
---
Pattern 1: Hook Registration Diagnosis
# Verify a hook is registered and callable
import frappe
def diagnose_hook(hook_type, doctype=None):
"""Diagnose why a hook is not firing."""
hooks = frappe.get_hooks(hook_type)
if not hooks:
print(f"No hooks registered for '{hook_type}'")
return
if isinstance(hooks, dict) and doctype:
hooks = hooks.get(doctype, [])
if not hooks:
print(f"No hooks for '{hook_type}' on DocType '{doctype}'")
# Check for typo — list all registered DocTypes
all_doctypes = list(frappe.get_hooks(hook_type).keys())
print(f"Registered DocTypes: {all_doctypes}")
return
for hook_path in hooks:
try:
module_path, func_name = hook_path.rsplit(".", 1)
module = frappe.get_module(module_path)
func = getattr(module, func_name)
print(f"OK: {hook_path} → {func}")
except ImportError as e:
print(f"IMPORT ERROR: {hook_path} → {e}")
except AttributeError:
print(f"FUNCTION NOT FOUND: {hook_path}")
# Usage:
# diagnose_hook("doc_events", "Sales Invoice")
# diagnose_hook("scheduler_events")---
Pattern 2: doc_events Multi-Operation Handler
# myapp/events/sales_invoice.py
import frappe
from frappe import _
def on_submit(doc, method=None):
"""
Post-submit handler with isolated operations.
Document is already submitted — errors show message but do not roll back.
"""
errors = []
# Operation 1: Update linked quotation (critical)
try:
if doc.quotation:
frappe.db.set_value("Quotation", doc.quotation, "status", "Ordered")
except Exception as e:
errors.append(f"Quotation update: {str(e)}")
frappe.log_error(frappe.get_traceback(), "Quotation Update Error")
# Operation 2: Send notification (non-critical)
try:
send_invoice_notification(doc)
except Exception:
frappe.log_error(frappe.get_traceback(), f"Notification failed: {doc.name}")
# Operation 3: External sync (non-critical, queue for reliability)
try:
frappe.enqueue(
"myapp.tasks.sync_invoice",
invoice=doc.name,
queue="short",
job_id=f"sync_{doc.name}"
)
except Exception:
frappe.log_error(frappe.get_traceback(), f"Enqueue failed: {doc.name}")
if errors:
frappe.msgprint(
_("Submitted with errors:<br>{0}").format("<br>".join(errors)),
title=_("Warning"),
indicator="orange"
)---
Pattern 3: Scheduler Task with Batch Commits
# myapp/tasks.py
import frappe
from frappe.utils import now_datetime
def sync_inventory():
"""
hooks.py: scheduler_events = {"daily_long": ["myapp.tasks.sync_inventory"]}
ALWAYS: try/except, log errors, commit explicitly, limit queries.
"""
results = {"processed": 0, "failed": 0, "errors": []}
try:
items = frappe.get_all(
"Item",
filters={"sync_enabled": 1},
fields=["name", "item_code"],
limit=1000 # ALWAYS limit
)
BATCH_SIZE = 100
for i in range(0, len(items), BATCH_SIZE):
batch = items[i:i + BATCH_SIZE]
for item in batch:
try:
sync_single_item(item)
results["processed"] += 1
except frappe.ValidationError as e:
results["failed"] += 1
results["errors"].append({"item": item.name, "error": str(e)[:200]})
except Exception:
results["failed"] += 1
frappe.log_error(frappe.get_traceback(), f"Sync error: {item.name}")
frappe.db.commit() # Commit after each batch
except Exception:
frappe.log_error(frappe.get_traceback(), "Inventory Sync Fatal Error")
return
if results["errors"]:
frappe.log_error(
frappe.as_json(results),
f"Sync Summary: {results['processed']} ok, {results['failed']} failed"
)---
Pattern 4: Permission Query with Safe Fallback
# myapp/permissions.py
import frappe
def sales_invoice_query_conditions(user):
"""
hooks.py: permission_query_conditions = {
"Sales Invoice": "myapp.permissions.sales_invoice_query_conditions"
}
NEVER throw. ALWAYS return valid SQL or empty string.
NOTE: Only affects frappe.db.get_list(), NOT get_all().
"""
try:
if not user:
user = frappe.session.user
roles = frappe.get_roles(user)
if "System Manager" in roles:
return "" # No restrictions
if "Accounts Manager" in roles:
return "`tabSales Invoice`.docstatus < 2"
if "Sales Manager" in roles:
team_users = get_team_members(user)
if team_users:
escaped = ", ".join([frappe.db.escape(u) for u in team_users])
return f"`tabSales Invoice`.owner IN ({escaped})"
return f"`tabSales Invoice`.owner = {frappe.db.escape(user)}"
except Exception:
frappe.log_error(frappe.get_traceback(), f"Query conditions error: {user}")
return f"`tabSales Invoice`.owner = {frappe.db.escape(frappe.session.user)}"
def get_team_members(user):
"""Get team members — returns empty list on error."""
try:
dept = frappe.db.get_value("User", user, "department")
if not dept:
return [user]
return frappe.get_all("User", filters={"department": dept, "enabled": 1}, pluck="name") or [user]
except Exception:
return [user]---
Pattern 5: has_permission with Graceful Degradation
def project_has_permission(doc, user=None, permission_type=None):
"""
hooks.py: has_permission = {"Project": "myapp.permissions.project_has_permission"}
NEVER throw. Return False to deny, None to defer to default.
"""
try:
user = user or frappe.session.user
if "System Manager" in frappe.get_roles(user):
return None # Defer to default (allow)
if doc.status == "Archived" and permission_type in ["write", "delete"]:
return False
if doc.get("is_confidential"):
members = frappe.get_all("Project User", filters={"parent": doc.name}, pluck="user") or []
if user not in members and doc.owner != user:
return False
return None # Defer to default
except Exception:
frappe.log_error(
frappe.get_traceback(),
f"Permission error: {doc.name if hasattr(doc, 'name') else 'unknown'}"
)
return None---
Pattern 6: Override Class with Parent Error Handling
# myapp/overrides/sales_invoice.py
from erpnext.accounts.doctype.sales_invoice.sales_invoice import SalesInvoice
import frappe
from frappe import _
class CustomSalesInvoice(SalesInvoice):
"""
hooks.py: override_doctype_class = {
"Sales Invoice": "myapp.overrides.sales_invoice.CustomSalesInvoice"
}
"""
def validate(self):
# ALWAYS call parent — re-raise ValidationError
try:
super().validate()
except frappe.ValidationError:
raise
except Exception:
frappe.log_error(frappe.get_traceback(), f"Parent validate: {self.name}")
raise
self.validate_custom_fields()
def validate_custom_fields(self):
if self.custom_requires_po and not self.po_no:
frappe.throw(_("PO Number required for this customer"))
def on_submit(self):
try:
super().on_submit()
except Exception:
raise # Parent on_submit errors are ALWAYS critical
# Non-critical custom logic
try:
self.create_custom_entries()
except Exception:
frappe.log_error(frappe.get_traceback(), f"Custom entries: {self.name}")
frappe.msgprint(_("Custom entries will be created later."), indicator="orange")---
Pattern 7: extend_bootinfo with Safe Loading
# myapp/boot.py
import frappe
def extend_boot(bootinfo):
"""
hooks.py: extend_bootinfo = "myapp.boot.extend_boot"
NEVER let errors break login. ALWAYS wrap in try/except.
"""
bootinfo.myapp = {"settings": {}, "permissions": {}}
try:
if frappe.db.exists("My App Settings", "My App Settings"):
settings = frappe.get_single("My App Settings")
bootinfo.myapp["settings"] = {
"feature_enabled": settings.feature_enabled or False,
"max_items": settings.max_items or 100
}
except Exception:
frappe.log_error(frappe.get_traceback(), "Bootinfo: settings load error")
try:
user = frappe.session.user
if user and user != "Guest":
bootinfo.myapp["permissions"] = {
"can_approve": "Approver" in frappe.get_roles(user)
}
except Exception:
frappe.log_error(frappe.get_traceback(), "Bootinfo: permissions load error")---
Pattern 8: Wildcard doc_events (NEVER Break Saves)
def log_all_changes(doc, method=None):
"""
hooks.py: doc_events = {"*": {"on_update": "myapp.audit.log_all_changes"}}
CRITICAL: Errors here break ALL document saves system-wide.
"""
skip_types = ["Error Log", "Activity Log", "Communication", "Version", "Audit Log"]
if doc.doctype in skip_types:
return
try:
frappe.get_doc({
"doctype": "Audit Log",
"reference_doctype": doc.doctype,
"reference_name": doc.name,
"action": method,
"user": frappe.session.user
}).insert(ignore_permissions=True)
except Exception:
frappe.log_error(frappe.get_traceback(), f"Audit: {doc.doctype}/{doc.name}")---
Pattern 9: extend_doctype_class [v16+] Safe Extension
# myapp/extensions/sales_invoice.py
import frappe
from frappe import _
class SalesInvoiceMixin:
"""
hooks.py: extend_doctype_class = {
"Sales Invoice": ["myapp.extensions.sales_invoice.SalesInvoiceMixin"]
}
ALWAYS prefix methods with app name to avoid conflicts.
"""
def myapp_check_approval(self):
"""Prefixed method — no conflict with other extensions."""
try:
if not self.custom_approver:
frappe.throw(_("Approver not set"))
if not frappe.db.exists("User", self.custom_approver):
frappe.throw(_("Approver not found"))
except frappe.DoesNotExistError:
frappe.throw(_("Approver user does not exist"))---
Quick Reference: Hook Error Handling
| Hook | Error Strategy | Fallback |
|---|---|---|
| doc_events (validate) | Collect, throw once | N/A |
| doc_events (on_update+) | Isolate, log non-critical | Continue |
| scheduler_events | Try/except all, commit batches | Log summary |
| permission_query_conditions | Never throw | Return owner filter |
| has_permission | Never throw | Return None |
| extend_bootinfo | Never throw | Return defaults |
| override class | super() in try/except | Re-raise |
| extend class [v16+] | Prefix methods | Avoid conflicts |
| wildcard (*) events | Never break saves | Log and continue |