Merge pull request #35387 from akhilnarang/dependency-update

build(deps)!: only allow python>=3.14, nodejs>=24
This commit is contained in:
Akhil Narang 2025-12-23 13:52:30 +05:30 committed by GitHub
commit 84d346f683
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 191 additions and 244 deletions

View file

@ -64,3 +64,6 @@ e9bbe03354079cfcef65a77b0c33f57b047a7c93
# another ruff update
6ca4d4d167a1a009d99062747711de7a994aa633
# some more ruff
8723a2b6ee9dbec800077f18202ba53b0ef553e7

View file

@ -4,11 +4,11 @@ inputs:
python-version:
description: 'Python version to use'
required: false
default: '3.12.6'
default: '3.14'
node-version:
description: 'Node.js version to use'
required: false
default: '22'
default: '24'
build-assets:
required: false
description: 'Wether to build assets'
@ -45,12 +45,12 @@ runs:
git config --global advice.detachedHead false
- name: Clone
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
path: apps/${{ github.event.repository.name }}
- name: Setup Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: ${{ inputs.python-version }}
@ -64,14 +64,14 @@ runs:
fi
- name: Checkout Frappe
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
repository: ${{ env.FRAPPE_GH_ORG || github.repository_owner }}/frappe
ref: ${{ github.event.client_payload.frappe_sha || github.base_ref || github.ref_name }}
path: apps/frappe
if: github.event.repository.name != 'frappe'
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: ${{ inputs.node-version }}
check-latest: true

View file

@ -12,11 +12,11 @@ on:
python-version:
required: false
type: string
default: '3.10'
default: '3.14'
node-version:
required: false
type: number
default: 22
default: 24
db-artifact-url:
required: false
type: string
@ -49,6 +49,15 @@ jobs:
disable-socketio: true
disable-web: true
db-root-password: ${{ env.DB_ROOT_PASSWORD }}
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: |
3.11
3.13
${{ inputs.python-version }}
- name: Execute pre-migration tasks
if: inputs.pre
@ -108,7 +117,7 @@ jobs:
fi
echo "Setting up environment..."
if rm -rf ${GITHUB_WORKSPACE}/env && python -m venv ${GITHUB_WORKSPACE}/env; then
if rm -rf ${GITHUB_WORKSPACE}/env && python"$2" -m venv ${GITHUB_WORKSPACE}/env; then
source ${GITHUB_WORKSPACE}/env/bin/activate
pip install --quiet --upgrade pip
pip install --quiet frappe-bench
@ -148,13 +157,13 @@ jobs:
- name: Update to v14
run: |
source $RUNNER_TEMP/migrate
update_to_version 14
update_to_version 14 3.11
exit $?
- name: Update to v15
run: |
source $RUNNER_TEMP/migrate
update_to_version 15
update_to_version 15 3.13
exit $?
- name: Update to last commit

View file

@ -13,7 +13,7 @@ on:
node-version:
required: false
type: number
default: 22
default: 24
parallel-runs:
required: false
type: number

View file

@ -13,7 +13,7 @@ on:
node-version:
required: false
type: number
default: 22
default: 24
parallel-runs:
required: false
type: number

View file

@ -19,7 +19,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
- name: Setup dependencies
run: |
npm install @semantic-release/git @semantic-release/exec --no-save

View file

@ -24,7 +24,7 @@ jobs:
fetch-depth: 200
- uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
check-latest: true
- name: Check commit titles

View file

@ -22,7 +22,7 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
- uses: actions/setup-python@v6
with:

View file

@ -16,7 +16,7 @@ jobs:
path: 'frappe'
- uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
- uses: actions/setup-python@v6
with:
python-version: '3.14'

View file

@ -79,7 +79,7 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
check-latest: true
- name: Add to Hosts

View file

@ -58,8 +58,8 @@ jobs:
uses: ./.github/workflows/_base-migration.yml
with:
db-artifact-url: https://frappeframework.com/files/v13-frappe.sql.gz
python-version: '3.10'
node-version: 22
python-version: '3.14'
node-version: 24
fake-success: ${{ needs.checkrun.outputs.build != 'strawberry' }}
coverage:

View file

@ -6,7 +6,7 @@ fail_fast: false
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
rev: v6.0.0
hooks:
- id: trailing-whitespace
files: "frappe.*"
@ -22,7 +22,7 @@ repos:
exclude: ^frappe/tests/classes/context_managers\.py$
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.13.2
rev: v0.14.10
hooks:
- id: ruff
name: "Run ruff import sorter"
@ -71,7 +71,7 @@ repos:
)$
- repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook
rev: v9.22.0
rev: v9.23.0
hooks:
- id: commitlint
stages: [commit-msg]

View file

