fix: keep normalize_ctx_input's ctx annotation on Python 3.14

Python 3.14 (PEP 649/749) replaced "__annotations__" with "__annotate__"
in functools.WRAPPER_ASSIGNMENTS. normalize_ctx_input excluded only
"__annotations__" when wrapping, so functools.wraps copied the wrapped
function's __annotate__ and the wrapper's permissive ctx annotation
(_dict | Document | dict | str) was overwritten by the narrow
ItemDetailsCtx | str. Now that Frappe casts whitelisted args via
typing_validations, a dict ctx failed the isinstance-only frappe._dict
check and raised FrappeTypeError. Exclude "__annotate__" too.

Cleanup while here:
- Merge the three identical frappe._dict aliases (ItemDetails,
  ItemDetailsCtx, ItemPriceCtx) into ItemDetailsCtx.
- Drop the now-redundant "| str" from decorated signatures; the
  decorator's wrapper union is what typing_validations enforces.
- Decorate get_batch_based_item_price with normalize_ctx_input instead
  of a manual parse_json, renaming its arg pctx -> ctx (JS caller
  updated) so a dict/string payload is normalized to frappe._dict.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 6eeadbdbef)

# Conflicts:
#	erpnext/stock/get_item_details.py
This commit is contained in:
Mihir Kandoi
2026-07-01 16:07:45 +05:30
committed by Mergify
parent e51df54439
commit eecce23372
3 changed files with 44 additions and 24 deletions

View File

@@ -177,7 +177,9 @@ def normalize_ctx_input(T: type) -> callable:
def decorator(func: callable): def decorator(func: callable):
# conserve annotations for frappe.utils.typing_validations # conserve annotations for frappe.utils.typing_validations
@functools.wraps(func, assigned=(a for a in functools.WRAPPER_ASSIGNMENTS if a != "__annotations__")) @functools.wraps(
func, assigned=(a for a in functools.WRAPPER_ASSIGNMENTS if a not in ("__annotations__", "__annotate__"))
)
def wrapper(ctx: T | Document | dict | str, *args, **kwargs): def wrapper(ctx: T | Document | dict | str, *args, **kwargs):
if isinstance(ctx, Document): if isinstance(ctx, Document):
ctx = T(**ctx.as_dict()) ctx = T(**ctx.as_dict())

View File

