diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d56bb7fe9..7a30cfdbc 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -179,33 +179,3 @@ jobs: license: ${{ secrets.CONNECT_LICENSE_FILE }} command: | uv run --no-sync --group test ./scripts/runtests - - test-dev-connect: - name: "Integration tests against dev Connect" - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: astral-sh/setup-uv@v6 - with: - version: ">=0.9.0" - - uses: extractions/setup-just@v3 - - name: Install dependencies - run: | - uv sync --python 3.12 --group test - uv pip install -r vetiver-testing/vetiver-requirements.txt - - name: Run Posit Connect - run: | - docker compose up --build -d - uv pip freeze > requirements.txt - just dev - env: - RSC_LICENSE: ${{ secrets.RSC_LICENSE }} - GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} - - name: Get logs in case of failure - run: | - docker compose logs rsconnect - if: ${{ failure() }} - - name: Run tests - run: | - uv run --no-sync pytest tests/test_main_system_caches.py - uv run --no-sync pytest --vetiver -m 'vetiver' diff --git a/Justfile b/Justfile index cb4358c75..33c9ac554 100644 --- a/Justfile +++ b/Justfile @@ -60,18 +60,6 @@ clean-stores: set -euo pipefail find . -name "rsconnect-python" -o -name "rsconnect_python-*" -o -name "rsconnect-*" | xargs rm -rf -# Start a local Connect server for development (Docker; not replaced by uv) -dev: - docker compose up -d - sleep 30 - docker compose exec -T rsconnect bash < vetiver-testing/setup-rsconnect/add-users.sh - uv run python vetiver-testing/setup-rsconnect/dump_api_keys.py vetiver-testing/rsconnect_api_keys.json - -# Stop the local Connect server -dev-stop: - docker compose down - rm -f vetiver-testing/rsconnect_api_keys.json - # Sync latest docs to S3 (CI) sync-latest-docs-to-s3: aws s3 sync --acl bucket-owner-full-control --cache-control max-age=0 site/ s3://rstudio-connect-downloads/connect/rsconnect-python/latest/docs/ diff --git a/conftest.py b/conftest.py index 44fab2be3..4cb10b077 100644 --- a/conftest.py +++ b/conftest.py @@ -1,6 +1,5 @@ import os import sys -import pytest from os.path import abspath, dirname @@ -13,20 +12,3 @@ # default argument value at import time, so this must be set before any test # module imports rsconnect. (Previously injected by the Makefile's TEST_ENV.) os.environ.setdefault("CONNECT_CONTENT_BUILD_DIR", "rsconnect-build-test") - - -def pytest_addoption(parser): - parser.addoption("--vetiver", action="store_true", default=False, help="run vetiver tests") - - -def pytest_configure(config): - config.addinivalue_line("markers", "vetiver: test for vetiver interaction") - - -def pytest_collection_modifyitems(config, items): - if config.getoption("--vetiver"): - return - skip_vetiver = pytest.mark.skip(reason="need --vetiver option to run") - for item in items: - if "vetiver" in item.keywords: - item.add_marker(skip_vetiver) diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 9f5f2d50e..000000000 --- a/docker-compose.yml +++ /dev/null @@ -1,15 +0,0 @@ -services: - - rsconnect: - image: rstudio/rstudio-connect-preview:dev-jammy-daily - restart: always - ports: - - 3939:3939 - volumes: - - $PWD/vetiver-testing/setup-rsconnect/users.txt:/etc/users.txt - - $PWD/vetiver-testing/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 2890f4802..8fb46e08b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,7 +60,7 @@ module-root = "" [tool.ruff] line-length = 120 -extend-exclude = ["my-shiny-app", "rsconnect-build", "rsconnect-build-test", "integration", "vetiver-testing", "tests/testdata"] +extend-exclude = ["my-shiny-app", "rsconnect-build", "rsconnect-build-test", "integration", "tests/testdata"] [tool.ruff.lint] select = ["E", "F", "W"] @@ -71,7 +71,7 @@ per-file-ignores = { "tests/test_metadata.py" = ["E501"] } omit = ["tests/*"] [tool.pytest.ini_options] -markers = ["vetiver: tests for vetiver"] +markers = [] addopts = """ --ignore=tests/testdata """ diff --git a/rsconnect/actions.py b/rsconnect/actions.py index 3130e010a..164180885 100644 --- a/rsconnect/actions.py +++ b/rsconnect/actions.py @@ -278,10 +278,11 @@ def validate_quarto_engines(inspect: QuartoInspectResult): # =============================================================================== -# START: The following deprecated functions are here only for the vetiver-python -# package. -# Some the code in this section has `pyright: ignore` comments, because this -# deprecated code which will be removed in the future. +# START: Compatibility entry point used by the vetiver-python package. +# vetiver's `deploy_connect` calls `deploy_python_fastapi` (below), which routes +# through `deploy_app` and the local `validate_*` helpers. This is a supported +# shim; keep these signatures stable. The `pyright: ignore` comments remain +# because the kwargs-forwarding style predates strict typing. # =============================================================================== def validate_extra_files(directory: str, extra_files: Sequence[str]): """ @@ -439,7 +440,7 @@ def deploy_app( # =============================================================================== -# END deprecated functions for the vetiver-python package +# END compatibility entry point for the vetiver-python package # =============================================================================== diff --git a/tests/test_main_system_caches.py b/tests/test_main_system_caches.py index fe1f32b57..14500ab93 100644 --- a/tests/test_main_system_caches.py +++ b/tests/test_main_system_caches.py @@ -1,159 +1,163 @@ import json import unittest -from os import system +import httpretty from click.testing import CliRunner from rsconnect.main import cli +from .utils import apply_common_args CONNECT_SERVER = "http://localhost:3939" -CONNECT_KEYS_JSON = "vetiver-testing/rsconnect_api_keys.json" -CONNECT_CACHE_DIR = "/data/python-environments/_packages_cache" +API_KEY = "testapikey123" -ADD_CACHE_COMMAND = f"docker compose exec -u rstudio-connect -T rsconnect mkdir -p {CONNECT_CACHE_DIR}/pip/1.2.3" -RM_CACHE_COMMAND = f"docker compose exec -u rstudio-connect -T rsconnect rm -Rf {CONNECT_CACHE_DIR}/pip/1.2.3" -# The following returns int(0) if dir exists, else int(256). -CACHE_EXISTS_COMMAND = f"docker compose exec -u rstudio-connect -T rsconnect [ -d {CONNECT_CACHE_DIR}/pip/1.2.3 ]" -SERVICE_RUNNING_COMMAND = "docker compose ps --services --filter 'status=running' | grep rsconnect" +CACHES_PAYLOAD = {"caches": [{"language": "Python", "version": "1.2.3", "image_name": "Local"}]} +PERMISSION_DENIED_BODY = json.dumps({"code": 22, "error": "You don't have permission to perform this operation."}) -def rsconnect_service_running(): - exit_code = system(SERVICE_RUNNING_COMMAND) - if exit_code == 0: - return True - else: - return False - -def cache_dir_exists(): - exit_code = system(CACHE_EXISTS_COMMAND) - if exit_code == 0: - return True - else: - return False - - -def get_key(name): - with open(CONNECT_KEYS_JSON) as f: - api_key = json.load(f)[name] - return api_key - - -def apply_common_args(args: list, server=None, key=None, insecure=True): - if server: - args.extend(["-s", server]) - if key: - args.extend(["-k", key]) - if insecure: - args.extend(["--insecure"]) +def register_server_validation_uris(connect_server: str): + """Register the endpoints that RSConnectExecutor.validate_server() requires.""" + httpretty.register_uri( + httpretty.GET, + f"{connect_server}/__api__/server_settings", + body=open("tests/testdata/connect-responses/server_settings.json", "r").read(), + adding_headers={"Content-Type": "application/json"}, + ) + httpretty.register_uri( + httpretty.GET, + f"{connect_server}/__api__/v1/user", + body=open("tests/testdata/connect-responses/me.json", "r").read(), + adding_headers={"Content-Type": "application/json"}, + ) class TestSystemCachesList(unittest.TestCase): - @classmethod - def setUpClass(cls): - system(ADD_CACHE_COMMAND) - if not rsconnect_service_running(): - raise unittest.SkipTest("rsconnect docker service is not available") - return super().setUpClass() - - @classmethod - def tearDownClass(cls): - system(RM_CACHE_COMMAND) - return super().tearDownClass - - # Admins can list caches - def test_system_caches_list_admin(self): - api_key = get_key("admin") - runner = CliRunner() + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_system_caches_list_happy_path(self): + """Admin can list caches; stdout JSON matches the mocked payload.""" + register_server_validation_uris(CONNECT_SERVER) + httpretty.register_uri( + httpretty.GET, + f"{CONNECT_SERVER}/__api__/v1/system/caches/runtime", + body=json.dumps(CACHES_PAYLOAD), + adding_headers={"Content-Type": "application/json"}, + ) + runner = CliRunner() args = ["system", "caches", "list"] - apply_common_args(args, server=CONNECT_SERVER, key=api_key) - + apply_common_args(args, server=CONNECT_SERVER, key=API_KEY) result = runner.invoke(cli, args) - self.assertEqual(result.exit_code, 0) - expected = {"caches": [{"language": "Python", "version": "1.2.3", "image_name": "Local"}]} + self.assertEqual(result.exit_code, 0, result.output) result_dict = json.loads(result.output) - self.assertDictEqual(result_dict, expected) + self.assertDictEqual(result_dict, CACHES_PAYLOAD) + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_system_caches_list_permission_denied(self): + """A 403 from Connect is surfaced as exit code 1 with the permission message.""" + register_server_validation_uris(CONNECT_SERVER) + httpretty.register_uri( + httpretty.GET, + f"{CONNECT_SERVER}/__api__/v1/system/caches/runtime", + status=403, + body=PERMISSION_DENIED_BODY, + adding_headers={"Content-Type": "application/json"}, + ) - # Publishers cannot list caches - def test_system_caches_list_publisher(self): - api_key = get_key("susan") runner = CliRunner() - args = ["system", "caches", "list"] - apply_common_args(args, server=CONNECT_SERVER, key=api_key) - + apply_common_args(args, server=CONNECT_SERVER, key=API_KEY) result = runner.invoke(cli, args) - self.assertEqual(result.exit_code, 1) + self.assertEqual(result.exit_code, 1, result.output) self.assertRegex(result.output, "You don't have permission to perform this operation.") class TestSystemCachesDelete(unittest.TestCase): - @classmethod - def setUpClass(cls): - system(ADD_CACHE_COMMAND) - if not rsconnect_service_running(): - raise unittest.SkipTest("rsconnect docker service is not available") - return super().setUpClass() - - @classmethod - def tearDownClass(cls): - system(RM_CACHE_COMMAND) - return super().tearDownClass - - # Publishers cannot delete caches - def test_system_caches_delete_publisher(self): - api_key = get_key("susan") - runner = CliRunner() + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_system_caches_delete_happy_path(self): + """Admin can delete a cache; exit code 0.""" + register_server_validation_uris(CONNECT_SERVER) + httpretty.register_uri( + httpretty.DELETE, + f"{CONNECT_SERVER}/__api__/v1/system/caches/runtime", + status=200, + body=json.dumps( + {"language": "Python", "version": "1.2.3", "image_name": "Local", "dry_run": False, "task_id": "abc123"} + ), + adding_headers={"Content-Type": "application/json"}, + ) + httpretty.register_uri( + httpretty.GET, + f"{CONNECT_SERVER}/__api__/v1/tasks/abc123", + body=json.dumps( + { + "id": "abc123", + "output": [], + "result": {"type": "", "data": ""}, + "finished": True, + "code": 0, + "error": "", + "last": 0, + } + ), + adding_headers={"Content-Type": "application/json"}, + ) + runner = CliRunner() args = ["system", "caches", "delete", "--language", "Python", "--version", "1.2.3", "--image-name", "Local"] - apply_common_args(args, server=CONNECT_SERVER, key=api_key) - + apply_common_args(args, server=CONNECT_SERVER, key=API_KEY) result = runner.invoke(cli, args) - self.assertEqual(result.exit_code, 1) - self.assertRegex(result.output, "You don't have permission to perform this operation.") + self.assertEqual(result.exit_code, 0, result.output) - # Admins can delete caches that exist - def test_system_caches_delete_admin(self): - api_key = get_key("admin") + def test_system_caches_delete_missing_all_flags(self): + """Omitting both --language and --version yields exit code 2 (Click validation).""" runner = CliRunner() + args = ["system", "caches", "delete"] + apply_common_args(args, server=CONNECT_SERVER, key=API_KEY) + result = runner.invoke(cli, args) - args = ["system", "caches", "delete", "--language", "Python", "--version", "1.2.3", "--image-name", "Local"] - apply_common_args(args, server=CONNECT_SERVER, key=api_key) + self.assertEqual(result.exit_code, 2, result.output) + self.assertRegex(result.output, "Missing option '--language' / '-l'") - self.assertTrue(cache_dir_exists()) + def test_system_caches_delete_missing_version_flag(self): + """Providing --language but omitting --version yields exit code 2.""" + runner = CliRunner() + args = ["system", "caches", "delete", "--language", "Python"] + apply_common_args(args, server=CONNECT_SERVER, key=API_KEY) result = runner.invoke(cli, args) - self.assertEqual(result.exit_code, 0) - self.assertFalse(cache_dir_exists()) - # TODO: Unsure how to test log messages received from Connect. + self.assertEqual(result.exit_code, 2, result.output) + self.assertRegex(result.output, "Missing option '--version' / '-V'") - # --version and --language flags are required - def test_system_caches_delete_required_flags(self): - api_key = get_key("admin") + def test_system_caches_delete_missing_language_flag(self): + """Providing --version but omitting --language yields exit code 2.""" runner = CliRunner() - - # neither flag provided should fail - args = ["system", "caches", "delete"] - apply_common_args(args, server=CONNECT_SERVER, key=api_key) + args = ["system", "caches", "delete", "--version", "1.2.3"] + apply_common_args(args, server=CONNECT_SERVER, key=API_KEY) result = runner.invoke(cli, args) - self.assertEqual(result.exit_code, 2) - self.assertRegex(result.output, "Error: Missing option '--language' / '-l'") - # only --language flag provided should fail - args = ["system", "caches", "delete", "--language", "Python"] - apply_common_args(args, server=CONNECT_SERVER, key=api_key) - result = runner.invoke(cli, args) - self.assertEqual(result.exit_code, 2) - self.assertRegex(result.output, "Error: Missing option '--version' / '-V'") + self.assertEqual(result.exit_code, 2, result.output) + self.assertRegex(result.output, "Missing option '--language' / '-l'") + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_system_caches_delete_permission_denied(self): + """A 403 from Connect on delete is surfaced as exit code 1 with the permission message.""" + register_server_validation_uris(CONNECT_SERVER) + httpretty.register_uri( + httpretty.DELETE, + f"{CONNECT_SERVER}/__api__/v1/system/caches/runtime", + status=403, + body=PERMISSION_DENIED_BODY, + adding_headers={"Content-Type": "application/json"}, + ) - # only --version flag provided should fail - args = ["system", "caches", "delete", "--version", "1.2.3"] - apply_common_args(args, server=CONNECT_SERVER, key=api_key) + runner = CliRunner() + args = ["system", "caches", "delete", "--language", "Python", "--version", "1.2.3", "--image-name", "Local"] + apply_common_args(args, server=CONNECT_SERVER, key=API_KEY) result = runner.invoke(cli, args) - self.assertEqual(result.exit_code, 2) - self.assertRegex(result.output, "Error: Missing option '--language' / '-l'") + + self.assertEqual(result.exit_code, 1, result.output) + self.assertRegex(result.output, "You don't have permission to perform this operation.") diff --git a/tests/test_vetiver_pins.py b/tests/test_vetiver_pins.py deleted file mode 100644 index 5b187794f..000000000 --- a/tests/test_vetiver_pins.py +++ /dev/null @@ -1,95 +0,0 @@ -import pytest - -vetiver = pytest.importorskip("vetiver", reason="vetiver library not installed") - -import json # noqa -import pins # noqa -import pandas as pd # noqa -import numpy as np # noqa - -from pins.boards import BoardRsConnect # noqa -from pins.rsconnect.api import RsConnectApi # noqa -from pins.rsconnect.fs import RsConnectFs # noqa -from rsconnect.api import RSConnectServer, RSConnectClient # noqa - -RSC_SERVER_URL = "http://localhost:3939" -RSC_KEYS_FNAME = "vetiver-testing/rsconnect_api_keys.json" - -pytestmark = pytest.mark.vetiver # noqa - - -def get_key(name): - with open(RSC_KEYS_FNAME) as f: - api_key = json.load(f)[name] - return api_key - - -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 rsc_delete_user_content(rsc): - guid = rsc.get_user()["guid"] - content = rsc.get_content(owner_guid=guid) - for entry in content: - rsc.delete_content_item(entry["guid"]) - - -@pytest.fixture(scope="function") -def rsc_short(): - # tears down content after each test - fs_susan = rsc_fs_from_key("susan") - - # 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) - - -def test_deploy(rsc_short): - 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") - - board = pins.board_rsconnect(server_url=RSC_SERVER_URL, api_key=get_key("susan"), allow_pickle_read=True) - - vetiver.vetiver_pin_write(board=board, model=v) - connect_server = RSConnectServer(url=RSC_SERVER_URL, api_key=get_key("susan")) - - vetiver.deploy_rsconnect( - connect_server=connect_server, - board=board, - pin_name="susan/model", - title="testapivetiver", - extra_files=["requirements.txt"], - ) - - # get url of where content lives - client = RSConnectClient(connect_server) - dicts = client.content_list() - rsc_api = list(filter(lambda x: x["title"] == "testapivetiver", dicts)) - content_url = rsc_api[0].get("content_url") - - h = {"Authorization": "Key {}".format(get_key("susan"))} - - endpoint = vetiver.vetiver_endpoint(content_url + "/predict") - response = vetiver.predict(endpoint, X_df, headers=h) - - assert isinstance(response, pd.DataFrame), response - assert response.iloc[0, 0] == 44.47 - assert len(response) == 100 diff --git a/vetiver-testing/setup-rsconnect/add-users.sh b/vetiver-testing/setup-rsconnect/add-users.sh deleted file mode 100644 index 1df8c7f42..000000000 --- a/vetiver-testing/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/vetiver-testing/setup-rsconnect/dump_api_keys.py b/vetiver-testing/setup-rsconnect/dump_api_keys.py deleted file mode 100644 index eebef59f8..000000000 --- a/vetiver-testing/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/vetiver-testing/setup-rsconnect/rstudio-connect.gcfg b/vetiver-testing/setup-rsconnect/rstudio-connect.gcfg deleted file mode 100644 index fb58655f2..000000000 --- a/vetiver-testing/setup-rsconnect/rstudio-connect.gcfg +++ /dev/null @@ -1,29 +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.12.11/bin/python -Executable = /opt/python/3.11.13/bin/python - -[RPackageRepository "CRAN"] -URL = https://p3m.dev/cran/latest - -[RPackageRepository "RSPM"] -URL = https://p3m.dev/cran/latest - -[R] -PositPackageManagerURLRewriting = force-binary - -[Logging] -ServiceLog = STDOUT diff --git a/vetiver-testing/setup-rsconnect/users.txt b/vetiver-testing/setup-rsconnect/users.txt deleted file mode 100644 index dd4ec3599..000000000 --- a/vetiver-testing/setup-rsconnect/users.txt +++ /dev/null @@ -1,4 +0,0 @@ -admin admin0 -test test -susan susan -derek derek diff --git a/vetiver-testing/vetiver-requirements.txt b/vetiver-testing/vetiver-requirements.txt deleted file mode 100644 index 7fb4a023a..000000000 --- a/vetiver-testing/vetiver-requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -pandas -numpy -pydantic<2 -pytest -pins -vetiver