@ -73,8 +73,8 @@ if TYPE_CHECKING: # pragma: no cover
controllers: dict[str, type] = {}
lazy_controllers: dict[str, type] = {}
local = Local()
cache: Optional["RedisWrapper"] = None
client_cache: Optional["ClientCache"] = None
cache: "RedisWrapper" | None = None
client_cache: "ClientCache" | None = None
STANDARD_USERS = ("Guest", "Administrator")
# this global may be subsequently changed by frappe.tests.utils.toggle_test_mode()
@ -88,22 +88,20 @@ if _dev_server:
# local-globals
ConfType: TypeAlias = _dict[str, Any] # type: ignore[no-any-explicit]
type ConfType = _dict[str, Any] # type: ignore[no-any-explicit]
# TODO: make session a dataclass instead of undtyped _dict
SessionType: TypeAlias = _dict[str, Any] # type: ignore[no-any-explicit]
type SessionType = _dict[str, Any] # type: ignore[no-any-explicit]
# TODO: implement dataclass
LogMessageType: TypeAlias = _dict[str, Any] # type: ignore[no-any-explicit]
type LogMessageType = _dict[str, Any] # type: ignore[no-any-explicit]
# TODO: implement dataclass
# holds job metadata if the code is run in a background job context
JobMetaType: TypeAlias = _dict[str, Any] # type: ignore[no-any-explicit]
ResponseDict: TypeAlias = _dict[str, Any] # type: ignore[no-any-explicit]
FlagsDict: TypeAlias = _dict[str, Any] # type: ignore[no-any-explicit]
FormDict: TypeAlias = _dict[str, str]
type JobMetaType = _dict[str, Any] # type: ignore[no-any-explicit]
type ResponseDict = _dict[str, Any] # type: ignore[no-any-explicit]
type FlagsDict = _dict[str, Any] # type: ignore[no-any-explicit]
type FormDict = _dict[str, str]
db: LocalProxy[Union["PyMariaDBDatabase", "MariaDBDatabase", "PostgresDatabase", "SQLiteDatabase"]] = local(
"db"
)
qb: LocalProxy[Union["MariaDB", "Postgres", "SQLite"]] = local("qb")
db: LocalProxy["PyMariaDBDatabase" | "MariaDBDatabase" | "PostgresDatabase" | "SQLiteDatabase"] = local("db")
qb: LocalProxy["MariaDB" | "Postgres" | "SQLite"] = local("qb")
conf: LocalProxy[ConfType] = local("conf")
form_dict: LocalProxy[FormDict] = local("form_dict")
form = form_dict
@ -675,7 +673,7 @@ def is_table(doctype: str) -> bool:
def get_precision(
doctype: str, fieldname: str, currency: str | None = None, doc: Optional["Document"] = None
doctype: str, fieldname: str, currency: str | None = None, doc: "Document" | None = None
) -> int:
"""Get precision for a given field"""
from frappe.model.meta import get_field_precision

View file

@ -230,7 +230,7 @@ class Communication(Document, CommunicationEmailMixin):
html_signature = soup.find("div", {"class": "ql-editor read-mode"})
_signature = None
if html_signature:
_signature = html_signature.renderContents()
_signature = html_signature.encode_contents()
if (cstr(_signature) or signature) not in self.content:
self.content = f'{self.content}</p><br><p class="signature">{signature}'

View file