@@ -2037,7 +2037,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
frappe.call({ frappe.call({
method: "erpnext.stock.get_item_details.get_batch_based_item_price", method: "erpnext.stock.get_item_details.get_batch_based_item_price",
args: { args: {
pctx: params, ctx: params,
item_code: row.item_code, item_code: row.item_code,
}, },
callback: function (r) { callback: function (r) {

View File

@@ -28,9 +28,7 @@ from erpnext.stock.doctype.item.item import get_item_defaults, get_uom_conv_fact
from erpnext.stock.doctype.item_manufacturer.item_manufacturer import get_item_manufacturer_part_no from erpnext.stock.doctype.item_manufacturer.item_manufacturer import get_item_manufacturer_part_no
from erpnext.stock.doctype.price_list.price_list import get_price_list_details from erpnext.stock.doctype.price_list.price_list import get_price_list_details
ItemDetails = frappe._dict
ItemDetailsCtx = frappe._dict ItemDetailsCtx = frappe._dict
ItemPriceCtx = frappe._dict
sales_doctypes = ["Quotation", "Sales Order", "Delivery Note", "Sales Invoice", "POS Invoice"] sales_doctypes = ["Quotation", "Sales Order", "Delivery Note", "Sales Invoice", "POS Invoice"]
purchase_doctypes = [ purchase_doctypes = [
@@ -123,7 +121,7 @@ def get_item_details(
if doc.get("doctype") == "Purchase Invoice": if doc.get("doctype") == "Purchase Invoice":
ctx.bill_date = doc.get("bill_date") ctx.bill_date = doc.get("bill_date")
out: ItemDetails = get_basic_details(ctx, item, overwrite_warehouse) out: ItemDetailsCtx = get_basic_details(ctx, item, overwrite_warehouse)
get_item_tax_template(ctx, item, out) get_item_tax_template(ctx, item, out)
out.item_tax_rate = get_item_tax_map( out.item_tax_rate = get_item_tax_map(
@@ -200,14 +198,19 @@ def get_item_details(
return out return out
def remove_standard_fields(out: ItemDetails): def remove_standard_fields(out: ItemDetailsCtx):
for key in child_table_fields + default_fields: for key in child_table_fields + default_fields:
out.pop(key, None) out.pop(key, None)
return out return out
<<<<<<< HEAD
def get_rate_locked_source_row(ctx: ItemDetailsCtx, doc) -> frappe._dict | None: def get_rate_locked_source_row(ctx: ItemDetailsCtx, doc) -> frappe._dict | None:
"""Return the persisted source-document row a mapped target row is locked to. """Return the persisted source-document row a mapped target row is locked to.
=======
def set_valuation_rate(out: ItemDetailsCtx, ctx: ItemDetailsCtx):
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
>>>>>>> 6eeadbdbef (fix: keep normalize_ctx_input's ctx annotation on Python 3.14)
The rate is read from the linked source row in the database (not the mutable The rate is read from the linked source row in the database (not the mutable
target row), so a re-fetch always restores the source pricing the maintain-same- target row), so a re-fetch always restores the source pricing the maintain-same-
@@ -388,7 +391,7 @@ def get_filtered_serial_nos(serial_nos, doc, table=None):
return serial_nos return serial_nos
def update_bin_details(ctx: ItemDetailsCtx, out: ItemDetails, doc): def update_bin_details(ctx: ItemDetailsCtx, out: ItemDetailsCtx, doc):
if ctx.doctype == "Material Request" and ctx.material_request_type == "Material Transfer": if ctx.doctype == "Material Request" and ctx.material_request_type == "Material Transfer":
out.update(get_bin_details(ctx.item_code, ctx.from_warehouse)) out.update(get_bin_details(ctx.item_code, ctx.from_warehouse))
@@ -436,7 +439,7 @@ def validate_item_details(ctx: ItemDetailsCtx, item):
throw(_("Item {0} must be a Non-Stock Item").format(item.name)) throw(_("Item {0} must be a Non-Stock Item").format(item.name))
def get_basic_details(ctx: ItemDetailsCtx, item, overwrite_warehouse=True) -> ItemDetails: def get_basic_details(ctx: ItemDetailsCtx, item, overwrite_warehouse=True) -> ItemDetailsCtx:
""" """
:param ctx: { :param ctx: {
"item_code": "", "item_code": "",
@@ -541,7 +544,7 @@ def get_basic_details(ctx: ItemDetailsCtx, item, overwrite_warehouse=True) -> It
if ctx.batch_no and item.name != frappe.get_cached_value("Batch", ctx.batch_no, "item"): if ctx.batch_no and item.name != frappe.get_cached_value("Batch", ctx.batch_no, "item"):
ctx.batch_no = "" ctx.batch_no = ""
out = ItemDetails( out = ItemDetailsCtx(
{ {
"item_code": item.name, "item_code": item.name,
"item_name": item.item_name, "item_name": item.item_name,
@@ -770,7 +773,7 @@ def get_item_tax_info(doc, tax_category, item_codes, item_rates=None, item_tax_t
if not item_code or item_code[1] in out or not item_tax_templates.get(item_code[1]): if not item_code or item_code[1] in out or not item_tax_templates.get(item_code[1]):
continue continue
out[item_code[1]] = ItemDetails() out[item_code[1]] = ItemDetailsCtx()
item = frappe.get_cached_doc("Item", item_code[0]) item = frappe.get_cached_doc("Item", item_code[0])
ctx: ItemDetailsCtx = { ctx: ItemDetailsCtx = {
"company": doc.company, "company": doc.company,
@@ -793,7 +796,13 @@ def get_item_tax_info(doc, tax_category, item_codes, item_rates=None, item_tax_t
@frappe.whitelist() @frappe.whitelist()
@erpnext.normalize_ctx_input(ItemDetailsCtx) @erpnext.normalize_ctx_input(ItemDetailsCtx)
<<<<<<< HEAD
def get_item_tax_template(ctx, item=None, out: ItemDetails | None = None): def get_item_tax_template(ctx, item=None, out: ItemDetails | None = None):
=======
def get_item_tax_template(
ctx: ItemDetailsCtx, item: Document | None = None, out: ItemDetailsCtx | None = None
):
>>>>>>> 6eeadbdbef (fix: keep normalize_ctx_input's ctx annotation on Python 3.14)
""" """
Determines item_tax template from item or parent item groups. Determines item_tax template from item or parent item groups.
@@ -844,7 +853,7 @@ def _get_item_tax_template_from_item_group(ctx, item_group, out=None):
@erpnext.normalize_ctx_input(ItemDetailsCtx) @erpnext.normalize_ctx_input(ItemDetailsCtx)
def _get_item_tax_template( def _get_item_tax_template(
ctx: ItemDetailsCtx, taxes, out: ItemDetails | None = None, for_validate=False ctx: ItemDetailsCtx, taxes, out: ItemDetailsCtx | None = None, for_validate=False
) -> None | str | list[str]: ) -> None | str | list[str]:
""" """
Accesses: Accesses:
@@ -861,7 +870,7 @@ def _get_item_tax_template(
} }
""" """
if out is None: if out is None:
out = ItemDetails() out = ItemDetailsCtx()
taxes_with_validity = [] taxes_with_validity = []
taxes_with_no_validity = [] taxes_with_no_validity = []
@@ -1106,9 +1115,9 @@ def get_default_supplier(_ctx: ItemDetailsCtx, item, item_group, brand):
return item.get("default_supplier") or item_group.get("default_supplier") or brand.get("default_supplier") return item.get("default_supplier") or item_group.get("default_supplier") or brand.get("default_supplier")
def get_price_list_rate(ctx: ItemDetailsCtx, item_doc, out: ItemDetails = None): def get_price_list_rate(ctx: ItemDetailsCtx, item_doc, out: ItemDetailsCtx = None):
if out is None: if out is None:
out = ItemDetails() out = ItemDetailsCtx()
meta = frappe.get_meta(ctx.parenttype or ctx.doctype) meta = frappe.get_meta(ctx.parenttype or ctx.doctype)
@@ -1261,7 +1270,7 @@ def _get_stock_uom_rate(rate: float, ctx: ItemDetailsCtx):
def get_item_price( def get_item_price(
pctx: ItemPriceCtx | dict, item_code, ignore_party=False, force_batch_no=False pctx: ItemDetailsCtx, item_code, ignore_party=False, force_batch_no=False
) -> list[dict]: ) -> list[dict]:
""" """
Get name, price_list_rate from Item Price based on conditions Get name, price_list_rate from Item Price based on conditions
@@ -1270,7 +1279,6 @@ def get_item_price(
optional fields transaction_date, customer, supplier optional fields transaction_date, customer, supplier
:param item_code: str, Item Doctype field item_code :param item_code: str, Item Doctype field item_code
""" """
pctx: ItemPriceCtx = frappe._dict(pctx)
ip = frappe.qb.DocType("Item Price") ip = frappe.qb.DocType("Item Price")
query = ( query = (
@@ -1316,16 +1324,22 @@ def get_item_price(
@frappe.whitelist() @frappe.whitelist()
<<<<<<< HEAD
def get_batch_based_item_price(pctx: ItemPriceCtx | dict | str, item_code) -> float: def get_batch_based_item_price(pctx: ItemPriceCtx | dict | str, item_code) -> float:
pctx = parse_json(pctx) pctx = parse_json(pctx)
item_price = get_item_price(pctx, item_code, force_batch_no=True) item_price = get_item_price(pctx, item_code, force_batch_no=True)
=======
@erpnext.normalize_ctx_input(ItemDetailsCtx)
def get_batch_based_item_price(ctx: ItemDetailsCtx, item_code: str):
item_price = get_item_price(ctx, item_code, force_batch_no=True)
>>>>>>> 6eeadbdbef (fix: keep normalize_ctx_input's ctx annotation on Python 3.14)
if not item_price: if not item_price:
item_price = get_item_price(pctx, item_code, ignore_party=True, force_batch_no=True) item_price = get_item_price(ctx, item_code, ignore_party=True, force_batch_no=True)
is_free_item = pctx.get("items", [{}])[0].get("is_free_item") is_free_item = ctx.get("items", [{}])[0].get("is_free_item")
if item_price and item_price[0].uom == pctx.uom and not is_free_item: if item_price and item_price[0].uom == ctx.uom and not is_free_item:
return item_price[0].price_list_rate return item_price[0].price_list_rate
return 0.0 return 0.0
@@ -1341,7 +1355,7 @@ def get_price_list_rate_for(ctx: ItemDetailsCtx, item_code):
:param qty: Desired Qty :param qty: Desired Qty
:param transaction_date: Date of the price :param transaction_date: Date of the price
""" """
pctx = ItemPriceCtx( pctx = ItemDetailsCtx(
{ {
"item_code": item_code, "item_code": item_code,
"price_list": ctx.get("price_list"), "price_list": ctx.get("price_list"),
@@ -1443,7 +1457,7 @@ def validate_conversion_rate(ctx: ItemDetailsCtx, meta):
) )
def get_party_item_code(ctx: ItemDetailsCtx, item_doc, out: ItemDetails): def get_party_item_code(ctx: ItemDetailsCtx, item_doc, out: ItemDetailsCtx):
if ctx.transaction_type == "selling" and ctx.customer: if ctx.transaction_type == "selling" and ctx.customer:
out.customer_item_code = None out.customer_item_code = None
@@ -1465,7 +1479,7 @@ def get_party_item_code(ctx: ItemDetailsCtx, item_doc, out: ItemDetails):
out.supplier_part_no = item_supplier[0].supplier_part_no if item_supplier else None out.supplier_part_no = item_supplier[0].supplier_part_no if item_supplier else None
def get_tax_withholding_category(ctx: ItemDetailsCtx, item_doc, out: ItemDetails): def get_tax_withholding_category(ctx: ItemDetailsCtx, item_doc, out: ItemDetailsCtx):
""" """
Get tax withholding category for the item based on the transaction type and party. Get tax withholding category for the item based on the transaction type and party.
""" """
@@ -1641,7 +1655,11 @@ def get_batch_qty(batch_no, warehouse, item_code):
@frappe.whitelist() @frappe.whitelist()
@erpnext.normalize_ctx_input(ItemDetailsCtx) @erpnext.normalize_ctx_input(ItemDetailsCtx)
<<<<<<< HEAD
def apply_price_list(ctx, as_doc=False, doc=None): def apply_price_list(ctx, as_doc=False, doc=None):
=======
def apply_price_list(ctx: ItemDetailsCtx, as_doc: bool = False, doc: Document | str | None = None):
>>>>>>> 6eeadbdbef (fix: keep normalize_ctx_input's ctx annotation on Python 3.14)
"""Apply pricelist on a document-like dict object and return as """Apply pricelist on a document-like dict object and return as
{'parent': dict, 'children': list} {'parent': dict, 'children': list}
@@ -1805,7 +1823,7 @@ def get_valuation_rate(item_code, company, warehouse=None):
return {"valuation_rate": 0.0} return {"valuation_rate": 0.0}
def get_gross_profit(out: ItemDetails): def get_gross_profit(out: ItemDetailsCtx):
if out.valuation_rate: if out.valuation_rate:
out.update({"gross_profit": ((out.base_rate - out.valuation_rate) * out.stock_qty)}) out.update({"gross_profit": ((out.base_rate - out.valuation_rate) * out.stock_qty)})
@@ -1818,7 +1836,7 @@ def get_serial_no(_args, serial_nos=None, sales_order=None):
return serial_nos return serial_nos
def update_party_blanket_order(ctx: ItemDetailsCtx, out: ItemDetails | dict): def update_party_blanket_order(ctx: ItemDetailsCtx, out: ItemDetailsCtx):
if out["against_blanket_order"]: if out["against_blanket_order"]:
blanket_order_details = get_blanket_order_details(ctx) blanket_order_details = get_blanket_order_details(ctx)
if blanket_order_details: if blanket_order_details: