ci: Fix coverage reporting (again) (#38849)

* chore: remove _decorate_all_methods_and_functions_with_type_checker

No one understands this runtime magic anymore.

* build: Bump coverage.py to latest

* test: Skip github in coverage reporting

* test: Print traceback from all threads when test is stuck

* ci: Enable coverage in server side tests

* ci: Always enable coverage

It's cheap in recent python versions, our reasons for selectively
disabling aren't valid anymore.

* ci: Disable stderr capturing

* ci: Use default buffer behaviour in unittest runner

* ci(coverage): Set concurrency to multiprocessing

We do use multiprocessing, perhaps the patches aren't concurrectly
handled?

* ci(coverage): Try parallel run

* fix: Apply subprocess patch

* ci: Don't start web server with coverage

Causes deadlock for some reason. We don't actually report it either.

* ci: only submit UI coverage if ran

* test: remove aggresive stuck test checking

* ci: disable UI coverage

(for now)
This commit is contained in:
Ankush Menat 2026-04-24 16:05:14 +05:30 committed by GitHub
parent d9d35fa4ad
commit 098a0851c6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 17 additions and 129 deletions

View file

@ -29,7 +29,7 @@ on:
enable-coverage: enable-coverage:
required: false required: false
type: boolean type: boolean
default: false default: true
jobs: jobs:
@ -100,7 +100,6 @@ jobs:
python-version: ${{ inputs.python-version }} python-version: ${{ inputs.python-version }}
node-version: ${{ inputs.node-version }} node-version: ${{ inputs.node-version }}
disable-socketio: true disable-socketio: true
enable-coverage: ${{ inputs.enable-coverage }}
db-root-password: ${{ env.DB_ROOT_PASSWORD }} db-root-password: ${{ env.DB_ROOT_PASSWORD }}
db: ${{ matrix.db }} db: ${{ matrix.db }}
env: env:
@ -110,29 +109,11 @@ jobs:
run: | run: |
bench --site test_site \ bench --site test_site \
run-parallel-tests \ run-parallel-tests \
--with-coverage \
--app "${{ github.event.repository.name }}" \ --app "${{ github.event.repository.name }}" \
--total-builds ${{ inputs.parallel-runs }} \ --total-builds ${{ inputs.parallel-runs }} \
--build-number ${{ matrix.index }} 2> >(tee -a stderr.log >&2) --build-number ${{ matrix.index }}
# Process warnings and create annotations
if [ -s stderr.log ] && [ "$DB" == "mariadb" ]; then
echo "Processing deprecation warnings..."
grep -E "DeprecationWarning" stderr.log | sort -u | while read -r warning; do
# Extract file path, line number, and warning type
file_info=$(echo "$warning" | grep -oP '^.*?:\d+:')
file_path=$(echo "$file_info" | cut -d':' -f1)
line_number=$(echo "$file_info" | cut -d':' -f2)
warning_type=$(echo "$warning" | grep -oP '\w+Warning')
# Extract the actual warning message
message=$(echo "$warning" | sed -E "s/^.*$warning_type: //")
# Create the annotation
echo "::warning file=${file_path},line=${line_number}::${warning_type}: ${message}"
done
else
echo "No deprecation warnings found."
fi
env: env:
DB: ${{ matrix.db }} DB: ${{ matrix.db }}
# consumed by bench run-parallel-tests # consumed by bench run-parallel-tests
@ -144,7 +125,7 @@ jobs:
if: inputs.enable-coverage if: inputs.enable-coverage
with: with:
name: coverage-${{ matrix.db }}-${{ matrix.index }} name: coverage-${{ matrix.db }}-${{ matrix.index }}
path: ./sites/*-coverage*.xml path: ./sites/*coverage*.xml
- name: Setup tmate session - name: Setup tmate session
uses: mxschmitt/action-tmate@v3 uses: mxschmitt/action-tmate@v3

View file

@ -48,7 +48,6 @@ jobs:
enable-postgres: ${{ needs.checkrun.outputs.run_postgres == 'true' }} # This enables PostgreSQL to run tests enable-postgres: ${{ needs.checkrun.outputs.run_postgres == 'true' }} # This enables PostgreSQL to run tests
enable-sqlite: false # This will test against both MariaDB and SQLite if enabled enable-sqlite: false # This will test against both MariaDB and SQLite if enabled
parallel-runs: 2 parallel-runs: 2
enable-coverage: ${{ github.event_name != 'pull_request' }}
fake-success: ${{ needs.checkrun.outputs.build != 'strawberry' }} fake-success: ${{ needs.checkrun.outputs.build != 'strawberry' }}
needs: checkrun needs: checkrun
secrets: inherit secrets: inherit
@ -67,7 +66,6 @@ jobs:
name: Coverage Wrap Up name: Coverage Wrap Up
needs: [test, checkrun] needs: [test, checkrun]
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ github.event_name != 'pull_request' }}
steps: steps:
- name: Clone - name: Clone
uses: actions/checkout@v6 uses: actions/checkout@v6

View file

@ -44,14 +44,14 @@ jobs:
uses: ./.github/workflows/_base-ui-tests.yml uses: ./.github/workflows/_base-ui-tests.yml
with: with:
parallel-runs: 3 parallel-runs: 3
enable-coverage: ${{ github.event_name != 'pull_request' }} enable-coverage: false
fake-success: ${{ needs.checkrun.outputs.build != 'strawberry' }} fake-success: ${{ needs.checkrun.outputs.build != 'strawberry' }}
needs: checkrun needs: checkrun
coverage: coverage:
name: Coverage Wrap Up name: Coverage Wrap Up
needs: [test, checkrun] needs: [test, checkrun]
if: ${{ github.event_name != 'pull_request' }} if: ${{ needs.checkrun.outputs.build == 'strawberry' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Clone - name: Clone

View file

@ -29,10 +29,6 @@ flags:
paths: paths:
- "**/*.py" - "**/*.py"
carryforward: true carryforward: true
ui-tests:
paths:
- "**/*.js"
carryforward: true
server-ui: server-ui:
paths: paths:
- "**/*.py" - "**/*.py"

View file

@ -144,7 +144,6 @@ def main(
verbosity=2 if testing_module_logger.getEffectiveLevel() < logging.INFO else 1, verbosity=2 if testing_module_logger.getEffectiveLevel() < logging.INFO else 1,
tb_locals=testing_module_logger.getEffectiveLevel() <= logging.INFO, tb_locals=testing_module_logger.getEffectiveLevel() <= logging.INFO,
cfg=test_config, cfg=test_config,
buffer=not debug, # unfortunate as it messes up stdout/stderr output order
) )
if doctype or doctype_list_path: if doctype or doctype_list_path:

View file

@ -22,6 +22,7 @@ STANDARD_EXCLUSIONS = [
"*/node_modules/*", "*/node_modules/*",
"*/doctype/*/*_dashboard.py", "*/doctype/*/*_dashboard.py",
"*/patches/*", "*/patches/*",
"*/.github/*",
] ]
# tested via commands' test suite # tested via commands' test suite
@ -78,7 +79,12 @@ class CodeCoverage:
if self.app == "frappe": if self.app == "frappe":
omit.extend(FRAPPE_EXCLUSIONS) omit.extend(FRAPPE_EXCLUSIONS)
self.coverage = Coverage(source=[source_path], omit=omit, include=STANDARD_INCLUSIONS) self.coverage = Coverage(
source=[source_path],
omit=omit,
include=STANDARD_INCLUSIONS,
data_suffix=True,
)
self.coverage.start() self.coverage.start()
return self return self

View file

@ -14,7 +14,6 @@ import requests
import frappe import frappe
from frappe.tests.utils import make_test_records, toggle_test_mode from frappe.tests.utils import make_test_records, toggle_test_mode
from .testing.environment import _decorate_all_methods_and_functions_with_type_checker
from .testing.result import TestResult from .testing.result import TestResult
click_ctx = click.get_current_context(True) click_ctx = click.get_current_context(True)
@ -61,7 +60,6 @@ class ParallelTestRunner:
frappe.clear_cache() frappe.clear_cache()
frappe.utils.scheduler.disable_scheduler() frappe.utils.scheduler.disable_scheduler()
if not self.lightmode: if not self.lightmode:
_decorate_all_methods_and_functions_with_type_checker()
self.before_test_setup() self.before_test_setup()
def before_test_setup(self): def before_test_setup(self):

View file

@ -56,8 +56,6 @@ def _initialize_test_environment(site, config):
frappe.flags.print_messages = logger.getEffectiveLevel() < logging.INFO frappe.flags.print_messages = logger.getEffectiveLevel() < logging.INFO
frappe.flags.tests_verbose = logger.getEffectiveLevel() < logging.INFO frappe.flags.tests_verbose = logger.getEffectiveLevel() < logging.INFO
_decorate_all_methods_and_functions_with_type_checker()
def _cleanup_after_tests(): def _cleanup_after_tests():
"""Perform cleanup operations after running tests""" """Perform cleanup operations after running tests"""
@ -83,95 +81,6 @@ def _disable_scheduler_if_needed():
frappe.utils.scheduler.disable_scheduler() frappe.utils.scheduler.disable_scheduler()
@debug_timer
def _decorate_all_methods_and_functions_with_type_checker():
from frappe.utils.typing_validations import validate_argument_types
def _get_config_from_pyproject(app_path):
try:
with open(f"{app_path}/pyproject.toml", "rb") as f:
config = tomllib.load(f)
return (
config.get("tool", {})
.get("frappe", {})
.get("testing", {})
.get("function_type_validation", {})
)
except FileNotFoundError:
return {}
except tomllib.TOMLDecodeError:
logger.warning(f"Failed to parse pyproject.toml for app {app_path}")
return {}
def _decorate_callable(obj, parent_module):
# whitelisted methods are already checked, see frappe.whitelist
if getattr(obj, "__func__", obj) in frappe.whitelisted:
return obj
# Check if the function is already decorated
elif hasattr(obj, "_is_decorated_for_validate_argument_types"):
return obj
elif module := getattr(obj, "__module__", ""):
# ensure that the origin skip list is honored on imports; but not the origin
# max_depth because they are reimported thus attached to a different namespace
app = module.split(".", 1)[0]
config = _get_config_from_pyproject(frappe.get_app_source_path(app))
skip_namespaces = config.get("skip_namespaces", [])
if any(module.startswith(n) for n in skip_namespaces):
return obj
@functools.wraps(obj)
def wrapper(*args, **kwargs):
return validate_argument_types(obj)(*args, **kwargs)
wrapper._is_decorated_for_validate_argument_types = True
if obj.__module__ != parent_module.__name__:
logger.debug(f"... patching {obj.__module__}.{obj.__name__} (inside {parent_module.__name__})")
else:
logger.debug(f"... patching {obj.__module__}.{obj.__name__}")
return wrapper
def _decorate_module(app, module, apps, current_depth, max_depth):
if current_depth > max_depth:
return
for name in dir(module):
obj = getattr(module, name)
if inspect.isfunction(obj):
if not getattr(obj, "__annotations__", None):
continue
# never cross the apps (plural!) boundary for functions
if obj.__module__.split(".", 1)[0] not in apps:
continue
setattr(module, name, _decorate_callable(obj, module))
elif inspect.ismodule(obj):
# never cross the app (singular!) boundary for modules
if obj.__name__.split(".", 1)[0] != app:
continue
if hasattr(obj, "_is_decorated_for_validate_argument_types"):
continue
obj._is_decorated_for_validate_argument_types = True
_decorate_module(app, obj, apps, current_depth + 1, max_depth)
for app in (apps := frappe.get_installed_apps()):
config = _get_config_from_pyproject(frappe.get_app_source_path(app))
max_depth = config.get("max_module_depth", 0)
skip_namespaces = config.get("skip_namespaces", [])
logger.info(f"Adding type validator in {app!r} (up to level {max_depth})...")
pkg = frappe.get_module(app)
_decorate_module(app, pkg, apps, 1, max_depth)
for _, submodule_name, _ in pkgutil.walk_packages(path=pkg.__path__, prefix=pkg.__name__ + "."):
current_depth = len(submodule_name.split("."))
if current_depth > max_depth:
continue
if any(submodule_name.startswith(n) for n in skip_namespaces):
continue
submodule = frappe.get_module(submodule_name)
_decorate_module(app, submodule, apps, current_depth, max_depth)
class IntegrationTestPreparation: class IntegrationTestPreparation:
def __init__(self, cfg): def __init__(self, cfg):
self.cfg = cfg self.cfg = cfg

View file

@ -52,7 +52,7 @@ class TestRunner(unittest.TextTestRunner):
descriptions=True, descriptions=True,
verbosity=1, verbosity=1,
failfast=False, failfast=False,
buffer=True, buffer=False,
resultclass=None, resultclass=None,
warnings="module", warnings="module",
*, *,

View file

@ -1,3 +1,4 @@
import faulthandler
import logging import logging
from collections.abc import Callable from collections.abc import Callable
from contextlib import contextmanager from contextlib import contextmanager

View file

@ -129,7 +129,7 @@ dev = [
] ]
test = [ test = [
"unittest-xml-reporting~=3.2.0", "unittest-xml-reporting~=3.2.0",
"coverage~=7.10.0", "coverage~=7.13.5",
"hypothesis~=6.77.0", "hypothesis~=6.77.0",
"freezegun~=1.5.1", "freezegun~=1.5.1",
] ]
@ -146,7 +146,7 @@ skip_namespaces = [
] ]
[tool.bench.dev-dependencies] [tool.bench.dev-dependencies]
coverage = "~=7.10.0" coverage = "~=7.13.5"
pyngrok = "~=7.5.0" pyngrok = "~=7.5.0"
unittest-xml-reporting = "~=3.2.0" unittest-xml-reporting = "~=3.2.0"
watchdog = "~=6.0.0" watchdog = "~=6.0.0"