@ -1140,10 +1140,10 @@ def validate_empty_name(dt, autoname):
frappe.toast(_("Warning: Naming is not set"), indicator="yellow")
def validate_autoincrement_autoname(dt: Union[DocType, "CustomizeForm"]) -> bool:
def validate_autoincrement_autoname(dt: DocType | "CustomizeForm") -> bool:
"""Checks if can doctype can change to/from autoincrement autoname"""
def get_autoname_before_save(dt: Union[DocType, "CustomizeForm"]) -> str:
def get_autoname_before_save(dt: DocType | "CustomizeForm") -> str:
if dt.doctype == "Customize Form":
property_value = frappe.db.get_value(
"Property Setter", {"doc_type": dt.doc_type, "property": "autoname"}, "value"

View file

@ -54,7 +54,7 @@ def get_extension(
filename,
extn: str | None = None,
content: bytes | None = None,
response: Optional["Response"] = None,
response: "Response" | None = None,
) -> str:
mimetype = None
@ -426,7 +426,7 @@ def decode_file_content(content: bytes) -> bytes:
return safe_b64decode(content)
def find_file_by_url(path: str, name: str | None = None) -> Optional["File"]:
def find_file_by_url(path: str, name: str | None = None) -> "File" | None:
filters = {"file_url": str(path)}
if name:
filters["name"] = str(name)

View file

@ -43,9 +43,9 @@ def make_perm_log(doc, method=None):
def insert_perm_log(
doc: Document,
doc_before_save: Document = None,
for_doctype: Optional["str"] = None,
for_document: Optional["str"] = None,
fields: Optional["list | tuple"] = None,
for_doctype: "str" | None = None,
for_document: "str" | None = None,
fields: "list | tuple" | None = None,
):
if frappe.flags.in_install or frappe.flags.in_migrate:
# no need to log changes when migrating or installing app/site

View file

@ -178,10 +178,7 @@ class TestRQJob(IntegrationTestCase):
LAST_MEASURED_USAGE += 2
# Observed higher usage on 3.14. Temporarily raising the limit
from sys import version_info
if version_info >= (3, 14):
LAST_MEASURED_USAGE += 5
LAST_MEASURED_USAGE += 5
self.assertLessEqual(rss, LAST_MEASURED_USAGE * 1.05, msg)

View file

@ -109,7 +109,6 @@ def serialize_worker(worker: Worker) -> frappe._dict:
def compute_utilization(worker: Worker) -> float:
with suppress(Exception):
total_time = (
datetime.datetime.now(datetime.timezone.utc)
- worker.birth_date.replace(tzinfo=datetime.timezone.utc)
datetime.datetime.now(datetime.UTC) - worker.birth_date.replace(tzinfo=datetime.UTC)
).total_seconds()
return worker.total_working_time / total_time * 100

View file

@ -12,7 +12,7 @@ if TYPE_CHECKING:
queue_prefix = "insert_queue_for_"
def deferred_insert(doctype: str, records: list[Union[dict, "Document"]] | str):
def deferred_insert(doctype: str, records: list[dict | "Document"] | str):
if isinstance(records, dict | list):
_records = json.dumps(records)
else:
@ -48,7 +48,7 @@ def save_to_db():
frappe.db.commit()
def insert_record(record: Union[dict, "Document"], doctype: str):
def insert_record(record: dict | "Document", doctype: str):
try:
record.update({"doctype": doctype})
frappe.get_doc(record).insert()

View file

@ -335,7 +335,7 @@ def get_events(
start: date, end: date, user: str | None = None, for_reminder: bool = False, filters=None
) -> list[frappe._dict]:
user = user or frappe.session.user
EventLikeDict: TypeAlias = Event | frappe._dict
type EventLikeDict = Event | frappe._dict
resolved_events: list[EventLikeDict] = []
if isinstance(filters, str):

View file

@ -10,7 +10,7 @@ from frappe.utils.data import convert_utc_to_system_timezone
def get_time(path: Path):
return convert_utc_to_system_timezone(
datetime.datetime.fromtimestamp(path.stat().st_mtime, tz=datetime.timezone.utc)
datetime.datetime.fromtimestamp(path.stat().st_mtime, tz=datetime.UTC)
).strftime("%a %b %d %H:%M %Y")

View file

@ -3,9 +3,10 @@
import json
import re
from typing import TypedDict
from typing_extensions import NotRequired # not required in 3.11+
from typing import (
NotRequired, # not required in 3.11+
TypedDict,
)
import frappe

View file

@ -148,7 +148,7 @@ def sendmail(
email_read_tracker_url=None,
x_priority: Literal[1, 3, 5] = 3,
email_headers=None,
) -> Optional["EmailQueue"]:
) -> "EmailQueue" | None:
"""Send email using user's default **Email Account** or global default **Email Account**.

View file

@ -403,7 +403,7 @@ class Email:
if self.mail["Date"]:
try:
utc = email.utils.mktime_tz(email.utils.parsedate_tz(self.mail["Date"]))
utc_dt = datetime.datetime.fromtimestamp(utc, tz=datetime.timezone.utc)
utc_dt = datetime.datetime.fromtimestamp(utc, tz=datetime.UTC)
self.date = convert_utc_to_system_timezone(utc_dt).strftime("%Y-%m-%d %H:%M:%S")
except Exception:
self.date = now()

View file

@ -74,8 +74,8 @@ class TokenCache(Document):
def get_expires_in(self):
system_timezone = ZoneInfo(get_system_timezone())
modified: datetime.datetime = get_datetime(self.modified).replace(tzinfo=system_timezone)
expiry_utc = modified.astimezone(datetime.timezone.utc) + datetime.timedelta(seconds=self.expires_in)
now_utc = datetime.datetime.now(datetime.timezone.utc)
expiry_utc = modified.astimezone(datetime.UTC) + datetime.timedelta(seconds=self.expires_in)
now_utc = datetime.datetime.now(datetime.UTC)
return cint((expiry_utc - now_utc).total_seconds())
def is_expired(self):

View file

@ -9,9 +9,8 @@ from collections.abc import Generator, Iterable
from contextlib import contextmanager
from functools import wraps
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Literal, Optional, TypeAlias, Union, overload
from typing import TYPE_CHECKING, Any, Literal, Optional, Self, TypeAlias, Union, overload, override
from typing_extensions import Self, override
from werkzeug.exceptions import NotFound
import frappe
@ -34,7 +33,7 @@ from frappe.utils.data import get_absolute_url, get_datetime, get_timedelta, get
from frappe.utils.global_search import update_global_search
if TYPE_CHECKING:
from typing_extensions import Self
from typing import Self
from frappe.core.doctype.docfield.docfield import DocField
@ -43,8 +42,8 @@ DOCUMENT_LOCK_EXPIRY = 3 * 60 * 60 # All locks expire in 3 hours automatically
DOCUMENT_LOCK_SOFT_EXPIRY = 30 * 60 # Let users force-unlock after 30 minutes
_SingleDocument: TypeAlias = "Document"
_NewDocument: TypeAlias = "Document"
type _SingleDocument = "Document"
type _NewDocument = "Document"
@overload
@ -614,7 +613,7 @@ class Document(BaseDocument):
for df in self.meta.get_table_fields():
self.update_child_table(df.fieldname, df)
def update_child_table(self, fieldname: str, df: Optional["DocField"] = None):
def update_child_table(self, fieldname: str, df: "DocField" | None = None):
"""sync child table for given fieldname"""
df: DocField = df or self.meta.get_field(fieldname)
if df.is_virtual:
@ -1994,7 +1993,7 @@ def bulk_insert(
def _document_values_generator(
documents: Iterable["Document"],
columns: list[str],
) -> Generator[tuple[Any], None, None]:
) -> Generator[tuple[Any]]:
for doc in documents:
doc.creation = doc.modified = now()
doc.owner = doc.modified_by = frappe.session.user
@ -2140,7 +2139,7 @@ def copy_doc(doc: "Document", ignore_no_copy: bool = True) -> "Document":
def new_doc(
doctype: str,
*,
parent_doc: Optional["Document"] = None,
parent_doc: "Document" | None = None,
parentfield: str | None = None,
as_dict: bool = False,
**kwargs,

View file

@ -200,7 +200,7 @@ def set_new_name(doc):
doc.name = validate_name(doc.doctype, doc.name)
def is_autoincremented(doctype: str, meta: Optional["Meta"] = None) -> bool:
def is_autoincremented(doctype: str, meta: "Meta" | None = None) -> bool:
"""Checks if the doctype has autoincrement autoname set"""
if not meta:
@ -328,7 +328,7 @@ def _generate_random_string(length=10):
def parse_naming_series(
parts: list[str] | str,
doctype=None,
doc: Optional["Document"] = None,
doc: "Document" | None = None,
number_generator: Callable[[str, int], str] | None = None,
) -> str:
"""Parse the naming series and get next name.

View file

