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):
# 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):
if isinstance(ctx, Document):
ctx = T(**ctx.as_dict())

View File

@@ -2037,7 +2037,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
frappe.call({
method: "erpnext.stock.get_item_details.get_batch_based_item_price",
args: {
pctx: params,
ctx: params,
item_code: row.item_code,
},
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.price_list.price_list import get_price_list_details
ItemDetails = frappe._dict
ItemDetailsCtx = frappe._dict
ItemPriceCtx = frappe._dict
sales_doctypes = ["Quotation", "Sales Order", "Delivery Note", "Sales Invoice", "POS Invoice"]
purchase_doctypes = [
@@ -123,7 +121,7 @@ def get_item_details(
if doc.get("doctype") == "Purchase Invoice":
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)
out.item_tax_rate = get_item_tax_map(
@@ -200,14 +198,19 @@ def get_item_details(
return out
def remove_standard_fields(out: ItemDetails):
def remove_standard_fields(out: ItemDetailsCtx):
for key in child_table_fields + default_fields:
out.pop(key, None)
return out
<<<<<<< HEAD
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.
=======
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
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
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":
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))
def get_basic_details(ctx: ItemDetailsCtx, item, overwrite_warehouse=True) -> ItemDetails:
def get_basic_details(ctx: ItemDetailsCtx, item, overwrite_warehouse=True) -> ItemDetailsCtx:
"""
:param ctx: {
"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"):
ctx.batch_no = ""
out = ItemDetails(
out = ItemDetailsCtx(
{
"item_code": 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]):
continue
out[item_code[1]] = ItemDetails()
out[item_code[1]] = ItemDetailsCtx()
item = frappe.get_cached_doc("Item", item_code[0])
ctx: ItemDetailsCtx = {
"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()
@erpnext.normalize_ctx_input(ItemDetailsCtx)
<<<<<<< HEAD
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.
@@ -844,7 +853,7 @@ def _get_item_tax_template_from_item_group(ctx, item_group, out=None):
@erpnext.normalize_ctx_input(ItemDetailsCtx)
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]:
"""
Accesses:
@@ -861,7 +870,7 @@ def _get_item_tax_template(
}
"""
if out is None:
out = ItemDetails()
out = ItemDetailsCtx()
taxes_with_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")
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:
out = ItemDetails()
out = ItemDetailsCtx()
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(
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]:
"""
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
:param item_code: str, Item Doctype field item_code
"""
pctx: ItemPriceCtx = frappe._dict(pctx)
ip = frappe.qb.DocType("Item Price")
query = (
@@ -1316,16 +1324,22 @@ def get_item_price(
@frappe.whitelist()
<<<<<<< HEAD
def get_batch_based_item_price(pctx: ItemPriceCtx | dict | str, item_code) -> float:
pctx = parse_json(pctx)
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:
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 0.0
@@ -1341,7 +1355,7 @@ def get_price_list_rate_for(ctx: ItemDetailsCtx, item_code):
:param qty: Desired Qty
:param transaction_date: Date of the price
"""
pctx = ItemPriceCtx(
pctx = ItemDetailsCtx(
{
"item_code": item_code,
"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:
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
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.
"""
@@ -1641,7 +1655,11 @@ def get_batch_qty(batch_no, warehouse, item_code):
@frappe.whitelist()
@erpnext.normalize_ctx_input(ItemDetailsCtx)
<<<<<<< HEAD
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
{'parent': dict, 'children': list}
@@ -1805,7 +1823,7 @@ def get_valuation_rate(item_code, company, warehouse=None):
return {"valuation_rate": 0.0}
def get_gross_profit(out: ItemDetails):
def get_gross_profit(out: ItemDetailsCtx):
if out.valuation_rate:
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
def update_party_blanket_order(ctx: ItemDetailsCtx, out: ItemDetails | dict):
def update_party_blanket_order(ctx: ItemDetailsCtx, out: ItemDetailsCtx):
if out["against_blanket_order"]:
blanket_order_details = get_blanket_order_details(ctx)
if blanket_order_details: