Skip to content

Commit

Permalink
feat: support get_pytest_cases
Browse files Browse the repository at this point in the history
  • Loading branch information
hfudev committed Jan 6, 2025
1 parent 74e6884 commit bde4edc
Show file tree
Hide file tree
Showing 5 changed files with 242 additions and 1 deletion.
8 changes: 8 additions & 0 deletions idf_ci/idf_pytest/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# SPDX-FileCopyrightText: 2025 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0

from .script import get_pytest_cases

__all__ = [
'get_pytest_cases',
]
128 changes: 128 additions & 0 deletions idf_ci/idf_pytest/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# SPDX-FileCopyrightText: 2025 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0

import logging
import os
import typing as t
from dataclasses import dataclass
from enum import Enum
from functools import cached_property

from _pytest.python import Function

LOGGER = logging.getLogger(__name__)


class CollectMode(str, Enum):
SINGLE_DUT = 'single'
MULTI_DUT = 'multi'
ALL = 'all'


class PytestApp:
"""
Represents a pytest app.
"""

def __init__(self, path: str, target: str, config: str) -> None:
self.path = os.path.abspath(path)
self.target = target
self.config = config

def __hash__(self) -> int:
return hash((self.path, self.target, self.config))

def build_dir(self) -> str:
"""
Returns the build directory for the app.
.. note::
Matches the build_dir (by default build_@t_@w) in the build profile.
:return: The build directory for the app.
"""
return os.path.join(self.path, f'build_{self.target}_{self.config}')


@dataclass
class PytestCase:
"""
Represents a pytest test case.
"""

apps: t.List[PytestApp]
item: Function

def __hash__(self) -> int:
return hash((self.path, self.name, self.apps, self.all_markers))

@cached_property
def path(self) -> str:
return str(self.item.path)

@cached_property
def name(self) -> str:
return self.item.originalname

@cached_property
def targets(self) -> t.List[str]:
return [app.target for app in self.apps]

@cached_property
def is_single_dut(self) -> bool:
return True if len(self.apps) == 1 else False

@cached_property
def is_host_test(self) -> bool:
return 'host_test' in self.all_markers or 'linux' in self.targets

@cached_property
def is_in_ci(self) -> bool:
return 'CI_JOB_ID' in os.environ or 'GITHUB_ACTIONS' in os.environ

@cached_property
def target_selector(self) -> str:
return ','.join(app.target for app in self.apps)

# the following markers could be changed dynamically, don't use cached_property
@property
def all_markers(self) -> t.Set[str]:
return {marker.name for marker in self.item.iter_markers()}

def all_built_in_app_lists(self, app_lists: t.Optional[t.List[str]] = None) -> t.Optional[str]:
"""
Check if all binaries of the test case are built in the app lists.
:param app_lists: app lists to check
:return: debug string if not all binaries are built in the app lists, None otherwise
"""
if app_lists is None:
# ignore this feature
return None

bin_found = [0] * len(self.apps)
for i, app in enumerate(self.apps):
if app.build_dir in app_lists:
bin_found[i] = 1

if sum(bin_found) == 0:
msg = f'Skip test case {self.name} because all following binaries are not listed in the app lists: '
for app in self.apps:
msg += f'\n - {app.build_dir}'

print(msg)
return msg

if sum(bin_found) == len(self.apps):
return None

# some found, some not, looks suspicious
msg = f'Found some binaries of test case {self.name} are not listed in the app lists.'
for i, app in enumerate(self.apps):
if bin_found[i] == 0:
msg += f'\n - {app.build_dir}'

msg += '\nMight be a issue of .build-test-rules.yml files'
print(msg)
return msg
94 changes: 94 additions & 0 deletions idf_ci/idf_pytest/script.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# SPDX-FileCopyrightText: 2025 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0

import importlib.util
import io
import os.path
import re
import sys
import typing as t
from contextlib import redirect_stderr, redirect_stdout
from unittest.mock import MagicMock

import pytest
from _pytest.config import ExitCode

_MODULE_NOT_FOUND_REGEX = re.compile(r"No module named '(.+?)'")


class CollectModuleNotFoundError(ModuleNotFoundError):
pass


def _warn_missing_package(pkg: str):
# redirect_stderr somehow breaks the sys.stderr.write() method
# fix it when implement proper logging
if sys.__stderr__:
sys.__stderr__.write(f'WARNING:Mocking missed package while collecting: {pkg}\n')
sys.modules[pkg] = MagicMock()


def try_import(path: str):
spec = importlib.util.spec_from_file_location('', path)
# write these if to make mypy happy
if spec:
module = importlib.util.module_from_spec(spec)
if spec.loader and module:
spec.loader.exec_module(module)


class Collector:
def pytest_pycollect_makemodule(
self,
module_path,
path, # noqa: ARG002
parent, # noqa: ARG002
):
while True:
try:
try_import(module_path)
except ModuleNotFoundError as e:
if res := _MODULE_NOT_FOUND_REGEX.search(e.msg):
_warn_missing_package(res.group(1))
continue
else:
break


def _get_pytest_cases(paths: t.List[str]) -> None:
with io.StringIO() as out_b, io.StringIO() as err_b:
with redirect_stdout(out_b), redirect_stderr(err_b):
res = pytest.main(
[
*paths,
'--collect-only',
'-c', # TODO: support profile
os.path.join(os.path.dirname(__file__), '..', 'profiles', 'default_test_profile.ini'),
'--target',
'all',
],
plugins=[Collector()],
)
stderr_msg = err_b.getvalue()

if res == ExitCode.OK:
return

module_not_found = _MODULE_NOT_FOUND_REGEX.search(stderr_msg)
if module_not_found:
_warn_missing_package(module_not_found.group(1))
raise CollectModuleNotFoundError()

raise RuntimeError(f'pytest collection failed at {", ".join(paths)}.\n' f'Error message: {stderr_msg}')


def get_pytest_cases(paths: t.List[str]):
while True:
try:
_get_pytest_cases(paths)
except CollectModuleNotFoundError:
continue
else:
break

# TODO return real plugin.cases()
5 changes: 5 additions & 0 deletions idf_ci/profiles/default_test_profile.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[pytest]
python_files = pytest_*.py

addopts =
--embedded-services esp,idf
8 changes: 7 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@ classifiers = [
"Programming Language :: Python :: 3.13",
"Topic :: Software Development :: Testing",
]
dependencies = ["idf-build-apps~=2.5", "click"]
dependencies = [
"click",
# build related
"idf-build-apps~=2.6",
# test related
"pytest-embedded-idf[serial]~=1.12",
]

[project.urls]
Homepage = "https://github.com/espressif/idf-ci"
Expand Down

0 comments on commit bde4edc

Please sign in to comment.