diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml deleted file mode 100644 index 4c61a4a..0000000 --- a/.github/workflows/build-release.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Upload Python Package - -on: - release: - types: [created] - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Set up Python - uses: actions/setup-python@v2 - with: - python-version: '3.9' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install setuptools wheel - - name: Build - run: | - python setup.py sdist bdist_wheel - - name: Publish package to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml new file mode 100644 index 0000000..22da709 --- /dev/null +++ b/.github/workflows/publish-to-pypi.yml @@ -0,0 +1,94 @@ +name: Publish Python 🐍 distribution 📦 to PyPI + +on: push + +jobs: + build: + name: Build distribution 📦 + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.x" + + - name: Install pypa/build + run: >- + python3 -m + pip install + build + --user + - name: Build a binary wheel and a source tarball + run: python3 -m build + - name: Store the distribution packages + uses: actions/upload-artifact@v3 + with: + name: python-package-distributions + path: dist/ + + publish-to-pypi: + name: >- + Publish Python 🐍 distribution 📦 to PyPI + if: startsWith(github.ref, 'refs/tags/') + needs: + - build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/pytest-slow-first + permissions: + id-token: write + + steps: + - name: Download all the dists + uses: actions/download-artifact@v3 + with: + name: python-package-distributions + path: dist/ + - name: Publish distribution 📦 to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + github-release: + name: >- + Sign the Python 🐍 distribution 📦 with Sigstore + and upload them to GitHub Release + needs: + - publish-to-pypi + runs-on: ubuntu-latest + + permissions: + contents: write # IMPORTANT: mandatory for making GitHub Releases + id-token: write # IMPORTANT: mandatory for sigstore + + steps: + - name: Download all the dists + uses: actions/download-artifact@v3 + with: + name: python-package-distributions + path: dist/ + - name: Sign the dists with Sigstore + uses: sigstore/gh-action-sigstore-python@v1.2.3 + with: + inputs: >- + ./dist/*.tar.gz + ./dist/*.whl + - name: Create GitHub Release + env: + GITHUB_TOKEN: ${{ github.token }} + run: >- + gh release create + '${{ github.ref_name }}' + --repo '${{ github.repository }}' + --notes "" + - name: Upload artifact signatures to GitHub Release + env: + GITHUB_TOKEN: ${{ github.token }} + # Upload to GitHub Release using the `gh` CLI. + # `dist/` contains the built packages, and the + # sigstore-produced signatures and certificates. + run: >- + gh release upload + '${{ github.ref_name }}' dist/** + --repo '${{ github.repository }}' \ No newline at end of file diff --git a/.gitignore b/.gitignore index 31e6ec5..449c0cb 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,7 @@ __pycache__/ # C extensions *.so - +.idea/ # Distribution / packaging .Python env/ diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 26d3352..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index 0b1ba55..0000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml deleted file mode 100644 index 105ce2d..0000000 --- a/.idea/inspectionProfiles/profiles_settings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 02c2b01..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index e58beaa..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/pytest-slow-first.iml b/.idea/pytest-slow-first.iml deleted file mode 100644 index 8b8c395..0000000 --- a/.idea/pytest-slow-first.iml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/README.md b/README.md index e275f87..3bd0f2a 100644 --- a/README.md +++ b/README.md @@ -39,44 +39,6 @@ In the next time you run it, tests will be sorted by time spend in the last run,
-Usage ------ - -You just need to define two functions inside your conftest.py file: `slow_first_save_durations` and `slow_first_load_durations`. - -The first one is to save results of current run and the second one is to load the same results in the folowing run. Allowing this plugin to sort execuntion of tests based in these results. - -Example of `conftest.py` file: -```python -import os, json - - -def slow_first_load_durations(): - if os.path.exists('/tmp/tests_duration'): - with open('/tmp/tests_duration', 'r') as f: - return f.read() - else: - # Durations not found. Run with default order - return None - -def slow_first_save_durations(durations_data: str): - with open('/tmp/tests_duration', 'w') as f: - f.write(durations_data) -``` - -#### Explanation - -1. First, `slow_first_load_durations` will be called before your tests starts running, it will load the durantion of the tests -of the previous run. - - * **obs**: if its the first time using this plugin or if you can't load the results, this function must return None. - -2. If `slow_first_load_durations` finds data, it returns the content and slow-first plugin will sort your tests, otherwise -the test suite will run at default order. - -3. If the suit runs with success, `slow_first_save_durations` is going to be called with durations as argument. This function must save the results -in a way that `slow_first_load_durations` can load in the next run. - ### Running with pytest-slow-first plugin Finally, activate the plugin by passing `--slow-first` as paramter of pytest command: @@ -87,6 +49,10 @@ pytest tests --slow-first -n3 # using along side xdist
+THis plugin will save the duration of each of your tests in a file named `pytest-slow-first.json` in the current directory. +You can change the location by setting the enviroment variable `SLOW_FIRST_PATH` to the path you want. +Ex: `export SLOW_FIRST_PATH=/tmp/pytest-slow-first.json pytest --slow-first` + Installation ------------ diff --git a/pytest_slow_first.py b/pytest_slow_first.py index 5b64070..22828e5 100644 --- a/pytest_slow_first.py +++ b/pytest_slow_first.py @@ -2,6 +2,18 @@ import importlib import json import logging +import os +from typing import Union, Dict + +import pytest +from _pytest.config import ExitCode + +FORMAT_VERSION = '1.0.0' +SLOW_FIRST_PATH = os.environ.get('SLOW_FIRST_PATH', 'pytest-slow-first.json') + + +def log_slow_first(message: str): + logging.getLogger().info(f"[pytest-slow-first] {message}") class SlowFirstRequiredFunctionNotImplemented(Exception): @@ -17,60 +29,77 @@ def _get_slow_first_from_config(config): class Test: - def __init__(self, name, setup_duration: float = None, call_duration: float = None, teardown_duration: float = None): - self.name = name + nodeid: str + setup_duration: Union[float, None] + call_duration: Union[float, None] + teardown_duration: Union[float, None] + + def __init__(self, nodeid: str, setup_duration: float = None, call_duration: float = None, teardown_duration: float = None): + self.nodeid = nodeid self.setup_duration = setup_duration self.call_duration = call_duration self.teardown_duration = teardown_duration @property - def total_duration(self): + def total_duration(self) -> float: return self.setup_duration + self.call_duration + self.teardown_duration def set_duration(self, kind: str, duration: float): setattr(self, f"{kind}_duration", duration) - def serialize(self): - return {'name': self.name, 'setup_duration': self.setup_duration, + def serialize(self) -> Dict[str, Union[str, float]]: + return {'nodeid': self.nodeid, 'setup_duration': self.setup_duration, 'call_duration': self.call_duration, 'teardown_duration': self.teardown_duration} @staticmethod - def deserialize(data: dict): - test = Test(data['name']) - test.setup_duration = data['setup_duration'] - test.call_duration = data['call_duration'] - test.teardown_duration = data['teardown_duration'] - return test + def deserialize(data: dict) -> "Test": + return Test(**data) class SlowFirst: - def __init__(self, tests_by_name: dict = None, enabled: bool = False): + _tests_by_name: Dict[str, Test] + enabled: bool + + def __init__(self, tests_by_name: Dict[str, Test] = None, enabled: bool = False): self._tests_by_name = tests_by_name or {} self.enabled = enabled - if enabled: - self._validate_conftest_functions_are_defined() - def save(self): - logging.getLogger().info("Saving testes durations") - self._get_conftest_module().slow_first_save_durations(self.serialize()) + log_slow_first("Saving testes durations") + with open(SLOW_FIRST_PATH, 'w') as f: + f.write(self.serialize()) @staticmethod def load(): - logging.getLogger().info("Loading testes durations of previous run") - data = SlowFirst._get_conftest_module().slow_first_load_durations() - - if not data: + if os.path.exists(SLOW_FIRST_PATH): + with open(SLOW_FIRST_PATH, 'r') as f: + data = f.read() + log_slow_first("Loaded testes durations from previous run. Applying order") + else: + log_slow_first("No previous run found. Skipping order") return None return SlowFirst.deserialize(data) def serialize(self) -> str: - return json.dumps(list(map(Test.serialize, self._tests_by_name.values()))) + return json.dumps({ + 'format_version': FORMAT_VERSION, + 'tests': [test.serialize() for test in self._tests_by_name.values()] + }) @staticmethod - def deserialize(data: str): - return SlowFirst({test.name: test for test in map(Test.deserialize, json.loads(data))}) + def deserialize(data: str) -> "SlowFirst": + data = json.loads(data) + + if data['format_version'] != FORMAT_VERSION: + pytest.exit( + reason=f"[pytest-slow-first] The format version of {SLOW_FIRST_PATH} " + f"is not compatible with this version of pytest-slow-first. " + f"Please delete {SLOW_FIRST_PATH} and run tests again.", + returncode=ExitCode.USAGE_ERROR + ) + + return SlowFirst({test.nodeid: test for test in map(Test.deserialize, data['tests'])}) def set_duration(self, name: str, kind: str, duration: float): test = self._tests_by_name.get(name) @@ -79,37 +108,16 @@ def set_duration(self, name: str, kind: str, duration: float): test.set_duration(kind, duration) else: test = Test(name) - self._tests_by_name[test.name] = test + self._tests_by_name[test.nodeid] = test test.set_duration(kind, duration) - def get_order(self, test_name: str): + def get_order(self, test_name: str) -> float: test = self._tests_by_name.get(test_name) if test: return test.total_duration else: return 0 - @staticmethod - def _get_conftest_module(): - return importlib.import_module('conftest') - - @staticmethod - def _validate_conftest_functions_are_defined(): - try: - conftest = SlowFirst._get_conftest_module() - except ModuleNotFoundError as e: - raise ConftestModuleNotFound('slow_first plugin was not able to load conftest module') from e - - if not hasattr(conftest, 'slow_first_save_durations'): - raise SlowFirstRequiredFunctionNotImplemented( - 'slow_first_save_durations function is not defined in conftest.py' - ) - - if not hasattr(conftest, 'slow_first_load_durations'): - raise SlowFirstRequiredFunctionNotImplemented( - 'slow_first_load_durations function is not defined in conftest.py' - ) - def pytest_addoption(parser): group = parser.getgroup('slow-first') @@ -118,7 +126,7 @@ def pytest_addoption(parser): action='store_true', dest='slow_first', default=False, - help='If given, will enable tests sorting by durations from last run.' + help='Sort tests from slowest to fastest.' ) diff --git a/setup.py b/setup.py index 4f900f0..cb98420 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ def read(fname): setup( name='pytest-slow-first', - version='0.1.0', + version='1.0.0', author='João Vitor Silvestre', author_email='joao_vitor_silvestre@outlook.com', maintainer='João Vitor Silvestre', diff --git a/tests/test_slow_first.py b/tests/test_slow_first.py index 0a8045b..fe3d2f2 100644 --- a/tests/test_slow_first.py +++ b/tests/test_slow_first.py @@ -1,4 +1,6 @@ # -*- coding: utf-8 -*- +import json + from _pytest.config import ExitCode @@ -18,22 +20,9 @@ def test3(): sleep(0.2) """) - file_to_save = testdir.tmpdir.join('slow_first.json') + file_to_save = testdir.tmpdir.join('pytest-slow-first.json') - testdir.makeconftest(f""" - import os - import json - - def slow_first_save_durations(durations_data: str): - assert json.loads(durations_data) - with open('{file_to_save}', 'w') as f: - f.write(durations_data) - - def slow_first_load_durations(): - if os.path.exists('{file_to_save}'): - with open('{file_to_save}', 'r') as f: - return f.read() - """) + testdir.makeconftest("") # 1º theres no previous durention saved, the first run will run # tests in the order they are defined @@ -76,16 +65,21 @@ def slow_first_load_durations(): assert result.ret == 0 -def test_validate_user_implemented_functions_in_conftest(testdir): - testdir.makepyfile(""" - from time import sleep +def test_different_format(testdir): + from pytest_slow_first import SlowFirst + testdir.makepyfile(""" def test1(): - pass + sleep(0.1) """) + file_oprevious_run = testdir.tmpdir.join('pytest-slow-first.json') + with open(f'{file_oprevious_run}', 'w') as f: + json.dump({"format_version": "0.0.1", "tests": []}, f) + testdir.makeconftest("") + # 1º Must exit with error because the format version is not compatible result = testdir.runpytest('--slow-first', '-v') - assert result.ret == ExitCode.INTERNAL_ERROR + assert result.ret == ExitCode.USAGE_ERROR