Skip to content

Commit

Permalink
Merge pull request #2 from espressif/feat/get_pytest_cases
Browse files Browse the repository at this point in the history
feat: support `get_pytest_cases`
  • Loading branch information
hfudev authored Jan 8, 2025
2 parents 74e6884 + 2a5c1c4 commit b9cd40d
Show file tree
Hide file tree
Showing 23 changed files with 969 additions and 133 deletions.
3 changes: 3 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,6 @@ repos:
- pydantic-settings
- click
- pytest
- tomlkit
- idf-build-apps~=2.6
- pytest-embedded~=1.12
16 changes: 16 additions & 0 deletions idf_ci/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,18 @@
# SPDX-FileCopyrightText: 2025 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0

from .idf_pytest.models import PytestApp, PytestCase
from .idf_pytest.plugin import IdfPytestPlugin
from .idf_pytest.script import get_pytest_cases
from .profiles import IniProfileManager, TomlProfileManager
from .settings import CiSettings

__all__ = [
'CiSettings',
'IdfPytestPlugin',
'IniProfileManager',
'PytestApp',
'PytestCase',
'TomlProfileManager',
'get_pytest_cases',
]
15 changes: 15 additions & 0 deletions idf_ci/_compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# SPDX-FileCopyrightText: 2025 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0

import os
import typing as t

PathLike = t.Union[str, os.PathLike]


class Undefined(str):
def __new__(cls, *args, **kwargs):
return super().__new__(cls, 'undefined')


UNDEF = Undefined()
2 changes: 2 additions & 0 deletions idf_ci/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from .build_profile import build_profile
from .ci_profile import ci_profile
from .completions import completions
from .test_profile import test_profile

_CLI_SETTINGS = {
'show_default': True,
Expand All @@ -21,6 +22,7 @@ def cli():
cli.add_command(build_profile)
cli.add_command(ci_profile)
cli.add_command(completions)
cli.add_command(test_profile)


__all__ = ['cli']
2 changes: 1 addition & 1 deletion idf_ci/cli/build_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,5 @@ def init(path: str):
else:
filepath = path

shutil.copyfile(os.path.join(os.path.dirname(__file__), '..', 'profiles', 'default_build_profile.toml'), filepath)
shutil.copyfile(os.path.join(os.path.dirname(__file__), '..', 'templates', 'default_build_profile.toml'), filepath)
click.echo(f'Created build profile at {filepath}')
2 changes: 1 addition & 1 deletion idf_ci/cli/ci_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,5 @@ def init(path: str):
else:
filepath = path

shutil.copyfile(os.path.join(os.path.dirname(__file__), '..', 'profiles', 'default_ci_profile.toml'), filepath)
shutil.copyfile(os.path.join(os.path.dirname(__file__), '..', 'templates', 'default_ci_profile.toml'), filepath)
click.echo(f'Created CI profile at {filepath}')
30 changes: 30 additions & 0 deletions idf_ci/cli/test_profile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# SPDX-FileCopyrightText: 2025 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0

import os
import shutil

import click


@click.group()
def test_profile():
"""
Group of commands for managing CI templates for idf-ci
"""
pass


@test_profile.command()
@click.option('--path', default=os.getcwd(), help='Path to create the CI profile')
def init(path: str):
"""
Create a CI profile at the given folder
"""
if os.path.isdir(path):
filepath = os.path.join(path, 'pytest.ini')
else:
filepath = path

shutil.copyfile(os.path.join(os.path.dirname(__file__), '..', 'templates', 'default_test_profile.ini'), filepath)
click.echo(f'Created test profile at {filepath}')
File renamed without changes.
177 changes: 177 additions & 0 deletions idf_ci/idf_pytest/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
# SPDX-FileCopyrightText: 2025 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0

import logging
import os
import typing as t
from functools import cached_property

from _pytest.python import Function
from idf_build_apps.utils import to_list
from pytest_embedded.plugin import parse_multi_dut_args

LOGGER = logging.getLogger(__name__)


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))

@property
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}')


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

def __init__(self, apps: t.List[PytestApp], item: Function) -> None:
self.apps = apps
self.item = item

@classmethod
def get_param(cls, item: Function, key: str, default: t.Any = None) -> t.Any:
# funcargs is not calculated while collection
# callspec is something defined in parametrize
if not hasattr(item, 'callspec'):
return default

return item.callspec.params.get(key, default) or default

@classmethod
def from_item(cls, item: Function, *, cli_target: str) -> t.Optional['PytestCase']:
"""
Turn pytest item to PytestCase
"""
count = cls.get_param(item, 'count', 1)

# default app_path is where the test script locates
app_paths = to_list(parse_multi_dut_args(count, cls.get_param(item, 'app_path', os.path.dirname(item.path))))
configs = to_list(parse_multi_dut_args(count, cls.get_param(item, 'config', 'default')))
targets = to_list(parse_multi_dut_args(count, cls.get_param(item, 'target'))) # defined fixture

cli_targets = cli_target.split(',')
if count > 1 and targets == [None] * count:
targets = cli_targets
elif targets is None:
if count == len(cli_targets):
LOGGER.debug('No param "target" for test case "%s", use CLI target "%s"', item.name, cli_target)
targets = cli_targets
else:
LOGGER.warning(
'No param "target" for test case "%s". '
'current DUT count is %d, while CLI target count is %d. Skipping the test.',
item.name,
count,
len(cli_targets),
)
return None

return PytestCase(
apps=[PytestApp(app_paths[i], targets[i], configs[i]) for i in range(count)],
item=item,
)

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 configs(self) -> t.List[str]:
return [app.config for app in self.apps]

@cached_property
def caseid(self) -> str:
if self.is_single_dut:
return f'{self.targets[0]}.{self.configs[0]}.{self.name}'
else:
return f'{tuple(self.targets)}.{tuple(self.configs)}.{self.name}'

@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
Loading

0 comments on commit b9cd40d

Please sign in to comment.