@ -1,8 +1,11 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
from __future__ import annotations
import json
from collections import defaultdict
from typing import TYPE_CHECKING, Union
from typing import TYPE_CHECKING
import frappe
from frappe import _
@ -40,7 +43,7 @@ def get_workflow_name(doctype):
@frappe.whitelist()
def get_transitions(
doc: Union["Document", str, dict], workflow: "Workflow" = None, raise_exception: bool = False
doc: Document | str | dict, workflow: Workflow = None, raise_exception: bool = False
) -> list[dict]:
"""Return list of possible transitions for the given doc"""
from frappe.model.document import Document

View file

@ -301,9 +301,7 @@ def get_app_publisher(module: str) -> str:
return frappe.get_hooks(hook="app_publisher", app_name=app)[0]
def make_boilerplate(
template: str, doc: Union["Document", "frappe._dict"], opts: Union[dict, "frappe._dict"] = None
):
def make_boilerplate(template: str, doc: "Document" | "frappe._dict", opts: dict | "frappe._dict" = None):
target_path = get_doc_path(doc.module, doc.doctype, doc.name)
template_name = template.replace("controller", scrub(doc.name))
if template_name.endswith("._py"):

View file

@ -52,7 +52,7 @@ class Monitor:
self.data = frappe._dict(
{
"site": frappe.local.site,
"timestamp": datetime.datetime.now(datetime.timezone.utc),
"timestamp": datetime.datetime.now(datetime.UTC),
"transaction_type": transaction_type,
"uuid": str(uuid.uuid4()),
}
@ -85,7 +85,7 @@ class Monitor:
if job := rq.get_current_job():
self.data.job_id = job.id
waitdiff = self.data.timestamp - job.enqueued_at.replace(tzinfo=datetime.timezone.utc)
waitdiff = self.data.timestamp - job.enqueued_at.replace(tzinfo=datetime.UTC)
self.data.job.wait = int(waitdiff.total_seconds() * 1000000)
def add_custom_data(self, **kwargs):
@ -94,7 +94,7 @@ class Monitor:
def dump(self, response=None):
try:
timediff = datetime.datetime.now(datetime.timezone.utc) - self.data.timestamp
timediff = datetime.datetime.now(datetime.UTC) - self.data.timestamp
# Obtain duration in microseconds
self.data.duration = int(timediff.total_seconds() * 1000000)

View file

@ -1,5 +1,5 @@
import hashlib
from datetime import datetime, timezone
from datetime import UTC, datetime, timezone
import frappe
@ -83,7 +83,7 @@ def get_frappe_version() -> str:
def utc_iso() -> str:
return datetime.now(timezone.utc).isoformat()
return datetime.now(UTC).isoformat()
def get_app_version(app_name: str) -> str:

View file

@ -8,7 +8,7 @@ permission, homepage, default variables, system defaults etc
"""
import json
from datetime import datetime, timezone
from datetime import UTC, datetime, timezone
from urllib.parse import unquote
import redis
@ -370,7 +370,7 @@ class Session:
if self.time_diff > expiry or (
(session_end := session_data.get("session_end"))
and datetime.now(tz=timezone.utc) > datetime.fromisoformat(session_end)
and datetime.now(tz=UTC) > datetime.fromisoformat(session_end)
):
self._delete_session()
data = None

View file

@ -23,10 +23,9 @@ import functools
import inspect
import logging
import pkgutil
import tomllib
import unittest
import tomli
import frappe
import frappe.utils.scheduler
from frappe.tests.utils import make_test_records, toggle_test_mode
@ -91,7 +90,7 @@ def _decorate_all_methods_and_functions_with_type_checker():
def _get_config_from_pyproject(app_path):
try:
with open(f"{app_path}/pyproject.toml", "rb") as f:
config = tomli.load(f)
config = tomllib.load(f)
return (
config.get("tool", {})
.get("frappe", {})
@ -100,7 +99,7 @@ def _decorate_all_methods_and_functions_with_type_checker():
)
except FileNotFoundError:
return {}
except tomli.TOMLDecodeError:
except tomllib.TOMLDecodeError:
logger.warning(f"Failed to parse pyproject.toml for app {app_path}")
return {}

View file

@ -432,7 +432,7 @@ def after_request(*args, **kwargs):
_test_REQ_HOOK["after_request"] = time()
class TestResponse(FrappeAPITestCase):
class TestAPIResponse(FrappeAPITestCase):
def test_generate_pdf(self):
response = self.get(
"/api/method/frappe.utils.print_format.download_pdf",

View file

@ -165,7 +165,7 @@ class TestAuth(IntegrationTestCase):
client = FrappeClient(self.HOST_NAME, self.test_user_email, self.test_user_password)
expiry_time = next(x for x in client.session.cookies if x.name == "sid").expires
current_time = datetime.datetime.now(tz=datetime.timezone.utc).timestamp()
current_time = datetime.datetime.now(tz=datetime.UTC).timestamp()
self.assertAlmostEqual(get_expiry_in_seconds(), expiry_time - current_time, delta=60 * 60)

View file

@ -193,7 +193,7 @@ class TestPerformance(IntegrationTestCase):
"""
query = "select * from tabUser"
expected_refcount = 1 if sys.version_info >= (3, 14) else 2
expected_refcount = 1
for kwargs in ({}, {"as_dict": True}, {"as_list": True}):
result = frappe.db.sql(query, **kwargs)
self.assertEqual(sys.getrefcount(result), expected_refcount) # Note: This always returns +1
@ -201,7 +201,7 @@ class TestPerformance(IntegrationTestCase):
def test_no_cyclic_references(self):
doc = frappe.get_doc("User", "Administrator")
expected_refcount = 1 if sys.version_info >= (3, 14) else 2
expected_refcount = 1
self.assertEqual(sys.getrefcount(doc), expected_refcount) # Note: This always returns +1
def test_get_doc_cache_calls(self):
@ -249,7 +249,7 @@ class TestPerformance(IntegrationTestCase):
default_affinity_16 = list(range(16))
# "linear" siblings = (0,1) (2,3) ...
linear_siblings_16 = list(itertools.batched(range(16), 2))
linear_siblings_16 = list(itertools.batched(range(16), 2, strict=True))
logical_cores = list(range(16))
expected_assignments = [*(l[0] for l in linear_siblings_16), *(l[1] for l in linear_siblings_16)]
for pid, expected_core in zip(logical_cores, expected_assignments, strict=True):

View file

@ -5,7 +5,7 @@ import io
import json
import os
import sys
from datetime import date, datetime, time, timedelta, timezone
from datetime import UTC, date, datetime, time, timedelta, timezone
from decimal import ROUND_HALF_UP, Decimal, localcontext
from enum import Enum
from io import StringIO
@ -961,9 +961,9 @@ class TestResponse(IntegrationTestCase):
minute=23,
second=23,
microsecond=23,
tzinfo=timezone.utc,
tzinfo=UTC,
),
time(hour=23, minute=23, second=23, microsecond=23, tzinfo=timezone.utc),
time(hour=23, minute=23, second=23, microsecond=23, tzinfo=UTC),
timedelta(days=10, hours=12, minutes=120, seconds=10),
],
"float": [

View file

@ -2,6 +2,7 @@ import datetime
import json
import logging
import os
import tomllib
from collections import defaultdict
from collections.abc import Generator
from functools import cache
@ -10,8 +11,6 @@ from pathlib import Path
from types import MappingProxyType, ModuleType
from typing import TYPE_CHECKING, Any
import tomli
import frappe
from frappe.model.naming import revert_series_if_last
from frappe.modules import get_doctype_module, get_module_path, load_doctype_module
@ -129,7 +128,7 @@ def load_test_records_for(index_doctype) -> dict[str, Any]:
toml_path = os.path.join(module_path, "test_records.toml")
if os.path.exists(toml_path):
with open(toml_path, "rb") as f:
return tomli.load(f)
return tomllib.load(f)
else:
return {}
@ -138,9 +137,7 @@ def load_test_records_for(index_doctype) -> dict[str, Any]:
# Test record generation
def _generate_all_records_towards(
index_doctype, reset=False, commit=False
) -> Generator[tuple[str, int], None, None]:
def _generate_all_records_towards(index_doctype, reset=False, commit=False) -> Generator[tuple[str, int]]:
"""Generate test records for the given doctype and its dependencies."""
# NOTE: visited excludes dependency discovery of any index doctype which
@ -156,7 +153,7 @@ def _generate_all_records_towards(
def _generate_records_for(
index_doctype: str, reset: bool = False, commit: bool = False, initial_doctype: str | None = None
) -> Generator[tuple[str, "Document"], None, None]:
) -> Generator[tuple[str, "Document"]]:
"""Create and yield test records for a specific doctype."""
test_module: ModuleType
@ -205,7 +202,7 @@ test_record_manager_instance = None
def _sync_records(
index_doctype: str, test_records: dict[str, list], reset: bool = False, commit: bool = False
) -> Generator[tuple[str, "Document"], None, None]:
) -> Generator[tuple[str, "Document"]]:
"""Generate test objects for a register doctype from provided records, with caching and persistence."""
# NOTE: This method is called in roughly these situations:
# 1. First sync of a index doctype's records

