From 325e04c7f3ed80b9993459f24d512947e44179bb Mon Sep 17 00:00:00 2001 From: Matthew Roeschke <10647082+mroeschke@users.noreply.github.com> Date: Wed, 22 Nov 2023 12:13:57 -1000 Subject: [PATCH] CI: Test clipboard on Linux via pyqt (#56084) --- .github/workflows/unit-tests.yml | 7 +- ci/deps/actions-310.yaml | 3 +- ci/deps/actions-311-downstream_compat.yaml | 3 +- ci/deps/actions-311.yaml | 3 +- ci/deps/actions-39-minimum_versions.yaml | 3 +- ci/deps/actions-39.yaml | 3 +- ci/deps/circle-310-arm64.yaml | 2 + doc/source/getting_started/install.rst | 2 +- environment.yml | 2 + pandas/compat/_optional.py | 2 +- pandas/tests/io/test_clipboard.py | 118 +++++++-------------- pyproject.toml | 4 +- requirements-dev.txt | 2 + scripts/generate_pip_deps_from_conda.py | 13 +-- scripts/validate_min_versions_in_sync.py | 8 +- 15 files changed, 73 insertions(+), 102 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index e156b1e0aeca2..33b6e7a8c2340 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -88,7 +88,6 @@ jobs: name: ${{ matrix.name || format('ubuntu-latest {0}', matrix.env_file) }} env: PATTERN: ${{ matrix.pattern }} - EXTRA_APT: ${{ matrix.extra_apt || '' }} LANG: ${{ matrix.lang || 'C.UTF-8' }} LC_ALL: ${{ matrix.lc_all || '' }} PANDAS_COPY_ON_WRITE: ${{ matrix.pandas_copy_on_write || '0' }} @@ -96,6 +95,8 @@ jobs: TEST_ARGS: ${{ matrix.test_args || '' }} PYTEST_WORKERS: 'auto' PYTEST_TARGET: ${{ matrix.pytest_target || 'pandas' }} + # Clipboard tests + QT_QPA_PLATFORM: offscreen concurrency: # https://github.community/t/concurrecy-not-work-for-push/183068/7 group: ${{ github.event_name == 'push' && github.run_number || github.ref }}-${{ matrix.env_file }}-${{ matrix.pattern }}-${{ matrix.extra_apt || '' }}-${{ matrix.pandas_copy_on_write || '' }} @@ -145,8 +146,8 @@ jobs: fetch-depth: 0 - name: Extra installs - # xsel for clipboard tests - run: sudo apt-get update && sudo apt-get install -y xsel ${{ env.EXTRA_APT }} + run: sudo apt-get update && sudo apt-get install -y ${{ matrix.extra_apt }} + if: ${{ matrix.extra_apt }} - name: Generate extra locales # These extra locales will be available for locale.setlocale() calls in tests diff --git a/ci/deps/actions-310.yaml b/ci/deps/actions-310.yaml index f03ee2d321529..7d08f2df5a8ca 100644 --- a/ci/deps/actions-310.yaml +++ b/ci/deps/actions-310.yaml @@ -15,6 +15,7 @@ dependencies: - pytest-cov - pytest-xdist>=2.2.0 - pytest-localserver>=0.7.1 + - pytest-qt>=4.2.0 - boto3 # required dependencies @@ -42,6 +43,7 @@ dependencies: - psycopg2>=2.9.6 - pyarrow>=10.0.1 - pymysql>=1.0.2 + - pyqt>=5.15.9 - pyreadstat>=1.2.0 - pytables>=3.8.0 - python-calamine>=0.1.6 @@ -58,5 +60,4 @@ dependencies: - pip: - adbc-driver-postgresql>=0.8.0 - adbc-driver-sqlite>=0.8.0 - - pyqt5>=5.15.8 - tzdata>=2022.7 diff --git a/ci/deps/actions-311-downstream_compat.yaml b/ci/deps/actions-311-downstream_compat.yaml index 7c1173a9c4d83..7a2bbe442cde7 100644 --- a/ci/deps/actions-311-downstream_compat.yaml +++ b/ci/deps/actions-311-downstream_compat.yaml @@ -16,6 +16,7 @@ dependencies: - pytest-cov - pytest-xdist>=2.2.0 - pytest-localserver>=0.7.1 + - pytest-qt>=4.2.0 - boto3 # required dependencies @@ -43,6 +44,7 @@ dependencies: - psycopg2>=2.9.6 - pyarrow>=10.0.1 - pymysql>=1.0.2 + - pyqt>=5.15.9 - pyreadstat>=1.2.0 - pytables>=3.8.0 - python-calamine>=0.1.6 @@ -73,5 +75,4 @@ dependencies: - adbc-driver-postgresql>=0.8.0 - adbc-driver-sqlite>=0.8.0 - dataframe-api-compat>=0.1.7 - - pyqt5>=5.15.8 - tzdata>=2022.7 diff --git a/ci/deps/actions-311.yaml b/ci/deps/actions-311.yaml index da73169513c0d..681345a34513d 100644 --- a/ci/deps/actions-311.yaml +++ b/ci/deps/actions-311.yaml @@ -15,6 +15,7 @@ dependencies: - pytest-cov - pytest-xdist>=2.2.0 - pytest-localserver>=0.7.1 + - pytest-qt>=4.2.0 - boto3 # required dependencies @@ -38,6 +39,7 @@ dependencies: - numexpr>=2.8.4 - odfpy>=1.4.1 - qtpy>=2.3.0 + - pyqt>=5.15.9 - openpyxl>=3.1.0 - psycopg2>=2.9.6 - pyarrow>=10.0.1 @@ -58,5 +60,4 @@ dependencies: - pip: - adbc-driver-postgresql>=0.8.0 - adbc-driver-sqlite>=0.8.0 - - pyqt5>=5.15.8 - tzdata>=2022.7 diff --git a/ci/deps/actions-39-minimum_versions.yaml b/ci/deps/actions-39-minimum_versions.yaml index 42729046c8d7f..2e3b03f63eb99 100644 --- a/ci/deps/actions-39-minimum_versions.yaml +++ b/ci/deps/actions-39-minimum_versions.yaml @@ -17,6 +17,7 @@ dependencies: - pytest-cov - pytest-xdist>=2.2.0 - pytest-localserver>=0.7.1 + - pytest-qt>=4.2.0 - boto3 # required dependencies @@ -44,6 +45,7 @@ dependencies: - psycopg2=2.9.6 - pyarrow=10.0.1 - pymysql=1.0.2 + - pyqt=5.15.9 - pyreadstat=1.2.0 - pytables=3.8.0 - python-calamine=0.1.6 @@ -61,5 +63,4 @@ dependencies: - adbc-driver-postgresql==0.8.0 - adbc-driver-sqlite==0.8.0 - dataframe-api-compat==0.1.7 - - pyqt5==5.15.8 - tzdata==2022.7 diff --git a/ci/deps/actions-39.yaml b/ci/deps/actions-39.yaml index 85bac1da098b8..d3e545b636de9 100644 --- a/ci/deps/actions-39.yaml +++ b/ci/deps/actions-39.yaml @@ -15,6 +15,7 @@ dependencies: - pytest-cov - pytest-xdist>=2.2.0 - pytest-localserver>=0.7.1 + - pytest-qt>=4.2.0 - boto3 # required dependencies @@ -42,6 +43,7 @@ dependencies: - psycopg2>=2.9.6 - pyarrow>=10.0.1 - pymysql>=1.0.2 + - pyqt>=5.15.9 - pyreadstat>=1.2.0 - pytables>=3.8.0 - python-calamine>=0.1.6 @@ -58,5 +60,4 @@ dependencies: - pip: - adbc-driver-postgresql>=0.8.0 - adbc-driver-sqlite>=0.8.0 - - pyqt5>=5.15.8 - tzdata>=2022.7 diff --git a/ci/deps/circle-310-arm64.yaml b/ci/deps/circle-310-arm64.yaml index 8f45666d3b124..0098406037876 100644 --- a/ci/deps/circle-310-arm64.yaml +++ b/ci/deps/circle-310-arm64.yaml @@ -15,6 +15,7 @@ dependencies: - pytest-cov - pytest-xdist>=2.2.0 - pytest-localserver>=0.7.1 + - pytest-qt>=4.2.0 - boto3 # required dependencies @@ -42,6 +43,7 @@ dependencies: - psycopg2>=2.9.6 - pyarrow>=10.0.1 - pymysql>=1.0.2 + - pyqt>=5.15.9 - pyreadstat>=1.2.0 - pytables>=3.8.0 - python-calamine>=0.1.6 diff --git a/doc/source/getting_started/install.rst b/doc/source/getting_started/install.rst index 27131dd113f1f..c048c71613b57 100644 --- a/doc/source/getting_started/install.rst +++ b/doc/source/getting_started/install.rst @@ -397,7 +397,7 @@ Installable with ``pip install "pandas[clipboard]"``. ========================= ================== =============== ============================================================= Dependency Minimum Version pip extra Notes ========================= ================== =============== ============================================================= -PyQt4/PyQt5 5.15.8 clipboard Clipboard I/O +PyQt4/PyQt5 5.15.9 clipboard Clipboard I/O qtpy 2.3.0 clipboard Clipboard I/O ========================= ================== =============== ============================================================= diff --git a/environment.yml b/environment.yml index 7558b8cf59a9f..4aa8073e93f6d 100644 --- a/environment.yml +++ b/environment.yml @@ -16,6 +16,8 @@ dependencies: - pytest>=7.3.2 - pytest-cov - pytest-xdist>=2.2.0 + - pytest-qt>=4.2.0 + - pyqt>=5.15.9 - coverage # required dependencies diff --git a/pandas/compat/_optional.py b/pandas/compat/_optional.py index 84cf7af0fe7a6..1354c0a6db7c5 100644 --- a/pandas/compat/_optional.py +++ b/pandas/compat/_optional.py @@ -52,7 +52,7 @@ "zstandard": "0.19.0", "tzdata": "2022.7", "qtpy": "2.3.0", - "pyqt5": "5.15.8", + "pyqt5": "5.15.9", } # A mapping from import name to package name (on PyPI) for packages where diff --git a/pandas/tests/io/test_clipboard.py b/pandas/tests/io/test_clipboard.py index 10e0467c5d74d..3d150aaa28eb4 100644 --- a/pandas/tests/io/test_clipboard.py +++ b/pandas/tests/io/test_clipboard.py @@ -1,13 +1,8 @@ -import os from textwrap import dedent import numpy as np import pytest -from pandas.compat import ( - is_ci_environment, - is_platform_mac, -) from pandas.errors import ( PyperclipException, PyperclipWindowsException, @@ -30,8 +25,7 @@ from pandas.io.clipboard import ( CheckedCall, _stringifyText, - clipboard_get, - clipboard_set, + init_qt_clipboard, ) @@ -205,60 +199,34 @@ def test_stringify_text(text): @pytest.fixture -def mock_clipboard(monkeypatch, request): - """Fixture mocking clipboard IO. - - This mocks pandas.io.clipboard.clipboard_get and - pandas.io.clipboard.clipboard_set. - - This uses a local dict for storing data. The dictionary - key used is the test ID, available with ``request.node.name``. - - This returns the local dictionary, for direct manipulation by - tests. - """ - # our local clipboard for tests - _mock_data = {} - - def _mock_set(data): - _mock_data[request.node.name] = data - - def _mock_get(): - return _mock_data[request.node.name] - - monkeypatch.setattr("pandas.io.clipboard.clipboard_set", _mock_set) - monkeypatch.setattr("pandas.io.clipboard.clipboard_get", _mock_get) - - yield _mock_data - +def set_pyqt_clipboard(monkeypatch): + qt_cut, qt_paste = init_qt_clipboard() + with monkeypatch.context() as m: + m.setattr(pd.io.clipboard, "clipboard_set", qt_cut) + m.setattr(pd.io.clipboard, "clipboard_get", qt_paste) + yield -@pytest.mark.clipboard -def test_mock_clipboard(mock_clipboard): - import pandas.io.clipboard - pandas.io.clipboard.clipboard_set("abc") - assert "abc" in set(mock_clipboard.values()) - result = pandas.io.clipboard.clipboard_get() - assert result == "abc" +@pytest.fixture +def clipboard(qapp): + clip = qapp.clipboard() + yield clip + clip.clear() @pytest.mark.single_cpu @pytest.mark.clipboard -@pytest.mark.usefixtures("mock_clipboard") +@pytest.mark.usefixtures("set_pyqt_clipboard") +@pytest.mark.usefixtures("clipboard") class TestClipboard: - def check_round_trip_frame(self, data, excel=None, sep=None, encoding=None): - data.to_clipboard(excel=excel, sep=sep, encoding=encoding) - result = read_clipboard(sep=sep or "\t", index_col=0, encoding=encoding) - tm.assert_frame_equal(data, result) - # Test that default arguments copy as tab delimited - def test_round_trip_frame(self, df): - self.check_round_trip_frame(df) - # Test that explicit delimiters are respected - @pytest.mark.parametrize("sep", ["\t", ",", "|"]) - def test_round_trip_frame_sep(self, df, sep): - self.check_round_trip_frame(df, sep=sep) + @pytest.mark.parametrize("sep", [None, "\t", ",", "|"]) + @pytest.mark.parametrize("encoding", [None, "UTF-8", "utf-8", "utf8"]) + def test_round_trip_frame_sep(self, df, sep, encoding): + df.to_clipboard(excel=None, sep=sep, encoding=encoding) + result = read_clipboard(sep=sep or "\t", index_col=0, encoding=encoding) + tm.assert_frame_equal(df, result) # Test white space separator def test_round_trip_frame_string(self, df): @@ -286,22 +254,21 @@ def test_copy_delim_warning(self, df): # delimited and excel="True" @pytest.mark.parametrize("sep", ["\t", None, "default"]) @pytest.mark.parametrize("excel", [True, None, "default"]) - def test_clipboard_copy_tabs_default(self, sep, excel, df, request, mock_clipboard): + def test_clipboard_copy_tabs_default(self, sep, excel, df, clipboard): kwargs = build_kwargs(sep, excel) df.to_clipboard(**kwargs) - assert mock_clipboard[request.node.name] == df.to_csv(sep="\t") + assert clipboard.text() == df.to_csv(sep="\t") # Tests reading of white space separated tables @pytest.mark.parametrize("sep", [None, "default"]) - @pytest.mark.parametrize("excel", [False]) - def test_clipboard_copy_strings(self, sep, excel, df): - kwargs = build_kwargs(sep, excel) + def test_clipboard_copy_strings(self, sep, df): + kwargs = build_kwargs(sep, False) df.to_clipboard(**kwargs) result = read_clipboard(sep=r"\s+") assert result.to_string() == df.to_string() assert df.shape == result.shape - def test_read_clipboard_infer_excel(self, request, mock_clipboard): + def test_read_clipboard_infer_excel(self, clipboard): # gh-19010: avoid warnings clip_kwargs = {"engine": "python"} @@ -312,7 +279,7 @@ def test_read_clipboard_infer_excel(self, request, mock_clipboard): 4\tHarry Carney """.strip() ) - mock_clipboard[request.node.name] = text + clipboard.setText(text) df = read_clipboard(**clip_kwargs) # excel data is parsed correctly @@ -326,7 +293,7 @@ def test_read_clipboard_infer_excel(self, request, mock_clipboard): 3 4 """.strip() ) - mock_clipboard[request.node.name] = text + clipboard.setText(text) res = read_clipboard(**clip_kwargs) text = dedent( @@ -336,16 +303,16 @@ def test_read_clipboard_infer_excel(self, request, mock_clipboard): 3 4 """.strip() ) - mock_clipboard[request.node.name] = text + clipboard.setText(text) exp = read_clipboard(**clip_kwargs) tm.assert_frame_equal(res, exp) - def test_infer_excel_with_nulls(self, request, mock_clipboard): + def test_infer_excel_with_nulls(self, clipboard): # GH41108 text = "col1\tcol2\n1\tred\n\tblue\n2\tgreen" - mock_clipboard[request.node.name] = text + clipboard.setText(text) df = read_clipboard() df_expected = DataFrame( data={"col1": [1, None, 2], "col2": ["red", "blue", "green"]} @@ -376,10 +343,10 @@ def test_infer_excel_with_nulls(self, request, mock_clipboard): ), ], ) - def test_infer_excel_with_multiindex(self, request, mock_clipboard, multiindex): + def test_infer_excel_with_multiindex(self, clipboard, multiindex): # GH41108 - mock_clipboard[request.node.name] = multiindex[0] + clipboard.setText(multiindex[0]) df = read_clipboard() df_expected = DataFrame( data={"col1": [1, None, 2], "col2": ["red", "blue", "green"]}, @@ -397,26 +364,17 @@ def test_invalid_encoding(self, df): with pytest.raises(NotImplementedError, match=msg): read_clipboard(encoding="ascii") - @pytest.mark.parametrize("enc", ["UTF-8", "utf-8", "utf8"]) - def test_round_trip_valid_encodings(self, enc, df): - self.check_round_trip_frame(df, encoding=enc) - - @pytest.mark.single_cpu @pytest.mark.parametrize("data", ["\U0001f44d...", "Ωœ∑`...", "abcd..."]) - @pytest.mark.xfail( - (os.environ.get("DISPLAY") is None and not is_platform_mac()) - or is_ci_environment(), - reason="Cannot pass if a headless system is not put in place with Xvfb", - strict=not is_ci_environment(), # Flaky failures in the CI - ) def test_raw_roundtrip(self, data): # PR #25040 wide unicode wasn't copied correctly on PY3 on windows - clipboard_set(data) - assert data == clipboard_get() + df = DataFrame({"data": [data]}) + df.to_clipboard() + result = read_clipboard() + tm.assert_frame_equal(df, result) @pytest.mark.parametrize("engine", ["c", "python"]) def test_read_clipboard_dtype_backend( - self, request, mock_clipboard, string_storage, dtype_backend, engine + self, clipboard, string_storage, dtype_backend, engine ): # GH#50502 if string_storage == "pyarrow" or dtype_backend == "pyarrow": @@ -433,7 +391,7 @@ def test_read_clipboard_dtype_backend( text = """a,b,c,d,e,f,g,h,i x,1,4.0,x,2,4.0,,True,False y,2,5.0,,,,,False,""" - mock_clipboard[request.node.name] = text + clipboard.setText(text) with pd.option_context("mode.string_storage", string_storage): result = read_clipboard(sep=",", dtype_backend=dtype_backend, engine=engine) diff --git a/pyproject.toml b/pyproject.toml index c9eb8d9ac4e40..65c647e439cb1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,7 +83,7 @@ html = ['beautifulsoup4>=4.11.2', 'html5lib>=1.1', 'lxml>=4.9.2'] xml = ['lxml>=4.9.2'] plot = ['matplotlib>=3.6.3'] output-formatting = ['jinja2>=3.1.2', 'tabulate>=0.9.0'] -clipboard = ['PyQt5>=5.15.8', 'qtpy>=2.3.0'] +clipboard = ['PyQt5>=5.15.9', 'qtpy>=2.3.0'] compression = ['zstandard>=0.19.0'] consortium-standard = ['dataframe-api-compat>=0.1.7'] all = ['adbc-driver-postgresql>=0.8.0', @@ -109,7 +109,7 @@ all = ['adbc-driver-postgresql>=0.8.0', 'psycopg2>=2.9.6', 'pyarrow>=10.0.1', 'pymysql>=1.0.2', - 'PyQt5>=5.15.8', + 'PyQt5>=5.15.9', 'pyreadstat>=1.2.0', 'pytest>=7.3.2', 'pytest-xdist>=2.2.0', diff --git a/requirements-dev.txt b/requirements-dev.txt index d074949b22df1..fa3ef1c214a14 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -9,6 +9,8 @@ meson-python==0.13.1 pytest>=7.3.2 pytest-cov pytest-xdist>=2.2.0 +pytest-qt>=4.2.0 +PyQt5>=5.15.9 coverage python-dateutil numpy<2 diff --git a/scripts/generate_pip_deps_from_conda.py b/scripts/generate_pip_deps_from_conda.py index 49fecca253ba4..5fcf09cd073fe 100755 --- a/scripts/generate_pip_deps_from_conda.py +++ b/scripts/generate_pip_deps_from_conda.py @@ -25,12 +25,13 @@ EXCLUDE = {"python", "c-compiler", "cxx-compiler"} REMAP_VERSION = {"tzdata": "2022.7"} -RENAME = { +CONDA_TO_PIP = { "pytables": "tables", "psycopg2": "psycopg2-binary", "dask-core": "dask", "seaborn-base": "seaborn", "sqlalchemy": "SQLAlchemy", + "pyqt": "PyQt5", } @@ -40,7 +41,7 @@ def conda_package_to_pip(package: str): In most cases they are the same, those are the exceptions: - Packages that should be excluded (in `EXCLUDE`) - - Packages that should be renamed (in `RENAME`) + - Packages that should be renamed (in `CONDA_TO_PIP`) - A package requiring a specific version, in conda is defined with a single equal (e.g. ``pandas=1.0``) and in pip with two (e.g. ``pandas==1.0``) """ @@ -53,14 +54,14 @@ def conda_package_to_pip(package: str): return if pkg in REMAP_VERSION: return "".join((pkg, compare, REMAP_VERSION[pkg])) - if pkg in RENAME: - return "".join((RENAME[pkg], compare, version)) + if pkg in CONDA_TO_PIP: + return "".join((CONDA_TO_PIP[pkg], compare, version)) if package in EXCLUDE: return - if package in RENAME: - return RENAME[package] + if package in CONDA_TO_PIP: + return CONDA_TO_PIP[package] return package diff --git a/scripts/validate_min_versions_in_sync.py b/scripts/validate_min_versions_in_sync.py index d5c491f6ebc12..7dd3e96e6ec18 100755 --- a/scripts/validate_min_versions_in_sync.py +++ b/scripts/validate_min_versions_in_sync.py @@ -26,7 +26,7 @@ from typing import Any -from scripts.generate_pip_deps_from_conda import RENAME +from scripts.generate_pip_deps_from_conda import CONDA_TO_PIP DOC_PATH = pathlib.Path("doc/source/getting_started/install.rst").resolve() CI_PATH = next( @@ -36,7 +36,7 @@ SETUP_PATH = pathlib.Path("pyproject.toml").resolve() YAML_PATH = pathlib.Path("ci/deps") ENV_PATH = pathlib.Path("environment.yml") -EXCLUDE_DEPS = {"tzdata", "blosc", "pandas-gbq"} +EXCLUDE_DEPS = {"tzdata", "blosc", "pandas-gbq", "pyqt", "pyqt5"} EXCLUSION_LIST = frozenset(["python=3.8[build=*_pypy]"]) # pandas package is not available # in pre-commit environment @@ -169,8 +169,8 @@ def pin_min_versions_to_yaml_file( old_dep = yaml_package if yaml_versions is not None: old_dep = old_dep + ", ".join(yaml_versions) - if RENAME.get(yaml_package, yaml_package) in toml_map: - min_dep = toml_map[RENAME.get(yaml_package, yaml_package)] + if CONDA_TO_PIP.get(yaml_package, yaml_package) in toml_map: + min_dep = toml_map[CONDA_TO_PIP.get(yaml_package, yaml_package)] elif yaml_package in toml_map: min_dep = toml_map[yaml_package] else: