diff --git a/Makefile b/Makefile index 3d509030..ab23b3db 100644 --- a/Makefile +++ b/Makefile @@ -24,6 +24,7 @@ cov: cov-html: $(PYTHON) -m coverage html + open htmlcov/index.html cov-xml: $(PYTHON) -m coverage xml diff --git a/integration/docker-compose.yaml b/integration/compose.yaml similarity index 100% rename from integration/docker-compose.yaml rename to integration/compose.yaml diff --git a/integration/resources/connect/bundles/example-flask-minimal/bundle.tar.gz b/integration/resources/connect/bundles/example-flask-minimal/bundle.tar.gz new file mode 100644 index 00000000..c9584dd9 Binary files /dev/null and b/integration/resources/connect/bundles/example-flask-minimal/bundle.tar.gz differ diff --git a/integration/resources/connect/bundles/example-flask-minimal/hello.py b/integration/resources/connect/bundles/example-flask-minimal/hello.py new file mode 100644 index 00000000..924d3eb2 --- /dev/null +++ b/integration/resources/connect/bundles/example-flask-minimal/hello.py @@ -0,0 +1,8 @@ +from flask import Flask + +app = Flask(__name__) + + +@app.route("/") +def hello_world(): + return "

Hello, World!

" diff --git a/integration/resources/connect/bundles/example-flask-minimal/manifest.json b/integration/resources/connect/bundles/example-flask-minimal/manifest.json new file mode 100644 index 00000000..5ecc3df8 --- /dev/null +++ b/integration/resources/connect/bundles/example-flask-minimal/manifest.json @@ -0,0 +1,24 @@ +{ + "version": 1, + "locale": "en_US.UTF-8", + "metadata": { + "appmode": "python-api", + "entrypoint": "hello" + }, + "python": { + "version": "3.12.2", + "package_manager": { + "name": "pip", + "version": "24.1.2", + "package_file": "requirements.txt" + } + }, + "files": { + "requirements.txt": { + "checksum": "2861f6872b39701536a1fdf2c7bff86b" + }, + "hello.py": { + "checksum": "09f4dee97c8b7e2770157cf5d7fb6a73" + } + } +} diff --git a/integration/resources/connect/bundles/example-flask-minimal/requirements.txt b/integration/resources/connect/bundles/example-flask-minimal/requirements.txt new file mode 100644 index 00000000..95fef4eb --- /dev/null +++ b/integration/resources/connect/bundles/example-flask-minimal/requirements.txt @@ -0,0 +1 @@ +Flask==3.0.3 diff --git a/integration/resources/connect/bundles/example-quarto-minimal/.gitignore b/integration/resources/connect/bundles/example-quarto-minimal/.gitignore new file mode 100644 index 00000000..4c23a061 --- /dev/null +++ b/integration/resources/connect/bundles/example-quarto-minimal/.gitignore @@ -0,0 +1,2 @@ +/.quarto/ +_site/ diff --git a/integration/resources/connect/bundles/example-quarto-minimal/_quarto.yml b/integration/resources/connect/bundles/example-quarto-minimal/_quarto.yml new file mode 100644 index 00000000..cf2cf259 --- /dev/null +++ b/integration/resources/connect/bundles/example-quarto-minimal/_quarto.yml @@ -0,0 +1,19 @@ +project: + type: website + +website: + title: "example-quarto-minimal" + navbar: + left: + - href: index.qmd + text: Home + - about.qmd + +format: + html: + theme: cosmo + css: styles.css + toc: true + + + diff --git a/integration/resources/connect/bundles/example-quarto-minimal/about.qmd b/integration/resources/connect/bundles/example-quarto-minimal/about.qmd new file mode 100644 index 00000000..07c5e7f9 --- /dev/null +++ b/integration/resources/connect/bundles/example-quarto-minimal/about.qmd @@ -0,0 +1,5 @@ +--- +title: "About" +--- + +About this site diff --git a/integration/resources/connect/bundles/example-quarto-minimal/bundle.tar.gz b/integration/resources/connect/bundles/example-quarto-minimal/bundle.tar.gz new file mode 100644 index 00000000..c12f0361 Binary files /dev/null and b/integration/resources/connect/bundles/example-quarto-minimal/bundle.tar.gz differ diff --git a/integration/resources/connect/bundles/example-quarto-minimal/index.qmd b/integration/resources/connect/bundles/example-quarto-minimal/index.qmd new file mode 100644 index 00000000..a316406e --- /dev/null +++ b/integration/resources/connect/bundles/example-quarto-minimal/index.qmd @@ -0,0 +1,7 @@ +--- +title: "example-quarto-minimal" +--- + +This is a Quarto website. + +To learn more about Quarto websites visit . diff --git a/integration/resources/connect/bundles/example-quarto-minimal/manifest.json b/integration/resources/connect/bundles/example-quarto-minimal/manifest.json new file mode 100644 index 00000000..02191884 --- /dev/null +++ b/integration/resources/connect/bundles/example-quarto-minimal/manifest.json @@ -0,0 +1,30 @@ +{ + "version": 1, + "metadata": { + "appmode": "quarto-static", + "content_category": "site" + }, + "quarto": { + "version": "1.5.54", + "engines": [ + "markdown" + ] + }, + "files": { + ".gitignore": { + "checksum": "ebea58ee833ccab90d803cd345b2c81f" + }, + "_quarto.yml": { + "checksum": "619323d181451c463ed77284cb31da12" + }, + "about.qmd": { + "checksum": "b3260e8597e68ac0d3a7951d26a2e945" + }, + "index.qmd": { + "checksum": "8395ee08073124f3ca275ed29ec1a24a" + }, + "styles.css": { + "checksum": "e31c3cdea03dfab8a29456978017bd10" + } + } +} diff --git a/integration/resources/connect/bundles/example-quarto-minimal/styles.css b/integration/resources/connect/bundles/example-quarto-minimal/styles.css new file mode 100644 index 00000000..2ddf50c7 --- /dev/null +++ b/integration/resources/connect/bundles/example-quarto-minimal/styles.css @@ -0,0 +1 @@ +/* css styles */ diff --git a/integration/tests/posit/connect/__init__.py b/integration/tests/posit/connect/__init__.py index e69de29b..54792a85 100644 --- a/integration/tests/posit/connect/__init__.py +++ b/integration/tests/posit/connect/__init__.py @@ -0,0 +1,8 @@ +import os + +from packaging import version + +from posit import connect + +client = connect.Client() +CONNECT_VERSION = version.parse(client.version) diff --git a/integration/tests/posit/connect/test_content.py b/integration/tests/posit/connect/test_content.py index 8ed83693..50af4fb3 100644 --- a/integration/tests/posit/connect/test_content.py +++ b/integration/tests/posit/connect/test_content.py @@ -1,26 +1,29 @@ +from pathlib import Path +from packaging import version + +import pytest + from posit import connect +from . import CONNECT_VERSION + class TestContent: @classmethod def setup_class(cls): cls.client = connect.Client() - cls.item = cls.client.content.create( - name="Sample", - description="Simple sample content for testing", - access_type="acl", - ) + cls.content = cls.client.content.create(name="example") @classmethod def teardown_class(cls): - cls.item.delete() + cls.content.delete() assert cls.client.content.count() == 0 def test_count(self): assert self.client.content.count() == 1 def test_get(self): - assert self.client.content.get(self.item.guid) == self.item + assert self.client.content.get(self.content.guid) == self.content def test_find(self): assert self.client.content.find() @@ -37,3 +40,46 @@ def test_content_item_owner_from_include(self): item = self.client.content.find_one(include="owner") owner = item.owner assert owner.guid == self.client.me.guid + + @pytest.mark.skipif( + CONNECT_VERSION <= version.parse("2024.04.1"), + reason="Python 3.12 not available", + ) + def test_restart(self): + # create content + content = self.client.content.create(name="example-flask-minimal") + # create bundle + path = Path( + "../../../resources/connect/bundles/example-flask-minimal/bundle.tar.gz" + ) + path = (Path(__file__).parent / path).resolve() + bundle = content.bundles.create(str(path)) + # deploy bundle + task = bundle.deploy() + task.wait_for() + # restart + content.restart() + # delete content + content.delete() + + @pytest.mark.skipif( + CONNECT_VERSION <= version.parse("2023.01.1"), + reason="Quarto not available", + ) + def test_render(self): + # create content + content = self.client.content.create(name="example-quarto-minimal") + # create bundle + path = Path( + "../../../resources/connect/bundles/example-quarto-minimal/bundle.tar.gz" + ) + path = (Path(__file__).parent / path).resolve() + bundle = content.bundles.create(str(path)) + # deploy bundle + task = bundle.deploy() + task.wait_for() + # render + task = content.render() + task.wait_for() + # delete content + content.delete() diff --git a/integration/tests/posit/connect/test_env.py b/integration/tests/posit/connect/test_env.py new file mode 100644 index 00000000..0d0f4083 --- /dev/null +++ b/integration/tests/posit/connect/test_env.py @@ -0,0 +1,41 @@ +from posit import connect + + +class TestEnvVars: + @classmethod + def setup_class(cls): + cls.client = connect.Client() + cls.content = cls.client.content.create( + name="Sample", + description="Simple sample content for testing", + access_type="acl", + ) + + @classmethod + def teardown_class(cls): + cls.content.delete() + assert cls.client.content.count() == 0 + + def test_clear(self): + self.content.environment_variables.create("KEY", "value") + assert self.content.environment_variables.find() == ["KEY"] + self.content.environment_variables.clear() + assert self.content.environment_variables.find() == [] + + def test_create(self): + self.content.environment_variables.create("KEY", "value") + assert self.content.environment_variables.find() == ["KEY"] + + def test_delete(self): + self.content.environment_variables.create("KEY", "value") + assert self.content.environment_variables.find() == ["KEY"] + self.content.environment_variables.delete("KEY") + assert self.content.environment_variables.find() == [] + + def test_find(self): + self.content.environment_variables.create("KEY", "value") + assert self.content.environment_variables.find() == ["KEY"] + + def test_update(self): + self.content.environment_variables.update(KEY="value") + assert self.content.environment_variables.find() == ["KEY"] diff --git a/pyproject.toml b/pyproject.toml index 41799b05..427ab6d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,9 @@ dependencies = ["requests>=2.31.0,<3"] Source = "https://github.com/posit-dev/posit-sdk-py" Issues = "https://github.com/posit-dev/posit-sdk-py/issues" +[tool.mypy] +exclude = "integration/resources/*" + [tool.pytest.ini_options] testpaths = ["tests"] addopts = ["--import-mode=importlib"] @@ -34,6 +37,7 @@ version_file = "src/posit/_version.py" [tool.ruff] line-length = 79 +exclude = ["integration/resources/*"] [tool.ruff.format] docstring-code-format = true @@ -71,5 +75,6 @@ ignore = [ "examples/*" = ["D"] "tests/*" = ["D"] + [tool.ruff.lint.pydocstyle] convention = "numpy" diff --git a/src/posit/connect/content.py b/src/posit/connect/content.py index eed077c9..db85551b 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -2,18 +2,20 @@ from __future__ import annotations -from collections import defaultdict -from typing import TYPE_CHECKING, List, Optional, overload - +import secrets +from posixpath import dirname +from typing import List, Optional, overload from requests import Session from . import tasks, urls - -from .config import Config from .bundles import Bundles +from .config import Config +from .env import EnvVars from .permissions import Permissions -from .resources import Resources, Resource +from .resources import Resource, Resources +from .tasks import Task +from .variants import Variants class ContentItemOwner(Resource): @@ -137,12 +139,195 @@ class ContentItem(Resource): Tags associated with the content item. """ + # CRUD Methods + + def delete(self) -> None: + """Delete the content item.""" + path = f"v1/content/{self.guid}" + url = urls.append(self.config.url, path) + self.session.delete(url) + + def deploy(self) -> tasks.Task: + """Deploy the content. + + Spawns an asynchronous task, which activates the latest bundle. + + Returns + ------- + tasks.Task + The task for the deployment. + + Examples + -------- + >>> task = content.deploy() + >>> task.wait_for() + None + """ + path = f"v1/content/{self.guid}/deploy" + url = urls.append(self.config.url, path) + response = self.session.post(url, json={"bundle_id": None}) + result = response.json() + ts = tasks.Tasks(self.config, self.session) + return ts.get(result["task_id"]) + + def render(self) -> Task: + """Render the content. + + Submit a render request to the server for the content. After submission, the server executes an asynchronous process to render the content. This is useful when content is dependent on external information, such as a dataset. + + See Also + -------- + restart + + Examples + -------- + >>> render() + """ + self.update() + + if self.app_mode in { + "rmd-static", + "jupyter-static", + "quarto-static", + }: + variants = self._variants.find() + variants = [variant for variant in variants if variant.is_default] + if len(variants) != 1: + raise RuntimeError( + f"Found {len(variants)} default variants. Expected 1. Without a single default variant, the content cannot be refreshed. This is indicative of a corrupted state." + ) + variant = variants[0] + return variant.render() + else: + raise ValueError( + f"Render not supported for this application mode: {self.app_mode}. Did you need to use the 'restart()' method instead? Note that some application modes do not support 'render()' or 'restart()'." + ) + + def restart(self) -> None: + """Mark for restart. + + Sends a restart request to the server for the content. Once submitted, the server performs an asynchronous process to restart the content. This is particularly useful when the content relies on external information loaded into application memory, such as datasets. Additionally, restarting can help clear memory leaks or reduce excessive memory usage that might build up over time. + + See Also + -------- + render + + Examples + -------- + >>> restart() + """ + self.update() + + if self.app_mode in { + "api", + "jupyter-voila", + "python-api", + "python-bokeh", + "python-dash", + "python-fastapi", + "python-shiny", + "python-streamlit", + "quarto-shiny", + "rmd-shiny", + "shiny", + "tensorflow-saved-model", + }: + random_hash = secrets.token_hex(32) + key = f"_CONNECT_RESTART_TMP_{random_hash}" + self.environment_variables.create(key, random_hash) + self.environment_variables.delete(key) + # GET via the base Connect URL to force create a new worker thread. + url = urls.append(dirname(self.config.url), f"content/{self.guid}") + self.session.get(url) + return None + else: + raise ValueError( + f"Restart not supported for this application mode: {self.app_mode}. Did you need to use the 'render()' method instead? Note that some application modes do not support 'render()' or 'restart()'." + ) + + @overload + def update( + self, + name: str = ..., + title: Optional[str] = ..., + description: str = ..., + access_type: str = ..., + owner_guid: Optional[str] = ..., + connection_timeout: Optional[int] = ..., + read_timeout: Optional[int] = ..., + init_timeout: Optional[int] = ..., + idle_timeout: Optional[int] = ..., + max_processes: Optional[int] = ..., + min_processes: Optional[int] = ..., + max_conns_per_process: Optional[int] = ..., + load_factor: Optional[float] = ..., + cpu_request: Optional[float] = ..., + cpu_limit: Optional[float] = ..., + memory_request: Optional[int] = ..., + memory_limit: Optional[int] = ..., + amd_gpu_limit: Optional[int] = ..., + nvidia_gpu_limit: Optional[int] = ..., + run_as: Optional[str] = ..., + run_as_current_user: Optional[bool] = ..., + default_image_name: Optional[str] = ..., + default_r_environment_management: Optional[bool] = ..., + default_py_environment_management: Optional[bool] = ..., + service_account_name: Optional[str] = ..., + ) -> None: + """Update the content item. + + Parameters + ---------- + name : str, optional + title : Optional[str], optional + description : str, optional + access_type : str, optional + owner_guid : Optional[str], optional + connection_timeout : Optional[int], optional + read_timeout : Optional[int], optional + init_timeout : Optional[int], optional + idle_timeout : Optional[int], optional + max_processes : Optional[int], optional + min_processes : Optional[int], optional + max_conns_per_process : Optional[int], optional + load_factor : Optional[float], optional + cpu_request : Optional[float], optional + cpu_limit : Optional[float], optional + memory_request : Optional[int], optional + memory_limit : Optional[int], optional + amd_gpu_limit : Optional[int], optional + nvidia_gpu_limit : Optional[int], optional + run_as : Optional[str], optional + run_as_current_user : Optional[bool], optional + default_image_name : Optional[str], optional + default_r_environment_management : Optional[bool], optional + default_py_environment_management : Optional[bool], optional + service_account_name : Optional[str], optional + """ + ... + + @overload + def update(self, *args, **kwargs) -> None: + """Update the content item.""" + ... + + def update(self, *args, **kwargs) -> None: + """Update the content item.""" + body = dict(*args, **kwargs) + url = urls.append(self.config.url, f"v1/content/{self.guid}") + response = self.session.patch(url, json=body) + super().update(**response.json()) + # Relationships @property def bundles(self) -> Bundles: return Bundles(self.config, self.session, self.guid) + @property + def environment_variables(self) -> EnvVars: + return EnvVars(self.config, self.session, self.guid) + @property def permissions(self) -> Permissions: return Permissions(self.config, self.session, self.guid) @@ -160,6 +345,10 @@ def owner(self) -> ContentItemOwner: ) return ContentItemOwner(self.config, self.session, **self["owner"]) + @property + def _variants(self) -> Variants: + return Variants(self.config, self.session, self.guid) + # Properties @property @@ -338,110 +527,6 @@ def app_role(self) -> str: def tags(self) -> List[dict]: return self.get("tags", []) - # CRUD Methods - - def delete(self) -> None: - """Delete the content item.""" - path = f"v1/content/{self.guid}" - url = urls.append(self.config.url, path) - self.session.delete(url) - - def deploy(self) -> tasks.Task: - """Deploy the content. - - Spawns an asynchronous task, which activates the latest bundle. - - Returns - ------- - tasks.Task - The task for the deployment. - - Examples - -------- - >>> task = content.deploy() - >>> task.wait_for() - None - """ - path = f"v1/content/{self.guid}/deploy" - url = urls.append(self.config.url, path) - response = self.session.post(url, json={"bundle_id": None}) - result = response.json() - ts = tasks.Tasks(self.config, self.session) - return ts.get(result["task_id"]) - - @overload - def update( - self, - name: str = ..., - title: Optional[str] = ..., - description: str = ..., - access_type: str = ..., - owner_guid: Optional[str] = ..., - connection_timeout: Optional[int] = ..., - read_timeout: Optional[int] = ..., - init_timeout: Optional[int] = ..., - idle_timeout: Optional[int] = ..., - max_processes: Optional[int] = ..., - min_processes: Optional[int] = ..., - max_conns_per_process: Optional[int] = ..., - load_factor: Optional[float] = ..., - cpu_request: Optional[float] = ..., - cpu_limit: Optional[float] = ..., - memory_request: Optional[int] = ..., - memory_limit: Optional[int] = ..., - amd_gpu_limit: Optional[int] = ..., - nvidia_gpu_limit: Optional[int] = ..., - run_as: Optional[str] = ..., - run_as_current_user: Optional[bool] = ..., - default_image_name: Optional[str] = ..., - default_r_environment_management: Optional[bool] = ..., - default_py_environment_management: Optional[bool] = ..., - service_account_name: Optional[str] = ..., - ) -> None: - """Update the content item. - - Parameters - ---------- - name : str, optional - title : Optional[str], optional - description : str, optional - access_type : str, optional - owner_guid : Optional[str], optional - connection_timeout : Optional[int], optional - read_timeout : Optional[int], optional - init_timeout : Optional[int], optional - idle_timeout : Optional[int], optional - max_processes : Optional[int], optional - min_processes : Optional[int], optional - max_conns_per_process : Optional[int], optional - load_factor : Optional[float], optional - cpu_request : Optional[float], optional - cpu_limit : Optional[float], optional - memory_request : Optional[int], optional - memory_limit : Optional[int], optional - amd_gpu_limit : Optional[int], optional - nvidia_gpu_limit : Optional[int], optional - run_as : Optional[str], optional - run_as_current_user : Optional[bool], optional - default_image_name : Optional[str], optional - default_r_environment_management : Optional[bool], optional - default_py_environment_management : Optional[bool], optional - service_account_name : Optional[str], optional - """ - ... - - @overload - def update(self, *args, **kwargs) -> None: - """Update the content item.""" - ... - - def update(self, *args, **kwargs) -> None: - """Update the content item.""" - body = dict(*args, **kwargs) - url = urls.append(self.config.url, f"v1/content/{self.guid}") - response = self.session.patch(url, json=body) - super().update(**response.json()) - class Content(Resources): """Content resource. diff --git a/src/posit/connect/env.py b/src/posit/connect/env.py new file mode 100644 index 00000000..bd6ab65a --- /dev/null +++ b/src/posit/connect/env.py @@ -0,0 +1,208 @@ +from __future__ import annotations + +from typing import Any, Iterator, List, Mapping, MutableMapping, Optional + +from requests import Session + +from . import urls +from .config import Config +from .resources import Resources + + +class EnvVars(Resources, MutableMapping[str, Optional[str]]): + def __init__( + self, config: Config, session: Session, content_guid: str + ) -> None: + super().__init__(config, session) + self.content_guid = content_guid + + def __delitem__(self, key: str, /) -> None: + """Delete the environment variable. + + Parameters + ---------- + key : str + The name of the environment variable to delete. + + Examples + -------- + >>> vars = EnvVars(config, session, content_guid) + >>> del vars["DATABASE_URL"] + """ + self.update({key: None}) + + def __getitem__(self, key: Any) -> Any: + raise NotImplementedError( + "Since environment variables may contain sensitive information, the values are not accessible outside of Connect." + ) + + def __iter__(self) -> Iterator: + return iter(self.find()) + + def __len__(self): + return len(self.find()) + + def __setitem__(self, key: str, value: Optional[str], /) -> None: + """Set environment variable. + + Set the environment variable for content. + + Parameters + ---------- + key : str + The name of the environment variable to set. + value : str + The value assigned to the environment variable. + + Examples + -------- + >>> vars = EnvVars(config, session, content_guid) + >>> vars["DATABASE_URL"] = ( + ... "postgres://user:password@localhost:5432/database" + ... ) + """ + self.update({key: value}) + + def clear(self) -> None: + """Remove all environment variables. + + Examples + -------- + >>> clear() + """ + path = f"v1/content/{self.content_guid}/environment" + url = urls.append(self.config.url, path) + self.session.put(url, json=[]) + + def create(self, key: str, value: str, /) -> None: + """Create an environment variable. + + Set an environment variable with the provided key and value. If the key already exists, its value is overwritten without warning to the provided value. + + Parameters + ---------- + key : str + The name of the environment variable to create. + value : str + The value assigned to the environment variable. + + Examples + -------- + >>> create( + ... "DATABASE_URL", + ... "postgres://user:password@localhost:5432/database", + ... ) + """ + self.update({key: value}) + + def delete(self, key: str, /) -> None: + """Delete the environment variable. + + Parameters + ---------- + key : str + The name of the environment variable to delete. + + Examples + -------- + >>> delete("DATABASE_URL") + """ + self.update({key: None}) + + def find(self) -> List[str]: + """Find environment variables. + + List the names of the defined environment variables. + + Returns + ------- + List[str] + Environment variable names. + + Notes + ----- + The Connect environment variables API does support retrieving the environment variable's value. + + Examples + -------- + >>> find() + ['DATABASE_URL'] + """ + path = f"v1/content/{self.content_guid}/environment" + url = urls.append(self.config.url, path) + response = self.session.get(url) + return response.json() + + def items(self): + raise NotImplementedError( + "Since environment variables may contain sensitive information, the values are not accessible outside of Connect." + ) + + def update(self, other=(), /, **kwargs: Optional[str]): + """ + Update environment variables. + + Updates environment variables with the provided key-value pairs. Accepts a dictionary, an iterable of key-value pairs, or keyword arguments to update the environment variables. All keys and values must be str types. + + Parameters + ---------- + other : dict, iterable of tuples, optional + A dictionary or an iterable of key-value pairs to update the environment variables. By default, it is None. + **kwargs : str + Additional key-value pairs to update the environment variables. + + Raises + ------ + TypeError + If the type of 'other' is not a dictionary or an iterable of key-value pairs. + + Examples + -------- + Update using keyword arguments: + >>> update( + ... DATABASE_URL="postgres://user:password@localhost:5432/database" + ... ) + + Update using multiple keyword arguments: + >>> update( + ... DATABASE_URL="postgres://localhost:5432/database", + ... DATABASE_USERNAME="user", + ... DATABASE_PASSWORD="password", + ... ) + + Update using a dictionary: + >>> update( + ... { + ... "DATABASE_URL": "postgres://localhost:5432/database", + ... "DATABASE_USERNAME": "user", + ... "DATABASE_PASSWORD": "password", + ... } + ... ) + + Update using an iterable of key-value pairs: + >>> update( + ... [ + ... ("DATABASE_URL", "postgres://localhost:5432/database"), + ... ("DATABASE_USERNAME", "user"), + ... ("DATABASE_PASSWORD", "password"), + ... ] + ... ) + """ + d = dict() + if isinstance(other, Mapping): + for key in other: + d[key] = other[key] + elif hasattr(other, "keys"): + for key in other.keys(): + d[key] = other[key] + else: + for key, value in other: + d[key] = value + + for key, value in kwargs.items(): + d[key] = value + + body = [{"name": key, "value": value} for key, value in d.items()] + path = f"v1/content/{self.content_guid}/environment" + url = urls.append(self.config.url, path) + self.session.patch(url, json=body) diff --git a/src/posit/connect/variants.py b/src/posit/connect/variants.py new file mode 100644 index 00000000..ed7a0d26 --- /dev/null +++ b/src/posit/connect/variants.py @@ -0,0 +1,40 @@ +from typing import List +from requests import Session + +from . import urls +from .config import Config +from .resources import Resource, Resources +from .tasks import Task + + +class Variant(Resource): + @property + def id(self) -> str: + return self["id"] + + @property + def is_default(self) -> bool: + return self.get("is_default", False) + + def render(self) -> Task: + path = f"variants/{self.id}/render" + url = urls.append(self.config.url, path) + response = self.session.post(url) + return Task(self.config, self.session, **response.json()) + + +class Variants(Resources): + def __init__( + self, config: Config, session: Session, content_guid: str + ) -> None: + super().__init__(config, session) + self.content_guid = content_guid + + def find(self) -> List[Variant]: + path = f"applications/{self.content_guid}/variants" + url = urls.append(self.config.url, path) + response = self.session.get(url) + results = response.json() or [] + return [ + Variant(self.config, self.session, **result) for result in results + ] diff --git a/tests/posit/connect/__api__/applications/f2f37341-e21d-3d80-c698-a935ad614066/variants.json b/tests/posit/connect/__api__/applications/f2f37341-e21d-3d80-c698-a935ad614066/variants.json new file mode 100644 index 00000000..de59c9a2 --- /dev/null +++ b/tests/posit/connect/__api__/applications/f2f37341-e21d-3d80-c698-a935ad614066/variants.json @@ -0,0 +1,19 @@ +[ + { + "id": 6627, + "app_id": 50941, + "key": "txvRW8SG", + "bundle_id": 120726, + "is_default": true, + "name": "default", + "email_collaborators": false, + "email_viewers": false, + "email_all": false, + "created_time": "2024-07-02T19:26:45.878442Z", + "rendering_id": 3055012, + "render_time": "2024-07-17T18:33:49.284709Z", + "render_duration": 5695616577, + "visibility": "public", + "owner_id": 0 + } +] diff --git a/tests/posit/connect/__api__/variants/6627/render.json b/tests/posit/connect/__api__/variants/6627/render.json new file mode 100644 index 00000000..c1308f5b --- /dev/null +++ b/tests/posit/connect/__api__/variants/6627/render.json @@ -0,0 +1,12 @@ +{ + "id": "jXhOhdm5OOSkGhJw", + "output": [ + "Building static content...", + "Launching static content..." + ], + "finished": true, + "code": 1, + "error": "Unable to render: Rendering exited abnormally: exit status 1", + "last": 2, + "result": null + } diff --git a/tests/posit/connect/test_content.py b/tests/posit/connect/test_content.py index a0570539..ab378574 100644 --- a/tests/posit/connect/test_content.py +++ b/tests/posit/connect/test_content.py @@ -1,12 +1,13 @@ +import warnings + +import pytest import requests import responses - -from responses import matchers - from posit.connect.client import Client from posit.connect.config import Config from posit.connect.content import ContentItem, ContentItemOwner from posit.connect.permissions import Permissions +from responses import matchers from .api import load_mock # type: ignore @@ -524,3 +525,169 @@ def test(self): con = Client(api_key="12345", url="https://connect.example/") count = con.content.count() assert count == 3 + + +class TestRender: + @responses.activate + def test(self): + # data + guid = "f2f37341-e21d-3d80-c698-a935ad614066" + + # behavior + get_content = responses.get( + f"https://connect.example.com/__api__/v1/content/{guid}", + json=load_mock(f"v1/content/{guid}.json"), + ) + + patch_content = responses.patch( + f"https://connect.example.com/__api__/v1/content/{guid}", + json=load_mock(f"v1/content/{guid}.json"), + ) + + get_variants = responses.get( + f"https://connect.example.com/__api__/applications/{guid}/variants", + json=load_mock(f"applications/{guid}/variants.json"), + ) + + post_render = responses.post( + "https://connect.example.com/__api__/variants/6627/render", + json=load_mock("variants/6627/render.json"), + ) + + # setup + c = Client("https://connect.example.com", "12345") + content = c.content.get(guid) + + # invoke + task = content.render() + + # assert + assert task is not None + assert get_content.call_count == 1 + assert patch_content.call_count == 1 + assert get_variants.call_count == 1 + assert post_render.call_count == 1 + + @responses.activate + def test_app_mode_is_other(self): + # data + guid = "f2f37341-e21d-3d80-c698-a935ad614066" + fixture_content = load_mock(f"v1/content/{guid}.json") + fixture_content.update(app_mode="other") + + # behavior + responses.get( + f"https://connect.example.com/__api__/v1/content/{guid}", + json=fixture_content, + ) + + responses.patch( + f"https://connect.example.com/__api__/v1/content/{guid}", + json=fixture_content, + ) + + # setup + c = Client("https://connect.example.com", "12345") + content = c.content.get(guid) + + # invoke + with pytest.raises(ValueError): + content.render() + + @responses.activate + def test_missing_default(self): + responses.get( + "https://connect.example.com/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066", + json=load_mock( + "v1/content/f2f37341-e21d-3d80-c698-a935ad614066.json" + ), + ) + + responses.patch( + "https://connect.example.com/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066", + json=load_mock( + "v1/content/f2f37341-e21d-3d80-c698-a935ad614066.json" + ), + ) + + responses.get( + "https://connect.example.com/__api__/applications/f2f37341-e21d-3d80-c698-a935ad614066/variants", + json=[], + ) + + c = Client("https://connect.example.com", "12345") + content = c.content.get("f2f37341-e21d-3d80-c698-a935ad614066") + with pytest.raises(RuntimeError): + content.render() + + +class TestRestart: + @responses.activate + def test(self): + # data + guid = "f2f37341-e21d-3d80-c698-a935ad614066" + fixture_content = load_mock(f"v1/content/{guid}.json") + fixture_content.update(app_mode="api") + + # behavior + mock_get_content = mock_get = responses.get( + f"https://connect.example.com/__api__/v1/content/{guid}", + json=fixture_content, + ) + + mock_patch_content = responses.patch( + f"https://connect.example.com/__api__/v1/content/{guid}", + json=fixture_content, + ) + + mock_patch_environment = responses.patch( + f"https://connect.example.com/__api__/v1/content/{guid}/environment", + ) + + mock_get_content_page = responses.get( + f"https://connect.example.com/content/{guid}", + ) + + # setup + c = Client("https://connect.example.com", "12345") + content = c.content.get(guid) + + # invoke + task = content.restart() + + # assert + assert task is None + assert mock_get_content.call_count == 1 + assert mock_patch_content.call_count == 1 + assert mock_patch_environment.call_count == 2 + assert mock_get_content_page.call_count == 1 + + @responses.activate + def test_app_mode_is_other(self): + # data + guid = "f2f37341-e21d-3d80-c698-a935ad614066" + fixture_content = load_mock(f"v1/content/{guid}.json") + fixture_content.update(app_mode="other") + + # behavior + mock_get_content = mock_get = responses.get( + f"https://connect.example.com/__api__/v1/content/{guid}", + json=fixture_content, + ) + + mock_patch_content = responses.patch( + f"https://connect.example.com/__api__/v1/content/{guid}", + json=fixture_content, + ) + + # setup + c = Client("https://connect.example.com", "12345") + content = c.content.get(guid) + + # invoke + with pytest.raises(ValueError): + content.restart() + + # assert + assert mock_get_content.call_count == 1 + assert mock_patch_content.call_count == 1 diff --git a/tests/posit/connect/test_env.py b/tests/posit/connect/test_env.py new file mode 100644 index 00000000..a7fc0731 --- /dev/null +++ b/tests/posit/connect/test_env.py @@ -0,0 +1,587 @@ +import pytest +import responses + +from responses import matchers + +from posit.connect import Client + +from .api import load_mock # type: ignore + + +@responses.activate +def test__delitem__(): + # data + guid = "f2f37341-e21d-3d80-c698-a935ad614066" + + # behavior + mock_get = responses.get( + f"https://connect.example.com/__api__/v1/content/{guid}", + json=load_mock(f"v1/content/{guid}.json"), + ) + + mock_patch = responses.patch( + f"https://connect.example.com/__api__/v1/content/{guid}/environment", + json=[], + match=[ + matchers.json_params_matcher( + [ + { + "name": "TEST", + "value": None, + } + ] + ) + ], + ) + + # setup + c = Client("https://connect.example.com", "12345") + content = c.content.get(guid) + + # invoke + del content.environment_variables["TEST"] + + # assert + assert mock_get.call_count == 1 + assert mock_patch.call_count == 1 + + +@responses.activate +def test__getitem__(): + # data + guid = "f2f37341-e21d-3d80-c698-a935ad614066" + + # behavior + responses.get( + f"https://connect.example.com/__api__/v1/content/{guid}", + json=load_mock(f"v1/content/{guid}.json"), + ) + + responses.patch( + f"https://connect.example.com/__api__/v1/content/{guid}/environment", + json=[], + match=[ + matchers.json_params_matcher( + [ + { + "name": "TEST", + "value": None, + } + ] + ) + ], + ) + + # setup + c = Client("https://connect.example.com", "12345") + content = c.content.get(guid) + + # invoke + with pytest.raises(NotImplementedError): + content.environment_variables["TEST"] + + +@responses.activate +def test__iter__(): + # data + guid = "f2f37341-e21d-3d80-c698-a935ad614066" + + # behavior + mock_get_content = responses.get( + f"https://connect.example.com/__api__/v1/content/{guid}", + json=load_mock(f"v1/content/{guid}.json"), + ) + + mock_get_environment = responses.get( + f"https://connect.example.com/__api__/v1/content/{guid}/environment", + json=["TEST"], + ) + + # setup + c = Client("https://connect.example.com", "12345") + content = c.content.get(guid) + + # invoke + iterator = iter(content.environment_variables) + + # assert + assert next(iterator) == "TEST" + with pytest.raises(StopIteration): + next(iterator) + + assert mock_get_content.call_count == 1 + assert mock_get_environment.call_count == 1 + + +@responses.activate +def test__len__(): + # data + guid = "f2f37341-e21d-3d80-c698-a935ad614066" + + # behavior + mock_get_content = responses.get( + f"https://connect.example.com/__api__/v1/content/{guid}", + json=load_mock(f"v1/content/{guid}.json"), + ) + + mock_get_environment = responses.get( + f"https://connect.example.com/__api__/v1/content/{guid}/environment", + json=["TEST"], + ) + + # setup + c = Client("https://connect.example.com", "12345") + content = c.content.get(guid) + + # invoke + length = len(content.environment_variables) + + # assert + assert length == 1 + assert mock_get_content.call_count == 1 + assert mock_get_environment.call_count == 1 + + +@responses.activate +def test__setitem__(): + # data + guid = "f2f37341-e21d-3d80-c698-a935ad614066" + + # behavior + mock_get = responses.get( + f"https://connect.example.com/__api__/v1/content/{guid}", + json=load_mock(f"v1/content/{guid}.json"), + ) + + mock_patch = responses.patch( + f"https://connect.example.com/__api__/v1/content/{guid}/environment", + json=["TEST"], + match=[ + matchers.json_params_matcher( + [ + { + "name": "TEST", + "value": "TEST", + } + ] + ) + ], + ) + + # setup + c = Client("https://connect.example.com", "12345") + content = c.content.get(guid) + + # invoke + content.environment_variables["TEST"] = "TEST" + + # assert + assert mock_get.call_count == 1 + assert mock_patch.call_count == 1 + + +@responses.activate +def test_clear(): + # data + guid = "f2f37341-e21d-3d80-c698-a935ad614066" + + # behavior + mock_get = responses.get( + f"https://connect.example.com/__api__/v1/content/{guid}", + json=load_mock(f"v1/content/{guid}.json"), + ) + + mock_put = responses.put( + f"https://connect.example.com/__api__/v1/content/{guid}/environment", + json=[], + match=[matchers.json_params_matcher([])], + ) + + # setup + c = Client("https://connect.example.com", "12345") + content = c.content.get(guid) + + # invoke + content.environment_variables.clear() + + # assert + assert mock_get.call_count == 1 + assert mock_put.call_count == 1 + + +@responses.activate +def test_create(): + # data + guid = "f2f37341-e21d-3d80-c698-a935ad614066" + + # behavior + mock_get = responses.get( + f"https://connect.example.com/__api__/v1/content/{guid}", + json=load_mock(f"v1/content/{guid}.json"), + ) + + mock_patch = responses.patch( + f"https://connect.example.com/__api__/v1/content/{guid}/environment", + json=["TEST"], + match=[ + matchers.json_params_matcher( + [ + { + "name": "TEST", + "value": "TEST", + } + ] + ) + ], + ) + + # setup + c = Client("https://connect.example.com", "12345") + content = c.content.get(guid) + + # invoke + content.environment_variables.create("TEST", "TEST") + + # assert + assert mock_get.call_count == 1 + assert mock_patch.call_count == 1 + + +@responses.activate +def test_delete(): + # data + guid = "f2f37341-e21d-3d80-c698-a935ad614066" + + # behavior + mock_get = responses.get( + f"https://connect.example.com/__api__/v1/content/{guid}", + json=load_mock(f"v1/content/{guid}.json"), + ) + + mock_patch = responses.patch( + f"https://connect.example.com/__api__/v1/content/{guid}/environment", + json=[], + match=[ + matchers.json_params_matcher( + [ + { + "name": "TEST", + "value": None, + } + ] + ) + ], + ) + + # setup + c = Client("https://connect.example.com", "12345") + content = c.content.get(guid) + + # invoke + content.environment_variables.delete("TEST") + + # assert + assert mock_get.call_count == 1 + assert mock_patch.call_count == 1 + + +@responses.activate +def test_find(): + # data + guid = "f2f37341-e21d-3d80-c698-a935ad614066" + + # behavior + mock_get_content = responses.get( + f"https://connect.example.com/__api__/v1/content/{guid}", + json=load_mock(f"v1/content/{guid}.json"), + ) + + mock_get_environment = responses.get( + f"https://connect.example.com/__api__/v1/content/{guid}/environment", + json=["TEST"], + ) + + # setup + c = Client("https://connect.example.com", "12345") + content = c.content.get(guid) + + # invoke + vars = content.environment_variables.find() + + # assert + assert vars == ["TEST"] + assert mock_get_content.call_count == 1 + assert mock_get_environment.call_count == 1 + + +@responses.activate +def test_items(): + # data + guid = "f2f37341-e21d-3d80-c698-a935ad614066" + + # behavior + mock_get_content = responses.get( + f"https://connect.example.com/__api__/v1/content/{guid}", + json=load_mock(f"v1/content/{guid}.json"), + ) + + mock_get_environment = responses.get( + f"https://connect.example.com/__api__/v1/content/{guid}/environment", + json=["TEST"], + ) + + # setup + c = Client("https://connect.example.com", "12345") + content = c.content.get(guid) + + # invoke + with pytest.raises(NotImplementedError): + content.environment_variables.items() + + +class TestUpdate: + @responses.activate + def test(self): + # data + guid = "f2f37341-e21d-3d80-c698-a935ad614066" + + # behavior + mock_get = responses.get( + f"https://connect.example.com/__api__/v1/content/{guid}", + json=load_mock(f"v1/content/{guid}.json"), + ) + + mock_patch = responses.patch( + f"https://connect.example.com/__api__/v1/content/{guid}/environment", + json=load_mock(f"v1/content/{guid}.json"), + match=[ + matchers.json_params_matcher( + [ + { + "name": "TEST", + "value": "TEST", + } + ] + ) + ], + ) + + # setup + c = Client("https://connect.example.com", "12345") + content = c.content.get(guid) + + # invoke + content.environment_variables.update(TEST="TEST") + + # assert + assert mock_get.call_count == 1 + assert mock_patch.call_count == 1 + + @responses.activate + def test_other_is_mapping(self): + # data + guid = "f2f37341-e21d-3d80-c698-a935ad614066" + + # behavior + mock_get = responses.get( + f"https://connect.example.com/__api__/v1/content/{guid}", + json=load_mock(f"v1/content/{guid}.json"), + ) + + mock_patch = responses.patch( + f"https://connect.example.com/__api__/v1/content/{guid}/environment", + json=load_mock(f"v1/content/{guid}.json"), + match=[ + matchers.json_params_matcher( + [ + { + "name": "TEST", + "value": "TEST", + } + ] + ) + ], + ) + + # setup + c = Client("https://connect.example.com", "12345") + content = c.content.get(guid) + + # invoke + content.environment_variables.update({"TEST": "TEST"}) + + # assert + assert mock_get.call_count == 1 + assert mock_patch.call_count == 1 + + @responses.activate + def test_other_hasattr_keys(self): + # data + guid = "f2f37341-e21d-3d80-c698-a935ad614066" + + # behavior + mock_get = responses.get( + f"https://connect.example.com/__api__/v1/content/{guid}", + json=load_mock(f"v1/content/{guid}.json"), + ) + + mock_patch = responses.patch( + f"https://connect.example.com/__api__/v1/content/{guid}/environment", + json=load_mock(f"v1/content/{guid}.json"), + match=[ + matchers.json_params_matcher( + [ + { + "name": "TEST", + "value": "TEST", + } + ] + ) + ], + ) + + # setup + c = Client("https://connect.example.com", "12345") + content = c.content.get(guid) + + class Test: + def __getitem__(self, key): + return "TEST" + + def keys(self): + return ["TEST"] + + # invoke + content.environment_variables.update(Test()) + + # assert + assert mock_get.call_count == 1 + assert mock_patch.call_count == 1 + + @responses.activate + def test_other_is_iterable(self): + # data + guid = "f2f37341-e21d-3d80-c698-a935ad614066" + + # behavior + mock_get = responses.get( + f"https://connect.example.com/__api__/v1/content/{guid}", + json=load_mock(f"v1/content/{guid}.json"), + ) + + mock_patch = responses.patch( + f"https://connect.example.com/__api__/v1/content/{guid}/environment", + json=load_mock(f"v1/content/{guid}.json"), + match=[ + matchers.json_params_matcher( + [ + { + "name": "TEST", + "value": "TEST", + } + ] + ) + ], + ) + + # setup + c = Client("https://connect.example.com", "12345") + content = c.content.get(guid) + + # invoke + content.environment_variables.update([("TEST", "TEST")]) + + # assert + assert mock_get.call_count == 1 + assert mock_patch.call_count == 1 + + @responses.activate + def test_other_is_iterable_of_something_else(self): + # data + guid = "f2f37341-e21d-3d80-c698-a935ad614066" + + # behavior + mock_get = responses.get( + f"https://connect.example.com/__api__/v1/content/{guid}", + json=load_mock(f"v1/content/{guid}.json"), + ) + + mock_patch = responses.patch( + f"https://connect.example.com/__api__/v1/content/{guid}/environment", + json=load_mock(f"v1/content/{guid}.json"), + match=[ + matchers.json_params_matcher( + [ + { + "name": "TEST", + "value": "TEST", + } + ] + ) + ], + ) + + # setup + c = Client("https://connect.example.com", "12345") + content = c.content.get(guid) + + # invoke + with pytest.raises(TypeError): + content.environment_variables.update([0, 1, 2, 3, 4, 5]) + + @responses.activate + def test_other_is_str(self): + # data + guid = "f2f37341-e21d-3d80-c698-a935ad614066" + + # behavior + mock_get = responses.get( + f"https://connect.example.com/__api__/v1/content/{guid}", + json=load_mock(f"v1/content/{guid}.json"), + ) + + # setup + c = Client("https://connect.example.com", "12345") + content = c.content.get(guid) + + # invoke + with pytest.raises(ValueError): + content.environment_variables.update("TEST") + + @responses.activate + def test_other_is_bytes(self): + # data + guid = "f2f37341-e21d-3d80-c698-a935ad614066" + + # behavior + mock_get = responses.get( + f"https://connect.example.com/__api__/v1/content/{guid}", + json=load_mock(f"v1/content/{guid}.json"), + ) + + # setup + c = Client("https://connect.example.com", "12345") + content = c.content.get(guid) + + # invoke + with pytest.raises(TypeError): + content.environment_variables.update(b"TEST") + + @responses.activate + def test_other_is_something_else(self): + # data + guid = "f2f37341-e21d-3d80-c698-a935ad614066" + + # behavior + mock_get = responses.get( + f"https://connect.example.com/__api__/v1/content/{guid}", + json=load_mock(f"v1/content/{guid}.json"), + ) + + # setup + c = Client("https://connect.example.com", "12345") + content = c.content.get(guid) + + # invoke + with pytest.raises(TypeError): + content.environment_variables.update(0)