View file

@ -4,10 +4,9 @@ from collections.abc import Generator, Iterable, Mapping, Sequence
from datetime import date, datetime
from itertools import groupby
from operator import attrgetter
from typing import Any, NamedTuple, TypeAlias, TypeGuard, TypeVar, cast
from typing import Any, NamedTuple, Self, TypeAlias, TypeGuard, TypeVar, cast, override
from pypika import Column
from typing_extensions import Self, override
Doct: TypeAlias = str
Fld: TypeAlias = str
@ -36,10 +35,8 @@ class Sentinel:
UNSPECIFIED = Sentinel()
T = TypeVar("T")
def is_unspecified(value: T | Sentinel) -> TypeGuard[Sentinel]:
def is_unspecified[T](value: T | Sentinel) -> TypeGuard[Sentinel]:
return value is UNSPECIFIED
@ -254,7 +251,7 @@ class Filters(list[FilterTuple]):
optimized.extend(filters)
else:
def _values() -> Generator[_Value, None, None]:
def _values() -> Generator[_Value]:
for f in filters:
# f.value is already narrowed to Val when we optimize over fully initialized FilterTuple
yield cast(_Value, f.value) # = operator only is allowed to have _Value
@ -281,4 +278,4 @@ class Filters(list[FilterTuple]):
return f"Filters(\n{filters_str}\n)"
FilterSignature: TypeAlias = Filters | FilterTuple | FilterMappingSpec | FilterTupleSpec
type FilterSignature = Filters | FilterTuple | FilterMappingSpec | FilterTupleSpec

View file

@ -1,12 +1,12 @@
from collections.abc import Iterable, Mapping
from typing import (
TYPE_CHECKING,
Self,
TypeVar,
overload,
override,
)
from typing_extensions import Self, override
_KT = TypeVar("_KT")
_VT = TypeVar("_VT")

View file

