diff --git a/.github/actions/setup-test-env/action.yml b/.github/actions/setup-test-env/action.yml new file mode 100644 index 0000000..0680feb --- /dev/null +++ b/.github/actions/setup-test-env/action.yml @@ -0,0 +1,75 @@ +name: setup-test-env +description: "Setup test environment" + +inputs: + python-version: + default: "3.10" + description: "Python version to test." + +outputs: + python-version: + description: "Installed Python version." + value: ${{ inputs.python-version }} + bokeh-version: + description: "Installed bokeh version." + value: ${{ steps.install.outputs.bokeh-version }} + +runs: + using: composite + + steps: + - name: Setup miniforge + uses: conda-incubator/setup-miniconda@v3 + with: + miniforge-version: latest + activate-environment: bokeh-fastapi-test + python-version: ${{ inputs.python-version }} + + - shell: bash -el {0} + run: | + # Display conda info + conda info + + # - name: Restore conda environment +# id: cache +# uses: actions/cache@v4 +# with: +# path: ${{ env.CONDA }}/envs +# key: +# env-${{ runner.os }}-${{ runner.arch }}-${{ inputs.python-version +# }}|${{ hashFiles('environment-dev.yml', 'pyproject.toml') }} +# restore-keys: | +# env-${{ runner.os }}-${{ runner.arch }}-${{ inputs.python-version }} + + - id: install + shell: bash -el {0} + run: | + # Install bokeh-fastapi + conda install pip + pip install . + BOKEH_VERSION=$(conda list --json | jq --raw-output '.[] | select(.name=="bokeh").version') + echo "bokeh-version=${BOKEH_VERSION}" | tee --append "${GITHUB_OUTPUT}" + + - name: Checkout repository + uses: actions/checkout@v4 + with: + repository: bokeh/bokeh + path: ./tests/bokeh + ref: ${{ steps.install.outputs.bokeh-version }} + + - # if: steps.cache.outputs.cache-hit != 'true' + shell: bash + run: | + # Update conda environment if necessary + conda env update --name bokeh-fastapi-test --file ./tests/bokeh/conda/environment-test-${{ inputs.python-version }}.yml + + - shell: bash -el {0} + run: | + # Display test environment + conda list + + - shell: bash + working-directory: ./tests + run: | + # Apply test patches + ./setup.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..70c1d5d --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,60 @@ +name: test + +on: + pull_request: + +jobs: + detect-usage: + runs-on: ubuntu-latest + defaults: + run: + shell: bash -el {0} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup environment + id: setup + uses: ./.github/actions/setup-test-env + + - name: Restore test cache + id: cache + uses: actions/cache@v4 + with: + path: ./.bokeh_fastapi_cache + key: test-${{ runner.os }}-${{ runner.arch }}|${{ steps.setup.outputs.python-version }}-${{ steps.setup.outputs.bokeh-version }} + restore-keys: test-${{ runner.os }}-${{ runner.arch }} + + - name: Create test cache + if: steps.cache.outputs.cache-hit != 'true' + working-directory: ./tests/bokeh + run: pytest --no-header --no-summary --quiet --tb=no tests/unit || true + run: + needs: + - detect-usage + + runs-on: ubuntu-latest + defaults: + run: + shell: bash -el {0} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup environment + id: setup + uses: ./.github/actions/setup-test-env + + - name: Restore test cache + id: cache + uses: actions/cache@v4 + with: + path: ./.bokeh_fastapi_cache + key: test-${{ runner.os }}-${{ runner.arch }}|${{ steps.setup.outputs.python-version }}-${{ steps.setup.outputs.bokeh-version }} + restore-keys: test-${{ runner.os }}-${{ runner.arch }} + + - name: Run tests + working-directory: ./tests/bokeh + run: pytest --tb=short diff --git a/.gitignore b/.gitignore index 6bf9b98..207e207 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ src/bokeh_fastapi/_version.py +tests/bokeh +.bokeh_fastapi_cache + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/pyproject.toml b/pyproject.toml index 224f75b..6e15e33 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ requires = [ build-backend = "setuptools.build_meta" [project] -name = "bokeh_fastapi" +name = "bokeh-fastapi" description = "Compatibility layer between Bokeh and FastAPI" readme = "README.md" authors = [ diff --git a/tests/conftest_patch.py b/tests/conftest_patch.py new file mode 100644 index 0000000..db30f05 --- /dev/null +++ b/tests/conftest_patch.py @@ -0,0 +1,111 @@ +import contextlib +import json +import sys +import unittest.mock +from pathlib import Path + +import bokeh +import pytest +from bokeh.server.tornado import BokehTornado +from bokeh_fastapi import BokehFastAPI + + +def cache_path() -> Path: + folder = Path(__file__).parents[3] / ".bokeh_fastapi_cache" + folder.mkdir(parents=True, exist_ok=True) + name = f"python-{sys.version_info[0]}.{sys.version_info[1]}-bokeh-{bokeh.__version__}.json" + return folder / name + + +TESTS = [] +PATCHES = {} + + +def update_required_patches(modules): + global PATCHES + for module in modules: + if module in PATCHES: + continue + + for name, obj in module.__dict__.items(): + if isinstance(obj, type) and issubclass(obj, BokehTornado): + PATCHES[module.__name__] = name + break + + +class BokehFastAPICompat(BokehFastAPI): + pass + # def __init__(self, *args, **kwargs): + # kwargs["websocket_origins"] = kwargs.pop("extra_websocket_origins") + # kwargs.pop("absolute_url", None) + # kwargs.pop("index", None) + # kwargs.pop("websocket_max_message_size_bytes", None) + # kwargs.pop("extra_patterns", None) + # super().__init__(*args, **kwargs) + # + # def initialize(self, *args, **kwargs): + # pass + # + # def start(self, *args, **kwargs): + # pass + # + # def __call__(self, *args, **kwargs): + # pass + + +@pytest.hookimpl(wrapper=True) +def pytest_collection_modifyitems(config, items): + path = cache_path() + if path.exists(): + with open(cache_path()) as file: + cache = json.load(file) + + tests = set(cache["tests"]) + select = [] + deselect = [] + for item in items: + (select if item.nodeid in tests else deselect).append(item) + items[:] = select + config.hook.pytest_deselected(items=deselect) + + for module_name, obj_name in cache["patches"].items(): + unittest.mock.patch( + f"{module_name}.{obj_name}", new=BokehFastAPICompat + ).start() + else: + update_required_patches({item.module for item in items}) + + return (yield) + + +def pytest_terminal_summary(): + path = cache_path() + if not path.exists(): + with open(path, "w") as file: + json.dump({"patches": PATCHES, "tests": TESTS}, file, indent=2) + + +@pytest.fixture(autouse=True) +def detect_bokeh_tornado_usage(request): + update_required_patches( + [ + module + for name, module in sys.modules.items() + if (name == "bokeh" or name.startswith("bokeh.")) + and name != "bokeh.server.tornado" + ] + ) + + with contextlib.ExitStack() as stack: + spies = [ + stack.enter_context( + unittest.mock.patch(f"{module_name}.{obj_name}", wraps=BokehTornado) + ) + for module_name, obj_name in PATCHES.items() + ] + + yield + + global TESTS + if any(spy.called for spy in spies): + TESTS.append(request.node.nodeid) diff --git a/tests/setup.sh b/tests/setup.sh new file mode 100755 index 0000000..5170e37 --- /dev/null +++ b/tests/setup.sh @@ -0,0 +1,9 @@ +TESTS_DIR='./bokeh/tests' +CONFTEST="${TESTS_DIR}/conftest.py" +BACKUP="${TESTS_DIR}/conftest.py.bak" + +if [[ ! -f "${BACKUP}" ]]; then + cp "${CONFTEST}" "${BACKUP}" +fi + +cat "${BACKUP}" './conftest_patch.py' > "${CONFTEST}"