diff --git a/frappe/database/database.py b/frappe/database/database.py index d6ecf0795d..9fab8e116f 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -14,7 +14,7 @@ import frappe.model.meta from frappe import _ from time import time -from frappe.utils import now, getdate, cast_fieldtype, get_datetime, get_table_name +from frappe.utils import now, getdate, cast, get_datetime, get_table_name from frappe.model.utils.link_count import flush_local_link_count @@ -516,7 +516,6 @@ class Database(object): FROM `tabSingles` WHERE doctype = %s """, doctype) - # result = _cast_result(doctype, result) dict_ = frappe._dict(result) @@ -557,7 +556,7 @@ class Database(object): if not df: frappe.throw(_('Invalid field name: {0}').format(frappe.bold(fieldname)), self.InvalidColumnName) - val = cast_fieldtype(df.fieldtype, val) + val = cast(df.fieldtype, val) self.value_cache[doctype][fieldname] = val @@ -1052,19 +1051,3 @@ def enqueue_jobs_after_commit(): q.enqueue_call(execute_job, timeout=job.get("timeout"), kwargs=job.get("queue_args")) frappe.flags.enqueue_after_commit = [] - -# Helpers -def _cast_result(doctype, result): - batch = [ ] - - try: - for field, value in result: - df = frappe.get_meta(doctype).get_field(field) - if df: - value = cast_fieldtype(df.fieldtype, value) - - batch.append(tuple([field, value])) - except frappe.exceptions.DoesNotExistError: - return result - - return tuple(batch) diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index 5603b2daae..5a204caf70 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -969,7 +969,7 @@ class BaseDocument(object): return self.cast(val, df) def cast(self, value, df): - return cast_fieldtype(df.fieldtype, value) + return cast_fieldtype(df.fieldtype, value, show_warning=False) def _extract_images_from_text_editor(self): from frappe.core.doctype.file.file import extract_images_from_doc diff --git a/frappe/model/meta.py b/frappe/model/meta.py index de794ba77f..f89163e092 100644 --- a/frappe/model/meta.py +++ b/frappe/model/meta.py @@ -16,7 +16,7 @@ Example: ''' from datetime import datetime import frappe, json, os -from frappe.utils import cstr, cint, cast_fieldtype +from frappe.utils import cstr, cint, cast from frappe.model import default_fields, no_value_fields, optional_fields, data_fieldtypes, table_fields from frappe.model.document import Document from frappe.model.base_document import BaseDocument @@ -322,24 +322,24 @@ class Meta(Document): for ps in property_setters: if ps.doctype_or_field=='DocType': - self.set(ps.property, cast_fieldtype(ps.property_type, ps.value)) + self.set(ps.property, cast(ps.property_type, ps.value)) elif ps.doctype_or_field=='DocField': for d in self.fields: if d.fieldname == ps.field_name: - d.set(ps.property, cast_fieldtype(ps.property_type, ps.value)) + d.set(ps.property, cast(ps.property_type, ps.value)) break elif ps.doctype_or_field=='DocType Link': for d in self.links: if d.name == ps.row_name: - d.set(ps.property, cast_fieldtype(ps.property_type, ps.value)) + d.set(ps.property, cast(ps.property_type, ps.value)) break elif ps.doctype_or_field=='DocType Action': for d in self.actions: if d.name == ps.row_name: - d.set(ps.property, cast_fieldtype(ps.property_type, ps.value)) + d.set(ps.property, cast(ps.property_type, ps.value)) break def add_custom_links_and_actions(self): @@ -532,7 +532,7 @@ class Meta(Document): label = link.group, items = [link.parent_doctype or link.link_doctype] )) - + if not link.is_child_table: if link.link_fieldname != data.fieldname: if data.fieldname: diff --git a/frappe/tests/test_utils.py b/frappe/tests/test_utils.py index 95ba763482..3033673224 100644 --- a/frappe/tests/test_utils.py +++ b/frappe/tests/test_utils.py @@ -6,12 +6,13 @@ import frappe from frappe.utils import evaluate_filters, money_in_words, scrub_urls, get_url from frappe.utils import validate_url, validate_email_address from frappe.utils import ceil, floor -from frappe.utils.data import validate_python_code +from frappe.utils.data import cast, validate_python_code from PIL import Image from frappe.utils.image import strip_exif_data, optimize_image import io from mimetypes import guess_type +from datetime import datetime, timedelta, date class TestFilters(unittest.TestCase): def test_simple_dict(self): @@ -93,6 +94,45 @@ class TestDataManipulation(unittest.TestCase): self.assertTrue('style="background-image: url(\'{0}/assets/frappe/bg.jpg\') !important"'.format(url) in html) self.assertTrue('email' in html) +class TestFieldCasting(unittest.TestCase): + def test_str_types(self): + STR_TYPES = ( + "Data", "Text", "Small Text", "Long Text", "Text Editor", "Select", "Link", "Dynamic Link" + ) + for fieldtype in STR_TYPES: + self.assertIsInstance(cast(fieldtype, value=None), str) + self.assertIsInstance(cast(fieldtype, value="12-12-2021"), str) + self.assertIsInstance(cast(fieldtype, value=""), str) + self.assertIsInstance(cast(fieldtype, value=[]), str) + self.assertIsInstance(cast(fieldtype, value=set()), str) + + def test_float_types(self): + FLOAT_TYPES = ("Currency", "Float", "Percent") + for fieldtype in FLOAT_TYPES: + self.assertIsInstance(cast(fieldtype, value=None), float) + self.assertIsInstance(cast(fieldtype, value=1.12), float) + self.assertIsInstance(cast(fieldtype, value=112), float) + + def test_int_types(self): + INT_TYPES = ("Int", "Check") + + for fieldtype in INT_TYPES: + self.assertIsInstance(cast(fieldtype, value=None), int) + self.assertIsInstance(cast(fieldtype, value=1.12), int) + self.assertIsInstance(cast(fieldtype, value=112), int) + + def test_datetime_types(self): + self.assertIsInstance(cast("Datetime", value=None), datetime) + self.assertIsInstance(cast("Datetime", value="12-2-22"), datetime) + + def test_date_types(self): + self.assertIsInstance(cast("Date", value=None), date) + self.assertIsInstance(cast("Date", value="12-12-2021"), date) + + def test_time_types(self): + self.assertIsInstance(cast("Time", value=None), timedelta) + self.assertIsInstance(cast("Time", value="12:03:34"), timedelta) + class TestMathUtils(unittest.TestCase): def test_floor(self): from decimal import Decimal @@ -205,7 +245,6 @@ class TestImage(unittest.TestCase): self.assertLess(len(optimized_content), len(original_content)) class TestPythonExpressions(unittest.TestCase): - def test_validation_for_good_python_expression(self): valid_expressions = [ "foo == bar", @@ -229,4 +268,4 @@ class TestPythonExpressions(unittest.TestCase): "oops = forgot_equals", ] for expr in invalid_expressions: - self.assertRaises(frappe.ValidationError, validate_python_code, expr) \ No newline at end of file + self.assertRaises(frappe.ValidationError, validate_python_code, expr) diff --git a/frappe/utils/data.py b/frappe/utils/data.py index f2c553211d..5a7328b07e 100644 --- a/frappe/utils/data.py +++ b/frappe/utils/data.py @@ -1,6 +1,7 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # MIT License. See license.txt +from typing import Optional import frappe import operator import json @@ -8,6 +9,7 @@ import re, datetime, math, time from code import compile_command from urllib.parse import quote, urljoin from frappe.desk.utils import slug +from click import secho DATE_FORMAT = "%Y-%m-%d" TIME_FORMAT = "%H:%M:%S.%f" @@ -16,10 +18,10 @@ DATETIME_FORMAT = DATE_FORMAT + " " + TIME_FORMAT def is_invalid_date_string(date_string): # dateutil parser does not agree with dates like "0001-01-01" or "0000-00-00" - return (not date_string) or (date_string or "").startswith(("0001-01-01", "0000-00-00")) + return not isinstance(date_string, str) or ((not date_string) or (date_string or "").startswith(("0001-01-01", "0000-00-00"))) # datetime functions -def getdate(string_date=None): +def getdate(string_date: Optional[str] = None): """ Converts string date (yyyy-mm-dd) to datetime.date object. If no input is provided, current date is returned. @@ -67,6 +69,31 @@ def get_datetime(datetime_str=None): except ValueError: return parser.parse(datetime_str) +def get_timedelta(time: Optional[str] = None) -> Optional[datetime.timedelta]: + """Return `datetime.timedelta` object from string value of a + valid time format. Returns None if `time` is not a valid format + + Args: + time (str): A valid time representation. This string is parsed + using `dateutil.parser.parse`. Examples of valid inputs are: + '0:0:0', '17:21:00', '2012-01-19 17:21:00'. Checkout + https://dateutil.readthedocs.io/en/stable/parser.html#dateutil.parser.parse + + Returns: + datetime.timedelta: Timedelta object equivalent of the passed `time` string + """ + from dateutil import parser + + time = time or "0:0:0" + + try: + t = parser.parse(time) + return datetime.timedelta( + hours=t.hour, minutes=t.minute, seconds=t.second, microseconds=t.microsecond + ) + except Exception: + return None + def to_timedelta(time_str): from dateutil import parser @@ -505,7 +532,14 @@ def has_common(l1, l2): """Returns truthy value if there are common elements in lists l1 and l2""" return set(l1) & set(l2) -def cast_fieldtype(fieldtype, value): +def cast_fieldtype(fieldtype, value, show_warning=True): + if show_warning: + message = ( + "Function `frappe.utils.data.cast` has been deprecated in favour" + " of `frappe.utils.data.cast`. Use the newer util for safer type casting." + ) + secho(message, fg="yellow") + if fieldtype in ("Currency", "Float", "Percent"): value = flt(value) @@ -527,6 +561,46 @@ def cast_fieldtype(fieldtype, value): return value +def cast(fieldtype, value=None): + """Cast the value to the Python native object of the Frappe fieldtype provided. + If value is None, the first/lowest value of the `fieldtype` will be returned. + If value can't be cast as fieldtype due to an invalid input, None will be returned. + + Mapping of Python types => Frappe types: + * str => ("Data", "Text", "Small Text", "Long Text", "Text Editor", "Select", "Link", "Dynamic Link") + * float => ("Currency", "Float", "Percent") + * int => ("Int", "Check") + * datetime.datetime => ("Datetime",) + * datetime.date => ("Date",) + * datetime.time => ("Time",) + """ + if fieldtype in ("Currency", "Float", "Percent"): + value = flt(value) + + elif fieldtype in ("Int", "Check"): + value = cint(value) + + elif fieldtype in ("Data", "Text", "Small Text", "Long Text", + "Text Editor", "Select", "Link", "Dynamic Link"): + value = cstr(value) + + elif fieldtype == "Date": + if value: + value = getdate(value) + else: + value = datetime.datetime(1, 1, 1).date() + + elif fieldtype == "Datetime": + if value: + value = get_datetime(value) + else: + value = datetime.datetime(1, 1, 1) + + elif fieldtype == "Time": + value = get_timedelta(value) + + return value + def flt(s, precision=None): """Convert to float (ignoring commas in string) @@ -1202,7 +1276,7 @@ def evaluate_filters(doc, filters): def compare(val1, condition, val2, fieldtype=None): ret = False if fieldtype: - val2 = cast_fieldtype(fieldtype, val2) + val2 = cast(fieldtype, val2) if condition in operator_map: ret = operator_map[condition](val1, val2) diff --git a/frappe/utils/safe_exec.py b/frappe/utils/safe_exec.py index 2e27859faa..4de685e53e 100644 --- a/frappe/utils/safe_exec.py +++ b/frappe/utils/safe_exec.py @@ -228,6 +228,7 @@ VALID_UTILS = ( "getdate", "get_datetime", "to_timedelta", +"get_timedelta", "add_to_date", "add_days", "add_months",