diff --git a/.github/actions/pip-cache/action.yml b/.github/actions/pip-cache/action.yml index b85cd5b28..82c99dad2 100644 --- a/.github/actions/pip-cache/action.yml +++ b/.github/actions/pip-cache/action.yml @@ -6,5 +6,5 @@ runs: - name: Restore Pip Cache uses: actions/cache@v4 with: - path: ~/.cache/pip - key: ${{ hashFiles('setup.py') }}-${{ hashFiles('requirements.txt') }} + path: ${{ runner.os == 'Linux' && '~/.cache/pip' || '~\AppData\Local\pip\Cache' }} + key: ${{ runner.os }}-${{ hashFiles('pyproject.toml') }} diff --git a/doc/en/getting_started.rst b/doc/en/getting_started.rst index 5dba3c891..3a9bcc957 100644 --- a/doc/en/getting_started.rst +++ b/doc/en/getting_started.rst @@ -76,7 +76,7 @@ Also find all our downloadable examples :ref:`here `. Working with the source ----------------------- - + You will need a working python 3.7+ interrpreter preferably a venv, and for the interactive ui you need node installed. We are using `doit `_ as the taskrunner ``doit list`` can show all the commands. @@ -85,10 +85,10 @@ We are using `doit `_ as the taskrunner ``doit git clone https://github.com/morganstanley/testplan.git cd testplan - # install all dev requirements - pip install -r requirements-txt # this install testplan in editable mode + # install testplan in editable mode & all dev requirements + pip install -e . - #build the interactive UI (if you do not like it is opening a browserwindow remove the `-o`) + # build the interactive UI (if you do not like it is opening a browserwindow remove the `-o`) doit build_ui -o Internal tests diff --git a/doc/newsfragments/3160_changed.case_name.rst b/doc/newsfragments/3160_changed.case_name.rst new file mode 100755 index 000000000..4d3d5e121 --- /dev/null +++ b/doc/newsfragments/3160_changed.case_name.rst @@ -0,0 +1 @@ +Limit the length of parameterization testcase name to 255 characters. If the name length exceeds 255 characters, index-suffixed names (e.g., ``{func_name} 1``, ``{func_name} 2``) will be used. \ No newline at end of file diff --git a/doc/newsfragments/3167_changed.json_exporter.rst b/doc/newsfragments/3167_changed.json_exporter.rst new file mode 100755 index 000000000..2bc36a8bc --- /dev/null +++ b/doc/newsfragments/3167_changed.json_exporter.rst @@ -0,0 +1 @@ +``JSONExporter`` will log a "file not found" warning in the log instead of raising an exception. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 5e179d852..90309e991 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,12 +34,9 @@ "setuptools", "pytest", "pytest-mock", - "py", "psutil", "schema", - "pytz", "lxml", - "python-dateutil", "reportlab", "marshmallow", "termcolor", diff --git a/pytest.ini b/pytest.ini index 328b7dd2e..8c10f8750 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,10 +1,12 @@ [pytest] filterwarnings = ignore::pytest.PytestWarning - ignore::DeprecationWarning:flask_restx.*: - ignore::DeprecationWarning:jinja2.*: - ignore::DeprecationWarning:jsonschema.*: - ignore::DeprecationWarning:marshmallow.*: - ignore::DeprecationWarning:werkzeug.*: + ignore:.*flask_restx.*:DeprecationWarning + ; jsonschema warning from flask_restx + ignore:.*jsonschema.*:DeprecationWarning + ignore:.*load_module.*:DeprecationWarning + ignore:.*LogMatcher.*:UserWarning + ; under most cases, included files are not hit + ignore:No data was collected:coverage.exceptions.CoverageWarning norecursedirs=tests/helpers diff --git a/requirements-basic.txt b/requirements-basic.txt deleted file mode 100644 index 9e0760a14..000000000 --- a/requirements-basic.txt +++ /dev/null @@ -1,44 +0,0 @@ -pytest -py -psutil>=5.6.7 -setuptools -schema -pytz -lxml -python-dateutil -reportlab -marshmallow -termcolor -colorama -pyzmq -terminaltables -requests>=2.4.3 -flask>2.0.0 -werkzeug>2.0.0 -flask_restx -cheroot -boltons -validators<=0.20.0 -Pillow -plotly -rpyc -coverage -memoization -typing_extensions -click>=8.1.0 - -# Documentation -# --------------- -# sphinx -# sphinx_rtd_theme -# sphinx-click - -# matplot assertion -# ----------------- -matplotlib -numpy - -# Data science -# ------------ -# scikit-learn -# scipy diff --git a/requirements-rtd.txt b/requirements-rtd.txt deleted file mode 100644 index 941be7a12..000000000 --- a/requirements-rtd.txt +++ /dev/null @@ -1,5 +0,0 @@ -docutils<0.18 -Jinja2<3.1.0 -sphinx<2 --r ./requirements.txt -./releaseherald \ No newline at end of file diff --git a/testplan/common/utils/path.py b/testplan/common/utils/path.py index f43fda3ac..d60a42e9d 100644 --- a/testplan/common/utils/path.py +++ b/testplan/common/utils/path.py @@ -204,7 +204,7 @@ def instantiate(template, values, destination): try: target.write(render(source.read(), values)) except UnicodeDecodeError: - shutil.copy(template, destination) + shutil.copyfile(template, destination) except Exception as exc: raise Exception( "On reading/writing template: {} - of file {}".format( diff --git a/testplan/common/utils/reporting.py b/testplan/common/utils/reporting.py index 12dd069e3..6ae133564 100644 --- a/testplan/common/utils/reporting.py +++ b/testplan/common/utils/reporting.py @@ -26,7 +26,7 @@ def __new__(cls): cls.__instance = object.__new__(cls) return cls.__instance - def __str__(self): + def __repr__(self): return self.descr diff --git a/testplan/exporters/testing/json/base.py b/testplan/exporters/testing/json/base.py index 308243038..8a389a9c2 100644 --- a/testplan/exporters/testing/json/base.py +++ b/testplan/exporters/testing/json/base.py @@ -24,37 +24,6 @@ from ..base import Exporter -def save_attachments(report: TestReport, directory: str) -> Dict[str, str]: - """ - Saves the report attachments to the given directory. - - :param report: Testplan report. - :param directory: directory to save attachments in - :return: dictionary of destination paths - """ - moved_attachments = {} - attachments = getattr(report, "attachments", None) - if attachments: - for dst, src in attachments.items(): - src = pathlib.Path(src) - dst_path = pathlib.Path(directory) / dst - makedirs(dst_path.parent) - if not src.is_file(): - dirname = src.parent - # Try retrieving the file from "_attachments" directory that is - # near to the test report, the downloaded report might be moved - src = pathlib.Path.cwd() / ATTACHMENTS / dst - if not src.is_file(): - raise FileNotFoundError( - f'Attachment "{dst}" not found in either {dirname} or' - f' the nearest "{ATTACHMENTS}" directory of test report' - ) - copyfile(src=src, dst=dst_path) - moved_attachments[dst] = str(dst_path) - - return moved_attachments - - def save_resource_data( report: TestReport, directory: pathlib.Path ) -> pathlib.Path: @@ -176,8 +145,9 @@ def export( with open(assertions_filepath, "w") as json_file: json_file.write(json_dumps(assertions)) - meta["attachments"] = save_attachments( - report=source, directory=attachments_dir + meta["attachments"] = self.save_attachments( + report=source, + directory=attachments_dir, ) meta["version"] = 2 meta["attachments"][structure_filename] = str( @@ -192,8 +162,9 @@ def export( with open(json_path, "w") as json_file: json_file.write(json_dumps(meta)) else: - data["attachments"] = save_attachments( - report=source, directory=attachments_dir + data["attachments"] = self.save_attachments( + report=source, + directory=attachments_dir, ) data["version"] = 1 @@ -208,6 +179,41 @@ def export( ) return result + def save_attachments( + self, report: TestReport, directory: str + ) -> Dict[str, str]: + """ + Saves the report attachments to the given directory. + + :param report: Testplan report. + :param directory: directory to save attachments in + :return: dictionary of destination paths + """ + moved_attachments = {} + attachments = getattr(report, "attachments", None) + if attachments: + for dst, src in attachments.items(): + src = pathlib.Path(src) + dst_path = pathlib.Path(directory) / dst + makedirs(dst_path.parent) + if not src.is_file(): + dirname = src.parent + # Try retrieving the file from "_attachments" directory that is + # near to the test report, the downloaded report might be moved + src = pathlib.Path.cwd() / ATTACHMENTS / dst + if not src.is_file(): + self.logger.warning( + 'Attachment "%s" not found in either %s or the nearest "%s" directory of test report', + dst, + dirname, + ATTACHMENTS, + ) + continue + copyfile(src=src, dst=dst_path) + moved_attachments[dst] = str(dst_path) + + return moved_attachments + @staticmethod def split_json_report(data): """Split a single Json into several parts.""" diff --git a/testplan/runners/pools/base.py b/testplan/runners/pools/base.py index ff71f76ea..df9e65969 100644 --- a/testplan/runners/pools/base.py +++ b/testplan/runners/pools/base.py @@ -162,7 +162,7 @@ def rebase_task_path(self, task: Task) -> None: def discard_running_tasks(self): self._discard_running.set() - def __str__(self): + def __repr__(self): return f"{self.__class__.__name__}[{self.cfg.index}]" diff --git a/testplan/testing/multitest/driver/http/client.py b/testplan/testing/multitest/driver/http/client.py index be6c9b243..31053b2c5 100644 --- a/testplan/testing/multitest/driver/http/client.py +++ b/testplan/testing/multitest/driver/http/client.py @@ -195,7 +195,7 @@ def send(self, method, api, **kwargs): args=(method, api, drop_response, self.timeout), kwargs=kwargs, ) - request_thread.setDaemon(True) + request_thread.daemon = True request_thread.start() self.request_threads.append((request_thread, drop_response)) diff --git a/testplan/testing/multitest/driver/http/server.py b/testplan/testing/multitest/driver/http/server.py index e8eb2096b..0757f0c25 100644 --- a/testplan/testing/multitest/driver/http/server.py +++ b/testplan/testing/multitest/driver/http/server.py @@ -454,7 +454,7 @@ def starting(self): timeout=self.timeout, logger=self.logger, ) - self._server_thread.setName(self.name) + self._server_thread.name = self.name self._server_thread.start() while not hasattr(self._server_thread.server, "server_port"): diff --git a/testplan/testing/multitest/parametrization.py b/testplan/testing/multitest/parametrization.py index 528e8de93..3337338f3 100644 --- a/testplan/testing/multitest/parametrization.py +++ b/testplan/testing/multitest/parametrization.py @@ -9,6 +9,7 @@ from testplan.common.utils import callable as callable_utils from testplan.common.utils import convert, interface from testplan.testing import tagging +from typing import Callable, Optional # Although any string will be processed as normal, it's a good # approach to warn the user if the generated method name is not a @@ -206,8 +207,11 @@ def _generated(self, env, result): ) # Users request the feature that when `name_func` set to `None`, # then simply append integer suffixes to the names of testcases - _generated.name = ( - name_func(name, kwargs) if name_func is not None else f"{name} {idx}" + _generated.name = _parametrization_report_name_func_wrapper( + name_func=name_func, + name=name, + kwargs=kwargs, + index=idx, ) if hasattr(function, "__xfail__"): @@ -267,10 +271,11 @@ def _check_tag_func(tag_func): ) -def _parametrization_name_func_wrapper(func_name, kwargs): +def _parametrization_name_func_wrapper(func_name: str, kwargs: dict): """ Make sure that name generation doesn't end up with invalid / unreadable - attribute names/types etc. + attribute names/types etc. The return value can be used as a + method __name__. If somehow a 'bad' function name is generated, will just return the original ``func_name`` instead (which will later on be suffixed with an @@ -291,6 +296,32 @@ def _parametrization_name_func_wrapper(func_name, kwargs): return generated_name +def _parametrization_report_name_func_wrapper( + name_func: Optional[Callable], name: str, kwargs: dict, index: int +): + """ + Make sure that generated name is not too long, + if it is, then use index suffixed names e.g. "{func_name} 1", "{func_name} 2", will be used. + + The return value is used for reporting purposes, it is not used as a method __name__. + """ + if name_func: + generated_name = name_func(name, kwargs) + if not isinstance(generated_name, str): + raise ValueError( + "The return value of name_func must be a string, " + f"it is of type: {type(generated_name)}, value: {generated_name}" + ) + if len(generated_name) <= MAX_METHOD_NAME_LENGTH: + return generated_name + else: + warnings.warn( + f"The name name_func returned ({generated_name}) is too long, using index suffixed names." + ) + + return f"{name} {index}" + + def parametrization_name_func(func_name, kwargs): """ Method name generator for parametrized testcases. @@ -321,7 +352,7 @@ def default_name_func(func_name, kwargs): >>> import collections >>> default_name_func('Test Method', collections.OrderedDict(('foo', 5), ('bar', 10))) - 'Test Method {foo:5, bar:10}' + 'Test Method ' :param func_name: Name of the parametrization target function. :type func_name: ``str`` diff --git a/testplan/testing/multitest/suite.py b/testplan/testing/multitest/suite.py index 9d03ee7c5..7f276cca1 100644 --- a/testplan/testing/multitest/suite.py +++ b/testplan/testing/multitest/suite.py @@ -580,10 +580,10 @@ def _validate_testcase(func): raise exc if len(func.name) > defaults.MAX_TEST_NAME_LENGTH: - warnings.warn( - 'Name defined for testcase "{}" is too long,' - ' consider customizing testcase name with argument "name_func"' - " in @testcase decorator.".format(func.__name__) + raise ValueError( + f'Name defined for testcase "{func.name}" is longer than {defaults.MAX_TEST_NAME_LENGTH},' + ' consider customizing testcase name with argument "name"' + " in @testcase decorator." ) diff --git a/tests/functional/testplan/testing/multitest/test_parametrization.py b/tests/functional/testplan/testing/multitest/test_parametrization.py index 4ce973faf..34c2b5e0c 100644 --- a/tests/functional/testplan/testing/multitest/test_parametrization.py +++ b/tests/functional/testplan/testing/multitest/test_parametrization.py @@ -1,11 +1,9 @@ import sys import logging -from contextlib import contextmanager from unittest import mock -from imp import reload +from importlib import reload import pytest - from testplan.defaults import MAX_TEST_NAME_LENGTH from testplan.testing.multitest import MultiTest, testsuite, testcase from testplan.testing.multitest.parametrization import ( @@ -24,8 +22,8 @@ LOGGER = logging.getLogger() -@contextmanager -def module_reloaded(mod): +@pytest.fixture() +def suite_reloaded(): """ If uncaught exception raised, Testplan process should abort. However, if the process is managed by PyTest for testing purpose, then the @@ -33,6 +31,7 @@ def module_reloaded(mod): modules still exist in memory, some global variables need to be reset. """ yield + mod = "testplan.testing.multitest.suite" if mod in sys.modules: reload(sys.modules[mod]) @@ -214,7 +213,7 @@ def test_sample(self, env, result, a, b): ), ), ) -def test_invalid_parametrization(val, msg): +def test_invalid_parametrization(suite_reloaded, val, msg): """Correct arguments should be passed to parametrized testcases.""" with pytest.raises(ParametrizationError): @@ -227,7 +226,7 @@ def sample_test(self, env, result, a, b, c=3): pytest.fail(msg) -def test_duplicate_parametrization_template_definition(): +def test_duplicate_parametrization_template_definition(suite_reloaded): """No duplicate name of testcase or parametrization template allowed.""" with pytest.raises(ValueError): @@ -305,8 +304,8 @@ def sample(self, env, result, test__val): ( ("a" * MAX_METHOD_NAME_LENGTH, "b" * MAX_METHOD_NAME_LENGTH), [ - "sample_test ".format("a" * MAX_METHOD_NAME_LENGTH), - "sample_test ".format("b" * MAX_METHOD_NAME_LENGTH), + "sample_test 0", + "sample_test 1", ], ["sample_test__0", "sample_test__1"], "Should use original method name + index fallback if" @@ -451,7 +450,7 @@ def sample_test(self, env, result, a, b): ), ), ) -def test_invalid_name_func(name_func, msg, err): +def test_invalid_name_func(suite_reloaded, name_func, msg, err): """Custom naming function should be correctly defined.""" with pytest.raises(err): diff --git a/tests/unit/testplan/test_parser.py b/tests/unit/testplan/test_parser.py index 1222287c8..b387ba1b7 100644 --- a/tests/unit/testplan/test_parser.py +++ b/tests/unit/testplan/test_parser.py @@ -1,7 +1,4 @@ -from jsonschema.cli import parse_args - from testplan import TestplanMock -from testplan.parser import TestplanParser from testplan.testing.listing import SimpleJsonLister, NameLister diff --git a/tests/unit/testplan/testing/multitest/driver/myapp/test_app.py b/tests/unit/testplan/testing/multitest/driver/myapp/test_app.py index 4ea6a7af3..9a41091f4 100644 --- a/tests/unit/testplan/testing/multitest/driver/myapp/test_app.py +++ b/tests/unit/testplan/testing/multitest/driver/myapp/test_app.py @@ -256,6 +256,7 @@ def test_install_files(runpath): bfile = os.path.join( os.path.abspath(os.path.dirname(__file__)), "binary_file" ) + os.chmod(bfile, 0o444) # only 644 or 755 supported by git stdout_regexps = [ re.compile(r".*binary=(?P.*)"), re.compile(r".*command=(?P.*)"), @@ -280,12 +281,13 @@ def test_install_files(runpath): assert os.path.exists(app.extracts["binary"]) assert bool(json.loads(app.extracts["command"])) assert os.path.exists(app.extracts["app_path"]) - assert os.path.exists(os.path.join(app.app_path, "etc", "binary_file")) - assert os.path.exists(os.path.join(app.app_path, "etc", "config.yaml")) - assert os.path.exists(os.path.join(dst, "config.yaml")) - assert os.path.exists( - os.path.join(app.app_path, "etc", "rel_path", "config.yaml") - ) + for p in [ + os.path.join(app.app_path, "etc", "binary_file"), + os.path.join(app.app_path, "etc", "config.yaml"), + os.path.join(dst, "config.yaml"), + os.path.join(app.app_path, "etc", "rel_path", "config.yaml"), + ]: + assert os.access(p, os.F_OK | os.R_OK | os.W_OK) def test_echo_hello(runpath):