@ -49,15 +49,6 @@ UNSET = object()
PropertyType: TypeAlias = property | functools.cached_property
if sys.version_info < (3, 11):
def exception():
_exc_type, exc_value, _exc_traceback = sys.exc_info()
return exc_value
sys.exception = exception
def get_fullname(user=None):
"""get the full name (first name + last name) of the user from User"""
if not user:
@ -925,7 +916,7 @@ def get_safe_filters(filters):
return filters
def create_batch(iterable: Iterable, size: int) -> Generator[Iterable, None, None]:
def create_batch(iterable: Iterable, size: int) -> Generator[Iterable]:
"""Convert an iterable to multiple batches of constant size of batch_size.
Args:
@ -1198,44 +1189,3 @@ def create_folder(path, with_init=False):
cached_property = functools.cached_property
if sys.version_info.minor < 12:
T = TypeVar("T")
class cached_property(functools.cached_property, Generic[T]):
"""
A simpler `functools.cached_property` implementation without locks.
This isn't needed in Python 3.12+, since lock was removed in newer versions.
Hence, in those versions, it returns the `functools.cached_property` object.
This does not prevent a possible race condition in multi-threaded usage.
The getter function could run more than once on the same instance,
with the latest run setting the cached value. If the cached property is
idempotent or otherwise not harmful to run more than once on an instance,
this is fine. If synchronization is needed, implement the necessary locking
inside the decorated getter function or around the cached property access.
"""
def __init__(self, func: Callable[[Any], T]):
self.func = func
self.attrname = None
self.__doc__ = func.__doc__
self.__module__ = func.__module__
def __set_name__(self, owner, name):
if self.attrname is None:
self.attrname = name
elif name != self.attrname:
raise TypeError(
"Cannot assign the same cached_property to two different names "
f"({self.attrname!r} and {name!r})."
)
def __get__(self, instance, owner=None) -> T:
if instance is None:
return self
value = self.func(instance)
instance.__dict__[self.attrname] = value
return value
# end: custom cached_property implementation

View file

@ -451,10 +451,9 @@ def start_worker_pool(
if sbool(os.environ.get("FRAPPE_BACKGROUND_WORKERS_NOFORK", False)):
worker_klass = FrappeWorkerNoFork
else:
if sys.version_info >= (3, 14):
import multiprocessing
import multiprocessing
multiprocessing.set_start_method("fork", force=True)
multiprocessing.set_start_method("fork", force=True)
worker_klass = FrappeWorker
pool = WorkerPool(

View file

@ -341,11 +341,11 @@ authors = [
{{ name = "{app_publisher}", email = "{app_email}"}}
]
description = "{app_description}"
requires-python = ">=3.10"
requires-python = ">=3.14"
readme = "README.md"
dynamic = ["version"]
dependencies = [
# "frappe~=15.0.0" # Installed and managed by bench.
# "frappe~=16.0.0" # Installed and managed by bench.
]
[build-system]
@ -358,7 +358,7 @@ build-backend = "flit_core.buildapi"
[tool.ruff]
line-length = 110
target-version = "py310"
target-version = "py314"
[tool.ruff.lint]
select = [
@ -387,6 +387,8 @@ ignore = [
"UP030", # Use implicit references for positional format fields (translations)
"UP031", # Use format specifiers instead of percent format
"UP032", # Use f-string instead of `format` call (translations)
"UP037", # quoted annotations
"UP040", # Use type aliases instead of type annotations
]
typing-modules = ["frappe.types.DF"]
@ -736,7 +738,7 @@ jobs:
ports:
- 11000:6379
mariadb:
image: mariadb:10.6
image: mariadb:11.8
env:
MYSQL_ROOT_PASSWORD: root
ports:
@ -745,7 +747,7 @@ jobs:
steps:
- name: Clone
uses: actions/checkout@v3
uses: actions/checkout@v6
- name: Find tests
run: |
@ -753,14 +755,14 @@ jobs:
grep -rn "def test" > /dev/null
- name: Setup Python
uses: actions/setup-python@v4
uses: actions/setup-python@v6
with:
python-version: '3.10'
python-version: '3.14'
- name: Setup Node
uses: actions/setup-node@v3
uses: actions/setup-node@v6
with:
node-version: 18
node-version: 24
check-latest: true
- name: Cache pip
@ -793,8 +795,6 @@ jobs:
run: |
pip install frappe-bench
bench init --skip-redis-config-generation --skip-assets --python "$(which python)" ~/frappe-bench
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'"
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'"
- name: Install
working-directory: /home/runner/frappe-bench
@ -831,7 +831,7 @@ fail_fast: false
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
rev: v6.0.0
hooks:
- id: trailing-whitespace
files: "{app_name}.*"
@ -844,7 +844,7 @@ repos:
- id: debug-statements
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.1
rev: v0.14.10
hooks:
- id: ruff
name: "Run ruff import sorter"
@ -915,10 +915,10 @@ jobs:
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: '3.10'
python-version: '3.14'
cache: pip
- uses: pre-commit/action@v3.0.0
@ -935,14 +935,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6
with:
python-version: '3.10'
python-version: '3.14'
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Cache pip
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py') }}

View file

@ -394,7 +394,7 @@ def show_update_popup():
def get_pyproject(app: str) -> dict | None:
from tomli import load
from tomllib import load
pyproject_path = frappe.get_app_path(app, "..", "pyproject.toml")

View file

@ -116,7 +116,7 @@ def is_invalid_date_string(date_string: str) -> bool:
def getdate(
string_date: Optional["DateTimeLikeObject"] = None, parse_day_first: bool = False
string_date: "DateTimeLikeObject" | None = None, parse_day_first: bool = False
) -> datetime.date | None:
"""
Convert string date (yyyy-mm-dd) to datetime.date object.
@ -148,7 +148,7 @@ def getdate(
def get_datetime(
datetime_str: Optional["DateTimeLikeObject"] | tuple | list = None,
datetime_str: "DateTimeLikeObject" | None | tuple | list = None,
) -> datetime.datetime | None:
"""Return the below mentioned values based on the given `datetime_str`:
@ -373,7 +373,7 @@ def now_datetime() -> datetime.datetime:
return datetime.datetime.now(ZoneInfo(get_system_timezone())).replace(tzinfo=None)
def get_timestamp(date: Optional["DateTimeLikeObject"] = None) -> float:
def get_timestamp(date: "DateTimeLikeObject" | None = None) -> float:
"""Return the Unix timestamp (seconds since Epoch) for the given `date`.
If `date` is None, the current timestamp is returned.
"""
@ -402,7 +402,7 @@ def convert_utc_to_timezone(utc_timestamp: datetime.datetime, time_zone: str) ->
def get_datetime_in_timezone(time_zone: str) -> datetime.datetime:
"""Return the current datetime in the given timezone (e.g. 'Asia/Kolkata')."""
utc_timestamp = datetime.datetime.now(datetime.timezone.utc)
utc_timestamp = datetime.datetime.now(datetime.UTC)
return convert_utc_to_timezone(utc_timestamp, time_zone)
@ -2404,7 +2404,7 @@ def to_markdown(html: str) -> str:
pass
def md_to_html(markdown_text: str) -> Optional["UnicodeWithAttrs"]:
def md_to_html(markdown_text: str) -> "UnicodeWithAttrs" | None:
"""Convert the given markdown text to HTML and returns it."""
from markdown2 import MarkdownError
from markdown2 import markdown as _markdown
@ -2424,7 +2424,7 @@ def md_to_html(markdown_text: str) -> Optional["UnicodeWithAttrs"]:
pass
def markdown(markdown_text: str) -> Optional["UnicodeWithAttrs"]:
def markdown(markdown_text: str) -> "UnicodeWithAttrs" | None:
"""Convert the given markdown text to HTML and returns it."""
return md_to_html(markdown_text)

View file

@ -59,7 +59,7 @@ class Local:
return lp
class LocalProxy(WerkzeugLocalProxy, Generic[T]):
class LocalProxy[T](WerkzeugLocalProxy):
__slots__ = ()
def __getattr__(self, name: str) -> Any:

View file

@ -171,7 +171,7 @@ class CDPSocketClient:
event = event[1]
try:
self.loop.run_until_complete(asyncio.wait_for(event, timeout))
except asyncio.TimeoutError:
except TimeoutError:
frappe.log_error(title="Timeout waiting for event", message=f"{frappe.get_traceback()}")
def remove_listener(self, method, event):

View file

@ -84,7 +84,7 @@ def sleep_duration(tick):
# This makes scheduler aligned with real clock,
# so event scheduled at 12:00 happen at 12:00 and not 12:00:35.
minutes = tick // 60
now = datetime.datetime.now(datetime.timezone.utc)
now = datetime.datetime.now(datetime.UTC)
left_minutes = minutes - now.minute % minutes
next_execution = now.replace(second=0) + datetime.timedelta(minutes=left_minutes)

View file

@ -151,7 +151,7 @@ class WebsiteTheme(Document):
return [{"name": app, "title": values["title"]} for app, values in apps.items()]
def get_active_theme() -> Optional["WebsiteTheme"]:
def get_active_theme() -> "WebsiteTheme" | None:
if website_theme := frappe.get_website_settings("website_theme"):
try:
return frappe.client_cache.get_doc("Website Theme", website_theme)

View file

@ -2,10 +2,9 @@ import contextlib
import importlib.metadata
import json
import re
import tomllib
from pathlib import Path
import tomli
import frappe
from frappe import _
from frappe.permissions import is_system_user
@ -134,7 +133,7 @@ def get_pyproject_info(app: str) -> dict:
return {}
with open(pyproject_toml, "rb") as f:
pyproject = tomli.load(f)
pyproject = tomllib.load(f)
return pyproject.get("project", {})

View file

@ -118,7 +118,7 @@ def get_context(context) -> PrintContext:
}
def get_print_format_doc(print_format_name: str, meta: "Meta") -> Optional["PrintFormat"]:
def get_print_format_doc(print_format_name: str, meta: "Meta") -> "PrintFormat" | None:
"""Return print format document."""
if not print_format_name:
print_format_name = frappe.form_dict.format or meta.default_print_format or "Standard"
@ -135,7 +135,7 @@ def get_print_format_doc(print_format_name: str, meta: "Meta") -> Optional["Prin
def get_rendered_template(
doc: "Document",
print_format: Optional["PrintFormat"] = None,
print_format: "PrintFormat" | None = None,
meta: "Meta" = None,
no_letterhead: bool | None = None,
letterhead: str | None = None,
@ -281,7 +281,7 @@ def set_link_titles(doc: "Document") -> None:
def set_title_values_for_link_and_dynamic_link_fields(
meta: "Meta", doc: "Document", parent_doc: Optional["Document"] = None
meta: "Meta", doc: "Document", parent_doc: "Document" | None = None
) -> None:
if parent_doc and not parent_doc.get("__link_titles"):
setattr(parent_doc, "__link_titles", {})
@ -586,7 +586,7 @@ def has_value(df: "DocField", doc: "Document") -> bool:
def get_print_style(
style: str | None = None, print_format: Optional["PrintFormat"] = None, for_legacy: bool = False
style: str | None = None, print_format: "PrintFormat" | None = None, for_legacy: bool = False
) -> str:
print_settings = frappe.get_doc("Print Settings")
@ -618,7 +618,7 @@ def get_print_style(
def get_font(
print_settings: "PrintSettings", print_format: Optional["PrintFormat"] = None, for_legacy=False
print_settings: "PrintSettings", print_format: "PrintFormat" | None = None, for_legacy=False
) -> str:
default = """
"InterVariable", "Inter", -apple-system", "BlinkMacSystemFont",

View file

@ -16,7 +16,7 @@
"url": "https://github.com/frappe/frappe/issues"
},
"engines": {
"node": ">=18"
"node": ">=24"
},
"homepage": "https://frappeframework.com",
"dependencies": {

View file

@ -4,85 +4,83 @@ authors = [
{ name = "Frappe Technologies Pvt Ltd", email = "developers@frappe.io"}
]
description = "Metadata driven, full-stack low code web framework"
requires-python = ">=3.10,<3.15"
requires-python = ">=3.14,<3.15"
readme = "README.md"
dynamic = ["version"]
dependencies = [
# core dependencies
"Babel~=2.16.0",
"Click~=8.2.0",
"Click~=8.3.1",
"filelock~=3.20.1",
"filetype~=1.2.0",
"GitPython~=3.1.44",
"GitPython~=3.1.45",
"Jinja2~=3.1.6",
"Pillow~=11.3.0",
"Pillow~=12.0.0",
"PyJWT~=2.10.1",
# We depend on internal attributes,
# do NOT add loose requirements on PyMySQL versions.
"PyMySQL==1.1.1",
"pypdf==6.4.0",
"PyMySQL==1.1.2",
"pypdf==6.5.0",
"PyPika @ git+https://github.com/frappe/pypika@2c50e6142b2d61d2d243e466fdd5dc03b3d918f2",
"mysqlclient==2.2.7",
"PyQRCode~=1.2.1",
"PyYAML~=6.0.2",
"PyYAML~=6.0.3",
"RestrictedPython~=8.1",
"WeasyPrint==66.0",
"pydyf==0.11.0",
"pydyf==0.12.1",
"Werkzeug==3.1.4",
"Whoosh~=2.7.4",
"beautifulsoup4~=4.13.4",
"beautifulsoup4~=4.13.5",
"bleach-allowlist~=1.0.3",
"bleach[css]~=6.2.0",
"bleach[css]~=6.3.0",
"chardet~=5.2.0",
"croniter~=6.0.0",
"cryptography~=46.0.2",
"cryptography~=46.0.3",
"cssutils~=2.11.1",
"email-reply-parser~=0.5.12",
"gunicorn @ git+https://github.com/frappe/gunicorn@bb554053bb87218120d76ab6676af7015680e8b6",
"html5lib~=1.1",
"ipython~=8.37.0",
"ldap3~=2.9",
"markdown2~=2.5.3",
"MarkupSafe~=3.0.2",
"ldap3~=2.9.1",
"markdown2~=2.5.4",
"MarkupSafe~=3.0.3",
"num2words~=0.5.14",
"oauthlib~=3.2.2",
"openpyxl~=3.1.5",
"orjson~=3.11.3",
"orjson~=3.11.5",
"passlib~=1.7.4",
"pdfkit~=1.0.0",
"phonenumbers~=9.0.7",
"phonenumbers~=9.0.21",
"premailer~=3.10.0",
"psutil~=7.0.0",
"psycopg2-binary~=2.9.1",
"psycopg2-binary~=2.9.11",
"pyOpenSSL~=25.3.0",
"pydantic~=2.12.0",
"pydantic~=2.12.5",
"pyotp~=2.9.0",
"python-dateutil~=2.9.0",
"python-dateutil~=2.9.0.post0",
"pytz==2025.2",
"rauth~=0.7.3",
"redis~=6.2.0",
"hiredis~=3.2.1",
"redis~=7.1.0",
"hiredis~=3.3.0",
"requests-oauthlib~=2.0.0",
"requests~=2.32.4",
"requests~=2.32.5",
# We depend on internal attributes of RQ.
# Do NOT add loose requirements on RQ versions.
# Audit the code changes w.r.t. background_jobs.py before updating.
"rq==2.4.0",
"rsa~=4.9",
"rq==2.6.1",
"rsa~=4.9.1",
"semantic-version~=2.10.0",
"sentry-sdk~=1.45.1",
"sqlparse~=0.5.0",
"sql_metadata~=2.17.0",
"sqlparse~=0.5.5",
"sql_metadata~=2.19.0",
"tenacity~=9.1.2",
"terminaltables~=3.1.10",
"traceback-with-variables~=2.2.0",
"typing_extensions>=4.6.1,<5",
"tomli~=2.2.1",
"uuid-utils~=0.11.0",
"traceback-with-variables~=2.2.1",
"typing_extensions>=4.15.0,<5",
"uuid-utils~=0.12.0",
"xlrd~=2.0.2",
"zxcvbn~=4.5.0",
"markdownify~=1.1.0",
"markdownify~=1.2.2",
# integration dependencies
"google-api-python-client~=2.172.0",
"google-auth-oauthlib~=1.2.2",
@ -90,8 +88,7 @@ dependencies = [
"posthog~=5.0.0",
"vobject~=0.9.9",
"pycountry~=24.6.1",
"websockets"
"websockets~=15.0.1",
]
[project.urls]
@ -102,7 +99,7 @@ Repository = "https://github.com/frappe/frappe.git"
[project.optional-dependencies]
dev = [
"pyngrok~=6.0.0",
"watchdog~=3.0.0",
"watchdog~=6.0.0",
"responses==0.23.1",
# typechecking
"basedmypy",
@ -153,14 +150,14 @@ coverage = "~=7.10.0"
Faker = "~=18.10.1"
pyngrok = "~=6.0.0"
unittest-xml-reporting = "~=3.2.0"
watchdog = "~=3.0.0"
watchdog = "~=6.0.0"
hypothesis = "~=6.77.0"
responses = "==0.23.1"
freezegun = "~=1.2.2"
[tool.ruff]
line-length = 110
target-version = "py310"
target-version = "py314"
exclude = [
"**/doctype/*/boilerplate/*.py" # boilerplate are template strings, not valid python
]
@ -192,6 +189,8 @@ ignore = [
"UP030", # Use implicit references for positional format fields (translations)
"UP031", # Use format specifiers instead of percent format
"UP032", # Use f-string instead of `format` call (translations)
"UP037", # quoted annotations
"UP040", # Use type aliases instead of type annotations
]
typing-modules = ["frappe.types.DF"]