diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 087b5621..abbee400 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -51,27 +51,29 @@ jobs: - name: Install uv and Python uses: astral-sh/setup-uv@v5 with: - python-version: "3.9" + # Match the Python the with-connect Connect image provides (3.14): + # rsconnect records the client Python in the deploy manifest and + # Connect must have a matching Local environment to build the + # content. 3.14 also satisfies the with-connect tool's own + # requires-python (>=3.13), so no separate UV_PYTHON override. + python-version: "3.14" - name: Install dependencies run: | uv pip install --upgrade pip uv pip install ".[dev]" uv pip install --upgrade git+https://github.com/rstudio/vetiver-python@${{ github.sha }} echo {{ github.sha }} - - name: run Connect - run: | - docker compose up --build -d - pip freeze > requirements.txt - make dev - cat requirements.txt - env: - RSC_LICENSE: ${{ secrets.RSC_LICENSE }} - GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} - - # NOTE: edited to run checks for python package - - name: Run tests - run: | - pytest vetiver -m 'rsc_test' + - name: Run Connect integration tests + uses: posit-dev/with-connect@0783dabdd24e360e985a4588ce1239c3dc31c542 + with: + license: ${{ secrets.RSC_LICENSE }} + # Plain freeze (unlike the Makefile/weekly jobs, which filter): here + # vetiver is installed from git+...@, so the freeze yields an + # installable git URL and Connect deploys THIS PR's vetiver. Filtering + # would deploy released vetiver and miss PR-level serving regressions. + command: | + pip freeze > requirements.txt + pytest vetiver/tests/test_rsconnect.py -m 'rsc_test' test-docker: name: "Test Docker" @@ -129,7 +131,9 @@ jobs: run: | python -m pip install --upgrade pip python -m pip install -e .[dev] - python -m pip install 'pydantic<2.0.0' + # FastAPI >=0.116 unconditionally imports `IncEx` from pydantic v2, + # so pin to the last release that still supports pydantic v1. + python -m pip install 'pydantic<2.0.0' 'fastapi<0.116' - name: Run tests run: | diff --git a/.github/workflows/weekly.yml b/.github/workflows/weekly.yml index a2c89ce6..8f8acf6d 100644 --- a/.github/workflows/weekly.yml +++ b/.github/workflows/weekly.yml @@ -37,14 +37,6 @@ jobs: python -m pip install '.[all]' python -m pip install --upgrade git+https://github.com/rstudio/pins-python - - name: run Connect - run: | - docker compose up --build -d - make dev - env: - RSC_LICENSE: ${{ secrets.RSC_LICENSE }} - GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} - - name: Run tests run: | make test @@ -84,14 +76,6 @@ jobs: python -m pip install .[dev] python -m pip install --upgrade git+https://github.com/rstudio/pins-python - - name: run Connect - run: | - docker compose up --build -d - make dev - env: - RSC_LICENSE: ${{ secrets.RSC_LICENSE }} - GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} - - name: Run Tests run: | make test @@ -103,7 +87,7 @@ jobs: strategy: matrix: # Minimum and Maximum supported versions - python-version: ['3.9'] + python-version: ['3.14'] steps: - name: Get latest release with tag from GitHub API @@ -129,19 +113,28 @@ jobs: python -m pip install --upgrade pip python -m pip install .[dev] python -m pip install --upgrade git+https://github.com/rstudio/rsconnect-python - pip freeze > requirements.txt - - name: run Connect - run: | - docker compose up --build -d - make dev - env: - RSC_LICENSE: ${{ secrets.RSC_LICENSE }} - GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} - - - name: Run Tests - run: | - pytest -m 'rsc_test' + - name: Run Connect integration tests + uses: posit-dev/with-connect@0783dabdd24e360e985a4588ce1239c3dc31c542 + with: + license: ${{ secrets.RSC_LICENSE }} + command: | + pip freeze | grep -v '^vetiver' | grep -v '^-e ' > requirements.txt + echo 'vetiver' >> requirements.txt + pytest vetiver/tests/test_rsconnect.py -m 'rsc_test' + + - name: Open issue on failure + if: failure() && github.event_name == 'schedule' + uses: actions/github-script@v7 + with: + script: | + github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `Weekly canary failed: ${context.job}`, + body: `The weekly Connect canary failed on run ${context.runId}. This often means a change in rsconnect-python \`main\` broke vetiver. See https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, + labels: ["ci-failure"] + }) vetiver_latest_rsconnect_latest: name: 'vetiver latest, rsconnect latest' @@ -164,16 +157,25 @@ jobs: python -m pip install --upgrade pip python -m pip install '.[all]' python -m pip install --upgrade git+https://github.com/rstudio/rsconnect-python - pip freeze > requirements.txt - - - name: run Connect - run: | - docker compose up --build -d - make dev - env: - RSC_LICENSE: ${{ secrets.RSC_LICENSE }} - GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} - - name: Run Tests - run: | - pytest -m 'rsc_test' + - name: Run Connect integration tests + uses: posit-dev/with-connect@0783dabdd24e360e985a4588ce1239c3dc31c542 + with: + license: ${{ secrets.RSC_LICENSE }} + command: | + pip freeze | grep -v '^vetiver' | grep -v '^-e ' > requirements.txt + echo 'vetiver' >> requirements.txt + pytest vetiver/tests/test_rsconnect.py -m 'rsc_test' + + - name: Open issue on failure + if: failure() && github.event_name == 'schedule' + uses: actions/github-script@v7 + with: + script: | + github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `Weekly canary failed: ${context.job}`, + body: `The weekly Connect canary failed on run ${context.runId}. This often means a change in rsconnect-python \`main\` broke vetiver. See https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, + labels: ["ci-failure"] + }) diff --git a/.gitignore b/.gitignore index c546a16a..884efd2c 100644 --- a/.gitignore +++ b/.gitignore @@ -149,3 +149,6 @@ NOTES.md /.quarto/ /.luarc.json + +# Local Connect license for with-connect integration tests +rstudio-connect.lic diff --git a/Makefile b/Makefile index 2350c8c2..1def8eb3 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,6 @@ .PHONY: clean-pyc clean-build clean docs UNAME := $(shell uname) -RSC_API_KEYS=vetiver/tests/rsconnect_api_keys.json - ifeq ($(UNAME), Darwin) BROWSER := open else @@ -19,9 +17,6 @@ help: @echo "coverage - check code coverage quickly with the default Python" @echo "docs - generate HTML documentation, including API docs" @echo "release - package and upload a release" - @echo "dev - generate Connect API keys" - @echo "dev-start - start up development Connect in Docker" - @echo "dev-stop - stop Connect dev container" clean: clean-build clean-pyc clean-test @@ -47,7 +42,10 @@ test-pdb: clean-test pytest -m 'not rsc_test and not docker' --pdb test-rsc: clean-test - pytest + uv run pip freeze | grep -v '^vetiver' | grep -v '^-e ' > requirements.txt + echo 'vetiver' >> requirements.txt + uvx --from git+https://github.com/posit-dev/with-connect@0783dabdd24e360e985a4588ce1239c3dc31c542 \ + with-connect -- uv run --with pytest pytest vetiver/tests/test_rsconnect.py -m 'rsc_test' coverage: coverage report -m @@ -60,21 +58,6 @@ docs doc documentation: release: dist twine upload dist/* -dev: vetiver/tests/rsconnect_api_keys.json - -dev-start: - docker compose up -d - docker compose exec -T rsconnect bash < script/setup-rsconnect/add-users.sh - # curl fails with error 52 without a short sleep.... - sleep 5 - curl -s --retry 10 --retry-connrefused http://localhost:3939 - -dev-stop: - docker compose down - rm -f $(RSC_API_KEYS) - typecheck: pyright -$(RSC_API_KEYS): dev-start - python script/setup-rsconnect/dump_api_keys.py $@ diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 4262845c..00000000 --- a/docker-compose.yml +++ /dev/null @@ -1,15 +0,0 @@ -services: - - rsconnect: - image: rstudio/rstudio-connect:latest - restart: always - ports: - - 3939:3939 - volumes: - - $PWD/script/setup-rsconnect/users.txt:/etc/users.txt - - $PWD/script/setup-rsconnect/rstudio-connect.gcfg:/etc/rstudio-connect/rstudio-connect.gcfg - # by default, mysql rounds to 4 decimals, but tests require more precision - privileged: true - environment: - RSTUDIO_CONNECT_HASTE: "enabled" - RSC_LICENSE: ${RSC_LICENSE} diff --git a/pyproject.toml b/pyproject.toml index 52365ba4..abf37965 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,9 @@ dev = [ ] docs = [ "quartodoc", + # quartodoc 0.11.x is incompatible with the griffe 2.x rewrite + # (it imports griffe.loader / passes removed parse_numpy kwargs). + "griffe<2", # quarto render dependencies "jupyter" ] diff --git a/script/setup-rsconnect/add-users.sh b/script/setup-rsconnect/add-users.sh deleted file mode 100644 index 1df8c7f4..00000000 --- a/script/setup-rsconnect/add-users.sh +++ /dev/null @@ -1 +0,0 @@ -awk ' { system("useradd -m -s /bin/bash "$1); system("echo \""$1":"$2"\" | chpasswd"); system("id "$1) } ' /etc/users.txt diff --git a/script/setup-rsconnect/dump_api_keys.py b/script/setup-rsconnect/dump_api_keys.py deleted file mode 100644 index eebef59f..00000000 --- a/script/setup-rsconnect/dump_api_keys.py +++ /dev/null @@ -1,21 +0,0 @@ -import json -import sys - -from pins.rsconnect.api import _HackyConnect - -OUT_FILE = sys.argv[1] - - -def get_api_key(user, password, email): - rsc = _HackyConnect("http://localhost:3939") - - return rsc.create_first_admin(user, password, email).api_key - - -api_keys = { - "admin": get_api_key("admin", "admin0", "admin@example.com"), - "susan": get_api_key("susan", "susan", "susan@example.com"), - "derek": get_api_key("derek", "derek", "derek@example.com"), -} - -json.dump(api_keys, open(OUT_FILE, "w")) diff --git a/script/setup-rsconnect/rstudio-connect.gcfg b/script/setup-rsconnect/rstudio-connect.gcfg deleted file mode 100644 index 98d0add0..00000000 --- a/script/setup-rsconnect/rstudio-connect.gcfg +++ /dev/null @@ -1,22 +0,0 @@ -[Server] -DataDir = /data -Address = http://localhost:3939 - -[HTTP] -Listen = :3939 - -[Authentication] -Provider = pam - -[Authorization] -DefaultUserRole = publisher - -[Python] -Enabled = true -Executable = /opt/python/3.9.5/bin/python - -[RPackageRepository "CRAN"] -URL = https://packagemanager.rstudio.com/cran/__linux__/bionic/latest - -[RPackageRepository "RSPM"] -URL = https://packagemanager.rstudio.com/cran/__linux__/bionic/latest diff --git a/script/setup-rsconnect/users.txt b/script/setup-rsconnect/users.txt deleted file mode 100644 index dd4ec359..00000000 --- a/script/setup-rsconnect/users.txt +++ /dev/null @@ -1,4 +0,0 @@ -admin admin0 -test test -susan susan -derek derek diff --git a/vetiver/data/__init__.py b/vetiver/data/__init__.py index 6e4bfd32..48ca60a7 100644 --- a/vetiver/data/__init__.py +++ b/vetiver/data/__init__.py @@ -11,10 +11,11 @@ def __dir__(): def _load_data_csv(name): import pandas as pd - import pkg_resources + from importlib.resources import files - fname = pkg_resources.resource_filename("vetiver.data", f"{name}.csv") - return pd.read_csv(fname) + fname = files("vetiver.data").joinpath(f"{name}.csv") + with fname.open("rb") as f: + return pd.read_csv(f) def __getattr__(name): diff --git a/vetiver/monitor.py b/vetiver/monitor.py index 24cd8a61..82a1505c 100644 --- a/vetiver/monitor.py +++ b/vetiver/monitor.py @@ -1,6 +1,5 @@ import plotly.express as px import pandas as pd -import numpy as np from datetime import timedelta @@ -55,7 +54,7 @@ def compute_metrics( df = data[[truth, estimate, date_var]].copy() - if not np.issubdtype(df[date_var], np.datetime64): + if not pd.api.types.is_datetime64_any_dtype(df[date_var]): df[date_var] = pd.to_datetime(df[date_var]) df = df.set_index(date_var).sort_index() diff --git a/vetiver/server.py b/vetiver/server.py index a5be4207..2c6d5679 100644 --- a/vetiver/server.py +++ b/vetiver/server.py @@ -394,8 +394,9 @@ def predict(endpoint, data: Union[dict, pd.DataFrame, pd.Series], **kw) -> pd.Da # TO DO: dispatch if isinstance(data, pd.DataFrame): + headers = {"Content-Type": "application/json", **kw.pop("headers", {})} response = requester.post( - endpoint, data=data.to_json(orient="records"), **kw + endpoint, data=data.to_json(orient="records"), headers=headers, **kw ) # TO DO: httpx deprecating data in favor of content for TestClient elif isinstance(data, pd.Series): response = requester.post(endpoint, json=[data.to_dict()], **kw) diff --git a/vetiver/tests/rsconnect_api_keys.json b/vetiver/tests/rsconnect_api_keys.json deleted file mode 100644 index 594a58c3..00000000 --- a/vetiver/tests/rsconnect_api_keys.json +++ /dev/null @@ -1 +0,0 @@ -{"admin": "iv6yzMB4GwhQIXjI3lOWG2T21N02C9SU", "susan": "llQqii6bHqV5R9eMN7qpTvJqr3xtnRmy", "derek": "FkakZvdVk2ApxDVkbEn6u2g0eSul4tE6"} diff --git a/vetiver/tests/test_monitor.py b/vetiver/tests/test_monitor.py index b0f875e0..4a9386a2 100644 --- a/vetiver/tests/test_monitor.py +++ b/vetiver/tests/test_monitor.py @@ -9,7 +9,7 @@ import pytest -rng = pd.date_range("1/1/2012", periods=10, freq="S") +rng = pd.date_range("1/1/2012", periods=10, freq="s") new = dict(x=range(len(rng)), y=range(len(rng))) df = pd.DataFrame(new, index=rng) td = timedelta(seconds=2) diff --git a/vetiver/tests/test_prepare_docker.py b/vetiver/tests/test_prepare_docker.py index 0b50f35f..97ed2f58 100644 --- a/vetiver/tests/test_prepare_docker.py +++ b/vetiver/tests/test_prepare_docker.py @@ -32,7 +32,17 @@ def create_vetiver_model(): return vetiver.VetiverModel(model.fit(X, y), "model", prototype_data=X) -@pytest.fixture(scope="module") +@pytest.fixture(autouse=True) +def _restore_fs_protocol(): + # board.fs is a cached fsspec LocalFileSystem singleton; the tests below + # mutate its `protocol` to simulate cloud backends. Restore it afterwards + # so the mutation does not leak into other tests (e.g. test_write_app). + fs = pins.board_temp(allow_pickle_read=True).fs + original = fs.protocol + yield + fs.protocol = original + + def test_warning_if_no_protocol(create_vetiver_model): with pytest.warns(UserWarning): board = pins.board_temp(allow_pickle_read=True) @@ -49,7 +59,6 @@ def test_warning_if_no_protocol(create_vetiver_model): (("gcs", "gs"), "gcsfs"), ], ) -@pytest.fixture(scope="module") def test_get_board_pkgs(prot, output, create_vetiver_model): board = pins.board_temp(allow_pickle_read=True) board.fs.protocol = prot diff --git a/vetiver/tests/test_rsconnect.py b/vetiver/tests/test_rsconnect.py index adeafae3..520b2b45 100644 --- a/vetiver/tests/test_rsconnect.py +++ b/vetiver/tests/test_rsconnect.py @@ -1,40 +1,31 @@ +import os + +import numpy as np +import pandas as pd import pytest -import json import sklearn -import pins -import pandas as pd -import numpy as np +import pins from pins.boards import BoardRsConnect from pins.rsconnect.api import RsConnectApi from pins.rsconnect.fs import RsConnectFs -from rsconnect.api import RSConnectServer, RSConnectClient +from rsconnect.api import RSConnectClient, RSConnectServer import vetiver -RSC_SERVER_URL = "http://localhost:3939" -RSC_KEYS_FNAME = "vetiver/tests/rsconnect_api_keys.json" +RSC_SERVER_URL = os.environ.get("CONNECT_SERVER") +RSC_API_KEY = os.environ.get("CONNECT_API_KEY") pytestmark = pytest.mark.rsc_test # noqa -def get_key(name): - with open(RSC_KEYS_FNAME) as f: - api_key = json.load(f)[name] - return api_key - +def _require_connect(): + if not RSC_SERVER_URL or not RSC_API_KEY: + pytest.skip("CONNECT_SERVER and CONNECT_API_KEY must be set (run via with-connect)") -def rsc_from_key(name): - with open(RSC_KEYS_FNAME) as f: - api_key = json.load(f)[name] - return RsConnectApi(RSC_SERVER_URL, api_key) - -def rsc_fs_from_key(name): - - rsc = rsc_from_key(name) - - return RsConnectFs(rsc) +def _api(): + return RsConnectApi(RSC_SERVER_URL, RSC_API_KEY) def rsc_delete_user_content(rsc): @@ -45,52 +36,55 @@ def rsc_delete_user_content(rsc): @pytest.fixture(scope="function") -def rsc_short(): - # tears down content after each test - fs_susan = rsc_fs_from_key("susan") +def username(): + _require_connect() + return _api().get_user()["username"] - # delete any content that might already exist - rsc_delete_user_content(fs_susan.api) - yield BoardRsConnect( - "", fs_susan, allow_pickle_read=True - ) # fs_susan.ls to list content - - rsc_delete_user_content(fs_susan.api) +@pytest.fixture(scope="function") +def rsc_admin(): + # tears down content after each test + _require_connect() + fs = RsConnectFs(_api()) + rsc_delete_user_content(fs.api) + yield BoardRsConnect("", fs, allow_pickle_read=True) + rsc_delete_user_content(fs.api) -def test_deploy(rsc_short): +def test_deploy(rsc_admin, username): np.random.seed(500) # Load data, model X_df, y = vetiver.mock.get_mock_data() model = vetiver.mock.get_mock_model().fit(X_df, y) - v = vetiver.VetiverModel(model=model, prototype_data=X_df, model_name="susan/model") + pin_name = f"{username}/model" + v = vetiver.VetiverModel(model=model, prototype_data=X_df, model_name=pin_name) board = pins.board_connect( - server_url=RSC_SERVER_URL, api_key=get_key("susan"), allow_pickle_read=True + server_url=RSC_SERVER_URL, api_key=RSC_API_KEY, allow_pickle_read=True ) vetiver.vetiver_pin_write(board=board, model=v) - connect_server = RSConnectServer(url=RSC_SERVER_URL, api_key=get_key("susan")) - assert isinstance(board.pin_read("susan/model"), sklearn.dummy.DummyRegressor) + connect_server = RSConnectServer(url=RSC_SERVER_URL, api_key=RSC_API_KEY) + assert isinstance(board.pin_read(pin_name), sklearn.dummy.DummyRegressor) vetiver.deploy_connect( connect_server=connect_server, board=board, - pin_name="susan/model", + pin_name=pin_name, title="testapi", extra_files=["requirements.txt"], + new=True, ) # get url of where content lives client = RSConnectClient(connect_server) - dicts = client.content_search() + dicts = client.content_list() rsc_api = list(filter(lambda x: x["title"] == "testapi", dicts)) - content_url = rsc_api[0].get("content_url") + content_url = rsc_api[0].get("content_url").rstrip("/") - h = {"Authorization": f'Key {get_key("susan")}'} + h = {"Authorization": f"Key {RSC_API_KEY}"} endpoint = vetiver.vetiver_endpoint(content_url + "/predict") response = vetiver.predict(endpoint, X_df, headers=h) diff --git a/vetiver/tests/test_server.py b/vetiver/tests/test_server.py index 6d953787..74ada611 100644 --- a/vetiver/tests/test_server.py +++ b/vetiver/tests/test_server.py @@ -167,7 +167,11 @@ def data() -> pd.DataFrame: def test_endpoint_adds(client, data): - response = client.post("/sum/", data=data.to_json(orient="records")) + response = client.post( + "/sum/", + data=data.to_json(orient="records"), + headers={"Content-Type": "application/json"}, + ) assert response.status_code == 200 assert response.json() == {"sum": [40, 320, 220, 7.8, 5.24, 32.00, 0, 2, 8, 8]}