From 5d3f2b2603376dbb4cfb419938357468bdbbfb81 Mon Sep 17 00:00:00 2001 From: tdstein Date: Wed, 9 Oct 2024 09:29:29 -0400 Subject: [PATCH 01/12] --wip-- [skip ci] --- src/posit/connect/client.py | 6 ++++ src/posit/connect/content.py | 4 +-- src/posit/connect/context.py | 3 +- src/posit/connect/vanity.py | 60 ++++++++++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 src/posit/connect/vanity.py diff --git a/src/posit/connect/client.py b/src/posit/connect/client.py index 0f42f475..fd0f2d12 100644 --- a/src/posit/connect/client.py +++ b/src/posit/connect/client.py @@ -6,6 +6,8 @@ from requests import Response, Session +from posit.connect.vanity import Vanities + from . import hooks, me from .auth import Auth from .config import Config @@ -271,6 +273,10 @@ def oauth(self) -> OAuth: """ return OAuth(self.resource_params, self.cfg.api_key) + @property + def vanities(self) -> Vanities: + return Vanities(self.ctx) + def __del__(self): """Close the session when the Client instance is deleted.""" if hasattr(self, "session") and self.session is not None: diff --git a/src/posit/connect/content.py b/src/posit/connect/content.py index d90377d1..73215cc2 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -7,14 +7,14 @@ from posixpath import dirname from typing import Any, List, Literal, Optional, overload -from posit.connect.oauth.associations import ContentItemAssociations - from . import tasks from .bundles import Bundles from .env import EnvVars +from .oauth.associations import ContentItemAssociations from .permissions import Permissions from .resources import Resource, ResourceParameters, Resources from .tasks import Task +from .vanity import VanityContentMixin from .variants import Variants diff --git a/src/posit/connect/context.py b/src/posit/connect/context.py index 14b7e330..c93fe7b0 100644 --- a/src/posit/connect/context.py +++ b/src/posit/connect/context.py @@ -1,6 +1,7 @@ import functools from typing import Optional, Protocol +import requests from packaging.version import Version @@ -21,7 +22,7 @@ def wrapper(instance: ContextManager, *args, **kwargs): class Context(dict): - def __init__(self, session, url): + def __init__(self, session: requests.Session, url: str): self.session = session self.url = url diff --git a/src/posit/connect/vanity.py b/src/posit/connect/vanity.py new file mode 100644 index 00000000..5d62f1fb --- /dev/null +++ b/src/posit/connect/vanity.py @@ -0,0 +1,60 @@ +from typing import Callable, Optional + +from .context import Context + +AfterDestroyCallback = Callable[[], None] + + +class Vanity(dict): + def __init__( + self, ctx: Context, *, after_destroy: AfterDestroyCallback = lambda: None, **kwargs + ): + super().__init__(**kwargs) + self._ctx = ctx + self._after_destroy = after_destroy + + def destroy(self): + url = self._ctx.url + f"v1/content/{self['content_guid']}/vanity" + self._ctx.session.delete(url) + self._after_destroy() + + +class Vanities: + def __init__(self, ctx: Context) -> None: + self._ctx = ctx + + def all(self) -> list[Vanity]: + url = self._ctx.url + f"v1/vanities" + response = self._ctx.session.get(url) + results = response.json() + return [Vanity(self._ctx, **result) for result in results] + + +class VanityContentMixin(dict): + def __init__(self, ctx: Context, **kwargs): + super().__init__(**kwargs) + self._ctx = ctx + self._vanity: Optional[Vanity] = None + + @property + def vanity(self) -> Vanity: + if self._vanity is None: + url = self._ctx.url + f"v1/content/{self['guid']}/vanity" + response = self._ctx.session.get(url) + vanity_data = response.json() + # Set the after_destroy callback to reset _vanity to None when destroyed + after_destroy = lambda: setattr(self, "_vanity", None) + self._vanity = Vanity(self._ctx, after_destroy=after_destroy, **vanity_data) + return self._vanity + + @vanity.setter + def vanity(self, value: dict): + url = self._ctx.url + f"v1/content/{self['guid']}/vanity" + self._ctx.session.put(url, json=value) + # Refresh the vanity property to reflect the updated value + self._vanity = self.vanity + + @vanity.deleter + def vanity(self): + if self._vanity: + self._vanity.destroy() From 9f8443c6c5369b4e95fd4aea005644660cca2267 Mon Sep 17 00:00:00 2001 From: tdstein Date: Wed, 9 Oct 2024 12:31:57 -0400 Subject: [PATCH 02/12] --wip-- [skip ci] --- src/posit/connect/client.py | 5 +- src/posit/connect/content.py | 4 +- src/posit/connect/vanities.py | 104 ++++++++++++++++++ src/posit/connect/vanity.py | 60 ----------- tests/posit/connect/test_vanities.py | 153 +++++++++++++++++++++++++++ 5 files changed, 261 insertions(+), 65 deletions(-) create mode 100644 src/posit/connect/vanities.py delete mode 100644 src/posit/connect/vanity.py create mode 100644 tests/posit/connect/test_vanities.py diff --git a/src/posit/connect/client.py b/src/posit/connect/client.py index fd0f2d12..5c46dd85 100644 --- a/src/posit/connect/client.py +++ b/src/posit/connect/client.py @@ -6,8 +6,6 @@ from requests import Response, Session -from posit.connect.vanity import Vanities - from . import hooks, me from .auth import Auth from .config import Config @@ -19,6 +17,7 @@ from .resources import ResourceParameters from .tasks import Tasks from .users import User, Users +from .vanities import Vanities class Client(ContextManager): @@ -275,7 +274,7 @@ def oauth(self) -> OAuth: @property def vanities(self) -> Vanities: - return Vanities(self.ctx) + return Vanities(self.resource_params) def __del__(self): """Close the session when the Client instance is deleted.""" diff --git a/src/posit/connect/content.py b/src/posit/connect/content.py index 73215cc2..fdf2cc95 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -14,7 +14,7 @@ from .permissions import Permissions from .resources import Resource, ResourceParameters, Resources from .tasks import Task -from .vanity import VanityContentMixin +from .vanities import VanityMixin from .variants import Variants @@ -32,7 +32,7 @@ class ContentItemOwner(Resource): pass -class ContentItem(Resource): +class ContentItem(VanityMixin, Resource): def __getitem__(self, key: Any) -> Any: v = super().__getitem__(key) if key == "owner" and isinstance(v, dict): diff --git a/src/posit/connect/vanities.py b/src/posit/connect/vanities.py new file mode 100644 index 00000000..6d3fa56f --- /dev/null +++ b/src/posit/connect/vanities.py @@ -0,0 +1,104 @@ +from typing import Callable, Optional, Union, overload + +from .resources import Resource, ResourceParameters, Resources + +AfterDestroyCallback = Callable[[], None] + + +class Vanity(Resource): + """Represents a Vanity resource with the ability to destroy itself.""" + + def __init__( + self, + /, + params: ResourceParameters, + *, + after_destroy: AfterDestroyCallback = lambda: None, + **kwargs, + ): + super().__init__(params, **kwargs) + self._after_destroy = after_destroy + + def destroy(self) -> None: + """Destroy the vanity resource.""" + content_guid = self.get("content_guid") + if content_guid is None: + raise ValueError( + "The 'content_guid' is missing. Unable to perform the destroy operation." + ) + endpoint = self.params.url + f"v1/content/{content_guid}/vanity" + self.params.session.delete(endpoint) + self._after_destroy() + + +class Vanities(Resources): + """Manages a collection of Vanity resources.""" + + def all(self) -> list[Vanity]: + """Retrieve all vanity resources.""" + endpoint = self.params.url + "v1/vanities" + response = self.params.session.get(endpoint) + results = response.json() + return [Vanity(self.params, **result) for result in results] + + +class VanityMixin(Resource): + """Mixin class to add vanity management capabilities to a resource.""" + + def __init__(self, /, params: ResourceParameters, **kwargs): + super().__init__(params, **kwargs) + self._vanity: Optional[Vanity] = None + + @property + def vanity(self) -> Vanity: + """Retrieve or lazily load the associated vanity resource.""" + if self._vanity is None: + uid = self.get("guid") + if uid is None: + raise ValueError( + "The 'guid' is missing. Unable to perform the get vanity operation." + ) + endpoint = self.params.url + f"v1/content/{uid}/vanity" + response = self.params.session.get(endpoint) + result = response.json() + self._vanity = Vanity( + self.params, after_destroy=lambda: setattr(self, "_vanity", None), **result + ) + return self._vanity + + @vanity.setter + def vanity(self, value: Union[str, dict]) -> None: + """Set the vanity using a path or dictionary of attributes.""" + if isinstance(value, str): + self.set_vanity(path=value) + elif isinstance(value, dict): + self.set_vanity(**value) + self.reset() + + @vanity.deleter + def vanity(self) -> None: + """Delete the vanity resource.""" + if self._vanity: + self._vanity.destroy() + self.reset() + + def reset(self) -> None: + """Reset the cached vanity resource.""" + self._vanity = None + + @overload + def set_vanity(self, *, path: str) -> None: ... + + @overload + def set_vanity(self, *, path: str, force: bool) -> None: ... + + @overload + def set_vanity(self, **attributes) -> None: ... + + def set_vanity(self, **attributes) -> None: + """Set or update the vanity resource with given attributes.""" + uid = self.get("guid") + if uid is None: + raise ValueError("The 'guid' is missing. Unable to perform the set vanity operation.") + endpoint = self.params.url + f"v1/content/{uid}/vanity" + self.params.session.put(endpoint, json=attributes) diff --git a/src/posit/connect/vanity.py b/src/posit/connect/vanity.py deleted file mode 100644 index 5d62f1fb..00000000 --- a/src/posit/connect/vanity.py +++ /dev/null @@ -1,60 +0,0 @@ -from typing import Callable, Optional - -from .context import Context - -AfterDestroyCallback = Callable[[], None] - - -class Vanity(dict): - def __init__( - self, ctx: Context, *, after_destroy: AfterDestroyCallback = lambda: None, **kwargs - ): - super().__init__(**kwargs) - self._ctx = ctx - self._after_destroy = after_destroy - - def destroy(self): - url = self._ctx.url + f"v1/content/{self['content_guid']}/vanity" - self._ctx.session.delete(url) - self._after_destroy() - - -class Vanities: - def __init__(self, ctx: Context) -> None: - self._ctx = ctx - - def all(self) -> list[Vanity]: - url = self._ctx.url + f"v1/vanities" - response = self._ctx.session.get(url) - results = response.json() - return [Vanity(self._ctx, **result) for result in results] - - -class VanityContentMixin(dict): - def __init__(self, ctx: Context, **kwargs): - super().__init__(**kwargs) - self._ctx = ctx - self._vanity: Optional[Vanity] = None - - @property - def vanity(self) -> Vanity: - if self._vanity is None: - url = self._ctx.url + f"v1/content/{self['guid']}/vanity" - response = self._ctx.session.get(url) - vanity_data = response.json() - # Set the after_destroy callback to reset _vanity to None when destroyed - after_destroy = lambda: setattr(self, "_vanity", None) - self._vanity = Vanity(self._ctx, after_destroy=after_destroy, **vanity_data) - return self._vanity - - @vanity.setter - def vanity(self, value: dict): - url = self._ctx.url + f"v1/content/{self['guid']}/vanity" - self._ctx.session.put(url, json=value) - # Refresh the vanity property to reflect the updated value - self._vanity = self.vanity - - @vanity.deleter - def vanity(self): - if self._vanity: - self._vanity.destroy() diff --git a/tests/posit/connect/test_vanities.py b/tests/posit/connect/test_vanities.py new file mode 100644 index 00000000..c39c3b91 --- /dev/null +++ b/tests/posit/connect/test_vanities.py @@ -0,0 +1,153 @@ +from unittest.mock import Mock + +import pytest +import requests +import responses +from responses.matchers import json_params_matcher + +from posit.connect.resources import ResourceParameters +from posit.connect.urls import Url +from posit.connect.vanities import Vanities, Vanity, VanityMixin + + +class TestVanityDestroy: + @responses.activate + def test_destroy_sends_delete_request(self): + content_guid = "8ce6eaca-60af-4c2f-93a0-f5f3cddf5ee5" + base_url = "http://connect.example/__api__" + endpoint = f"{base_url}/v1/content/{content_guid}/vanity" + mock_delete = responses.delete(endpoint) + + session = requests.Session() + url = Url(base_url) + params = ResourceParameters(session, url) + vanity = Vanity(params, content_guid=content_guid) + + vanity.destroy() + + assert mock_delete.call_count == 1 + + def test_destroy_without_content_guid_raises_value_error(self): + vanity = Vanity(params=Mock()) + with pytest.raises(ValueError): + vanity.destroy() + + def test_destroy_with_none_content_guid_raises_value_error(self): + vanity = Vanity(params=Mock(), content_guid=None) + with pytest.raises(ValueError): + vanity.destroy() + + @responses.activate + def test_destroy_calls_after_destroy_callback(self): + content_guid = "8ce6eaca-60af-4c2f-93a0-f5f3cddf5ee5" + base_url = "http://connect.example/__api__" + endpoint = f"{base_url}/v1/content/{content_guid}/vanity" + responses.delete(endpoint) + + session = requests.Session() + url = Url(base_url) + after_destroy = Mock() + params = ResourceParameters(session, url) + vanity = Vanity(params, after_destroy=after_destroy, content_guid=content_guid) + + vanity.destroy() + + assert after_destroy.call_count == 1 + + +class TestVanitiesAll: + @responses.activate + def test_all_sends_get_request(self): + base_url = "http://connect.example/__api__" + endpoint = f"{base_url}/v1/vanities" + mock_get = responses.get(endpoint, json=[]) + + session = requests.Session() + url = Url(base_url) + params = ResourceParameters(session, url) + vanities = Vanities(params) + + vanities.all() + + assert mock_get.call_count == 1 + + +class TestVanityMixin: + @responses.activate + def test_vanity_getter_returns_vanity(self): + guid = "8ce6eaca-60af-4c2f-93a0-f5f3cddf5ee5" + base_url = "http://connect.example/__api__" + endpoint = f"{base_url}/v1/content/{guid}/vanity" + vanity_data = {"content_guid": guid} + mock_get = responses.get(endpoint, json=vanity_data) + + session = requests.Session() + url = Url(base_url) + params = ResourceParameters(session, url) + content = VanityMixin(params, guid=guid) + + assert content.vanity == vanity_data + assert mock_get.call_count == 1 + + @responses.activate + def test_vanity_setter_with_string(self): + guid = "8ce6eaca-60af-4c2f-93a0-f5f3cddf5ee5" + base_url = "http://connect.example/__api__" + endpoint = f"{base_url}/v1/content/{guid}/vanity" + path = "example" + mock_put = responses.put(endpoint, match=[json_params_matcher({"path": path})]) + + session = requests.Session() + url = Url(base_url) + params = ResourceParameters(session, url) + content = VanityMixin(params, guid=guid) + content.vanity = path + + assert mock_put.call_count == 1 + + @responses.activate + def test_vanity_setter_with_dict(self): + guid = "8ce6eaca-60af-4c2f-93a0-f5f3cddf5ee5" + base_url = "http://connect.example/__api__" + endpoint = f"{base_url}/v1/content/{guid}/vanity" + vanity_attrs = {"path": "example", "locked": True} + mock_put = responses.put(endpoint, match=[json_params_matcher(vanity_attrs)]) + + session = requests.Session() + url = Url(base_url) + params = ResourceParameters(session, url) + content = VanityMixin(params, guid=guid) + content.vanity = vanity_attrs + + assert mock_put.call_count == 1 + + @responses.activate + def test_vanity_deleter_sends_delete_request(self): + guid = "8ce6eaca-60af-4c2f-93a0-f5f3cddf5ee5" + base_url = "http://connect.example/__api__" + endpoint = f"{base_url}/v1/content/{guid}/vanity" + mock_delete = responses.delete(endpoint) + + session = requests.Session() + url = Url(base_url) + params = ResourceParameters(session, url) + content = VanityMixin(params, guid=guid) + content._vanity = Vanity(params, content_guid=guid) + del content.vanity + + assert mock_delete.call_count == 1 + + @responses.activate + def test_set_vanity(self): + guid = "8ce6eaca-60af-4c2f-93a0-f5f3cddf5ee5" + base_url = "http://connect.example/__api__" + endpoint = f"{base_url}/v1/content/{guid}/vanity" + mock_put = responses.put(endpoint) + + session = requests.Session() + url = Url(base_url) + params = ResourceParameters(session, url) + content = VanityMixin(params, guid=guid) + content.set_vanity(path="example") + + assert mock_put.call_count == 1 From ea13944573556704baf80d31ad0f03619d3c0d6c Mon Sep 17 00:00:00 2001 From: tdstein Date: Wed, 9 Oct 2024 14:39:05 -0400 Subject: [PATCH 03/12] feat: add vanities --- .../tests/posit/connect/test_vanities.py | 75 +++++++++++++++++++ src/posit/connect/vanities.py | 39 +++++----- tests/posit/connect/test_vanities.py | 26 ++----- 3 files changed, 103 insertions(+), 37 deletions(-) create mode 100644 integration/tests/posit/connect/test_vanities.py diff --git a/integration/tests/posit/connect/test_vanities.py b/integration/tests/posit/connect/test_vanities.py new file mode 100644 index 00000000..b6dcc927 --- /dev/null +++ b/integration/tests/posit/connect/test_vanities.py @@ -0,0 +1,75 @@ +from posit import connect + + +class TestVanities: + + @classmethod + def setup_class(cls): + cls.client = connect.Client() + + @classmethod + def teardown_class(cls): + assert cls.client.content.count() == 0 + + def test_all(self): + content = self.client.content.create(name="example") + + # None by default + vanities = self.client.vanities.all() + assert len(vanities) == 0 + + # Set + content.vanity = "example" + + # Get + vanities = self.client.vanities.all() + assert len(vanities) == 1 + + # Cleanup + content.delete() + + vanities = self.client.vanities.all() + assert len(vanities) == 0 + + def test_property(self): + content = self.client.content.create(name="example") + + # None by default + assert content.vanity is None + + # Set + content.vanity = "example" + + # Get + vanity = content.vanity + assert vanity + assert vanity["path"] == "/example/" + + # Delete + del content.vanity + assert content.vanity is None + + # Cleanup + content.delete() + + + def test_destroy(self): + content = self.client.content.create(name="example") + + # None by default + assert content.vanity is None + + # Set + content.vanity = "example" + + # Get + vanity = content.vanity + assert vanity + assert vanity["path"] == "/example/" + + # Delete + vanity.destroy() + assert content.vanity is None + + # Cleanup + content.delete() diff --git a/src/posit/connect/vanities.py b/src/posit/connect/vanities.py index 6d3fa56f..55474186 100644 --- a/src/posit/connect/vanities.py +++ b/src/posit/connect/vanities.py @@ -1,4 +1,6 @@ -from typing import Callable, Optional, Union, overload +from typing import Callable, List, Optional, Union, overload + +from posit.connect.errors import ClientError from .resources import Resource, ResourceParameters, Resources @@ -21,12 +23,10 @@ def __init__( def destroy(self) -> None: """Destroy the vanity resource.""" - content_guid = self.get("content_guid") - if content_guid is None: - raise ValueError( - "The 'content_guid' is missing. Unable to perform the destroy operation." - ) - endpoint = self.params.url + f"v1/content/{content_guid}/vanity" + fuid = self.get("content_guid") + if fuid is None: + raise ValueError("Missing value for required field: 'content_guid'.") + endpoint = self.params.url + f"v1/content/{fuid}/vanity" self.params.session.delete(endpoint) self._after_destroy() @@ -34,7 +34,7 @@ def destroy(self) -> None: class Vanities(Resources): """Manages a collection of Vanity resources.""" - def all(self) -> list[Vanity]: + def all(self) -> List[Vanity]: """Retrieve all vanity resources.""" endpoint = self.params.url + "v1/vanities" response = self.params.session.get(endpoint) @@ -50,21 +50,24 @@ def __init__(self, /, params: ResourceParameters, **kwargs): self._vanity: Optional[Vanity] = None @property - def vanity(self) -> Vanity: + def vanity(self) -> Optional[Vanity]: """Retrieve or lazily load the associated vanity resource.""" - if self._vanity is None: + if self._vanity: + return self._vanity + + try: uid = self.get("guid") if uid is None: - raise ValueError( - "The 'guid' is missing. Unable to perform the get vanity operation." - ) + raise ValueError("Missing value for required field: 'guid'.") endpoint = self.params.url + f"v1/content/{uid}/vanity" response = self.params.session.get(endpoint) result = response.json() - self._vanity = Vanity( - self.params, after_destroy=lambda: setattr(self, "_vanity", None), **result - ) - return self._vanity + self._vanity = Vanity(self.params, after_destroy=self.reset, **result) + return self._vanity + except ClientError as e: + if e.http_status == 404: + return None + raise e @vanity.setter def vanity(self, value: Union[str, dict]) -> None: @@ -99,6 +102,6 @@ def set_vanity(self, **attributes) -> None: """Set or update the vanity resource with given attributes.""" uid = self.get("guid") if uid is None: - raise ValueError("The 'guid' is missing. Unable to perform the set vanity operation.") + raise ValueError("Missing value for required field: 'guid'.") endpoint = self.params.url + f"v1/content/{uid}/vanity" self.params.session.put(endpoint, json=attributes) diff --git a/tests/posit/connect/test_vanities.py b/tests/posit/connect/test_vanities.py index c39c3b91..8fb0df85 100644 --- a/tests/posit/connect/test_vanities.py +++ b/tests/posit/connect/test_vanities.py @@ -104,25 +104,27 @@ def test_vanity_setter_with_string(self): content.vanity = path assert mock_put.call_count == 1 + assert content._vanity is None @responses.activate def test_vanity_setter_with_dict(self): guid = "8ce6eaca-60af-4c2f-93a0-f5f3cddf5ee5" base_url = "http://connect.example/__api__" endpoint = f"{base_url}/v1/content/{guid}/vanity" - vanity_attrs = {"path": "example", "locked": True} - mock_put = responses.put(endpoint, match=[json_params_matcher(vanity_attrs)]) + attrs = {"path": "example", "locked": True} + mock_put = responses.put(endpoint, match=[json_params_matcher(attrs)]) session = requests.Session() url = Url(base_url) params = ResourceParameters(session, url) content = VanityMixin(params, guid=guid) - content.vanity = vanity_attrs + content.vanity = attrs + assert content._vanity is None assert mock_put.call_count == 1 @responses.activate - def test_vanity_deleter_sends_delete_request(self): + def test_vanity_deleter(self): guid = "8ce6eaca-60af-4c2f-93a0-f5f3cddf5ee5" base_url = "http://connect.example/__api__" endpoint = f"{base_url}/v1/content/{guid}/vanity" @@ -135,19 +137,5 @@ def test_vanity_deleter_sends_delete_request(self): content._vanity = Vanity(params, content_guid=guid) del content.vanity + assert content._vanity is None assert mock_delete.call_count == 1 - - @responses.activate - def test_set_vanity(self): - guid = "8ce6eaca-60af-4c2f-93a0-f5f3cddf5ee5" - base_url = "http://connect.example/__api__" - endpoint = f"{base_url}/v1/content/{guid}/vanity" - mock_put = responses.put(endpoint) - - session = requests.Session() - url = Url(base_url) - params = ResourceParameters(session, url) - content = VanityMixin(params, guid=guid) - content.set_vanity(path="example") - - assert mock_put.call_count == 1 From 7f3708704c0ee546d97d41d0b10c4109a7ff69bd Mon Sep 17 00:00:00 2001 From: tdstein Date: Thu, 10 Oct 2024 11:56:22 -0400 Subject: [PATCH 04/12] docs: add documentation for vanities --- .vscode/settings.json | 3 - docs/_quarto.yml | 1 + src/posit/connect/vanities.py | 178 +++++++++++++++++++++++++++++----- 3 files changed, 157 insertions(+), 25 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 4169db45..6fbbf1ba 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,7 +4,4 @@ ], "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true, - "cSpell.words": [ - "mypy" - ] } diff --git a/docs/_quarto.yml b/docs/_quarto.yml index a0555602..4cacdfce 100644 --- a/docs/_quarto.yml +++ b/docs/_quarto.yml @@ -104,6 +104,7 @@ quartodoc: - connect.permissions - connect.tasks - connect.users + - connect.vanities - title: Posit Connect Metrics package: posit contents: diff --git a/src/posit/connect/vanities.py b/src/posit/connect/vanities.py index 55474186..a21afcec 100644 --- a/src/posit/connect/vanities.py +++ b/src/posit/connect/vanities.py @@ -8,34 +8,103 @@ class Vanity(Resource): - """Represents a Vanity resource with the ability to destroy itself.""" + """A vanity resource. + + Vanities maintain custom URL paths assigned to content. + + Warnings + -------- + Vanity paths may only contain alphanumeric characters, hyphens, underscores, and slashes. + + Vanities cannot have children. For example, if the vanity path "/finance/" exists, the vanity path "/finance/budget/" cannot. But, if "/finance" does not exist, both "/finance/budget/" and "/finance/report" are allowed. + + The following vanities are reserved by Connect: + - `/__` + - `/favicon.ico` + - `/connect` + - `/apps` + - `/users` + - `/groups` + - `/setpassword` + - `/user-completion` + - `/confirm` + - `/recent` + - `/reports` + - `/plots` + - `/unpublished` + - `/settings` + - `/metrics` + - `/tokens` + - `/help` + - `/login` + - `/welcome` + - `/register` + - `/resetpassword` + - `/content` + """ + + _fuid: str = "content_guid" + """str : the foreign unique identifier field that points to the owner of this vanity, default is 'content_guid'""" def __init__( self, /, params: ResourceParameters, *, - after_destroy: AfterDestroyCallback = lambda: None, + after_destroy: Optional[AfterDestroyCallback] = None, **kwargs, ): + """Initialize a Vanity. + + Parameters + ---------- + params : ResourceParameters + after_destroy : AfterDestroyCallback, optional + Called after the Vanity is successfully destroyed, by default None + """ super().__init__(params, **kwargs) self._after_destroy = after_destroy def destroy(self) -> None: - """Destroy the vanity resource.""" + """Destroy the vanity. + + Raises + ------ + ValueError + If the foreign unique identifier is missing or its value is `None`. + + Warnings + -------- + This operation is irreversible. + + Note + ---- + This action requires administrator privileges. + """ fuid = self.get("content_guid") if fuid is None: raise ValueError("Missing value for required field: 'content_guid'.") endpoint = self.params.url + f"v1/content/{fuid}/vanity" self.params.session.delete(endpoint) - self._after_destroy() + + if self._after_destroy: + self._after_destroy() class Vanities(Resources): - """Manages a collection of Vanity resources.""" + """Manages a collection of vanities.""" def all(self) -> List[Vanity]: - """Retrieve all vanity resources.""" + """Retrieve all vanities. + + Returns + ------- + List[Vanity] + + Notes + ----- + This action requires administrator privileges. + """ endpoint = self.params.url + "v1/vanities" response = self.params.session.get(endpoint) results = response.json() @@ -43,7 +112,10 @@ def all(self) -> List[Vanity]: class VanityMixin(Resource): - """Mixin class to add vanity management capabilities to a resource.""" + """Mixin class to add a vanity attribute to a resource.""" + + _uid: str = "guid" + """str : the unique identifier field for this resource""" def __init__(self, /, params: ResourceParameters, **kwargs): super().__init__(params, **kwargs) @@ -62,7 +134,7 @@ def vanity(self) -> Optional[Vanity]: endpoint = self.params.url + f"v1/content/{uid}/vanity" response = self.params.session.get(endpoint) result = response.json() - self._vanity = Vanity(self.params, after_destroy=self.reset, **result) + self._vanity = Vanity(self.params, after_destroy=self.reset_vanity, **result) return self._vanity except ClientError as e: if e.http_status == 404: @@ -71,37 +143,99 @@ def vanity(self) -> Optional[Vanity]: @vanity.setter def vanity(self, value: Union[str, dict]) -> None: - """Set the vanity using a path or dictionary of attributes.""" + """Set the vanity. + + Parameters + ---------- + value : str or dict + The value can be a string or a dictionary. If provided as a string, it represents the vanity path. If provided as a dictionary, it contains key-value pairs with detailed information about the object. + """ if isinstance(value, str): self.set_vanity(path=value) elif isinstance(value, dict): self.set_vanity(**value) - self.reset() + self.reset_vanity() @vanity.deleter def vanity(self) -> None: - """Delete the vanity resource.""" + """Destroy the vanity. + + Warnings + -------- + This operation is irreversible. + + Note + ---- + This action requires administrator privileges. + + See Also + -------- + reset_vanity + """ if self._vanity: self._vanity.destroy() - self.reset() + self.reset_vanity() - def reset(self) -> None: - """Reset the cached vanity resource.""" + def reset_vanity(self) -> None: + """Unload the cached vanity. + + Forces the next access, if any, to query the vanity from the Connect server. + """ self._vanity = None @overload - def set_vanity(self, *, path: str) -> None: ... + def set_vanity(self, *, path: str) -> None: + """Set the vanity. + + Parameters + ---------- + path : str + The vanity path. + + Raises + ------ + ValueError + If the unique identifier field is missing or the value is None. + """ + ... @overload - def set_vanity(self, *, path: str, force: bool) -> None: ... + def set_vanity(self, *, path: str, force: bool) -> None: + """Set the vanity. + + Parameters + ---------- + path : str + The vanity path. + force : bool + If `True`, overwrite the ownership of this vanity to this resource, default `False` + + Raises + ------ + ValueError + If the unique identifier field is missing or the value is None. + """ + ... @overload - def set_vanity(self, **attributes) -> None: ... + def set_vanity(self, **attributes) -> None: + """Set the vanity. + + Parameters + ---------- + **attributes : dict, optional + Arbitrary vanity attributes. All attributes are passed as the request body to POST 'v1/content/:guid/vanity' + + Possible keys may include: + - `path` : str + - `force` : bool + """ + ... def set_vanity(self, **attributes) -> None: - """Set or update the vanity resource with given attributes.""" - uid = self.get("guid") - if uid is None: - raise ValueError("Missing value for required field: 'guid'.") - endpoint = self.params.url + f"v1/content/{uid}/vanity" + """Set the vanity.""" + v = self.get(self._uid) + if v is None: + raise ValueError(f"Missing value for required field: '{self._uid}'.") + endpoint = self.params.url + f"v1/content/{v}/vanity" self.params.session.put(endpoint, json=attributes) From e6897529d9df24696a73f7391fb8c3e25958f11c Mon Sep 17 00:00:00 2001 From: tdstein Date: Thu, 10 Oct 2024 12:44:09 -0400 Subject: [PATCH 05/12] consistent references --- src/posit/connect/vanities.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/posit/connect/vanities.py b/src/posit/connect/vanities.py index a21afcec..758163eb 100644 --- a/src/posit/connect/vanities.py +++ b/src/posit/connect/vanities.py @@ -128,10 +128,10 @@ def vanity(self) -> Optional[Vanity]: return self._vanity try: - uid = self.get("guid") - if uid is None: - raise ValueError("Missing value for required field: 'guid'.") - endpoint = self.params.url + f"v1/content/{uid}/vanity" + v = self.get(self._uid) + if v is None: + raise ValueError(f"Missing value for required field: '{self._uid}'.") + endpoint = self.params.url + f"v1/content/{v}/vanity" response = self.params.session.get(endpoint) result = response.json() self._vanity = Vanity(self.params, after_destroy=self.reset_vanity, **result) From ab693421cb153a03754acd96a82631d9b598ca30 Mon Sep 17 00:00:00 2001 From: tdstein Date: Thu, 10 Oct 2024 14:59:21 -0400 Subject: [PATCH 06/12] documentation --- src/posit/connect/vanities.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/posit/connect/vanities.py b/src/posit/connect/vanities.py index 758163eb..550d0e65 100644 --- a/src/posit/connect/vanities.py +++ b/src/posit/connect/vanities.py @@ -44,7 +44,7 @@ class Vanity(Resource): """ _fuid: str = "content_guid" - """str : the foreign unique identifier field that points to the owner of this vanity, default is 'content_guid'""" + """str : the foreign unique identifier field that points to the owner of this vanity, by default 'content_guid'""" def __init__( self, @@ -224,16 +224,28 @@ def set_vanity(self, **attributes) -> None: Parameters ---------- **attributes : dict, optional - Arbitrary vanity attributes. All attributes are passed as the request body to POST 'v1/content/:guid/vanity' + Arbitrary attributes. All attributes are passed as the request body to POST 'v1/content/:guid/vanity' - Possible keys may include: - - `path` : str - - `force` : bool + Raises + ------ + ValueError + If the unique identifier field is missing or the value is None. """ ... def set_vanity(self, **attributes) -> None: - """Set the vanity.""" + """Set the vanity. + + Parameters + ---------- + **attributes : dict, optional + Arbitrary attributes. All attributes are passed as the request body to POST 'v1/content/:guid/vanity' + + Raises + ------ + ValueError + If the unique identifier field is missing or the value is None. + """ v = self.get(self._uid) if v is None: raise ValueError(f"Missing value for required field: '{self._uid}'.") From 91aa99b92b65378810395d648a43a4b30b9ad347 Mon Sep 17 00:00:00 2001 From: tdstein Date: Thu, 10 Oct 2024 22:10:58 -0400 Subject: [PATCH 07/12] refactor: correct method names and reduce fluff. --- src/posit/connect/vanities.py | 121 ++++++++++----------------- tests/posit/connect/test_vanities.py | 11 --- 2 files changed, 42 insertions(+), 90 deletions(-) diff --git a/src/posit/connect/vanities.py b/src/posit/connect/vanities.py index 550d0e65..3db144da 100644 --- a/src/posit/connect/vanities.py +++ b/src/posit/connect/vanities.py @@ -1,7 +1,8 @@ -from typing import Callable, List, Optional, Union, overload +from typing import Callable, List, Optional, TypedDict, Union -from posit.connect.errors import ClientError +from typing_extensions import NotRequired, Required, Unpack +from .errors import ClientError from .resources import Resource, ResourceParameters, Resources AfterDestroyCallback = Callable[[], None] @@ -51,6 +52,7 @@ def __init__( /, params: ResourceParameters, *, + content_guid: str, after_destroy: Optional[AfterDestroyCallback] = None, **kwargs, ): @@ -62,7 +64,8 @@ def __init__( after_destroy : AfterDestroyCallback, optional Called after the Vanity is successfully destroyed, by default None """ - super().__init__(params, **kwargs) + super().__init__(params, content_guid=content_guid, **kwargs) + self._endpoint = self.params.url + f"v1/content/{content_guid}/vanity" self._after_destroy = after_destroy def destroy(self) -> None: @@ -81,11 +84,7 @@ def destroy(self) -> None: ---- This action requires administrator privileges. """ - fuid = self.get("content_guid") - if fuid is None: - raise ValueError("Missing value for required field: 'content_guid'.") - endpoint = self.params.url + f"v1/content/{fuid}/vanity" - self.params.session.delete(endpoint) + self.params.session.delete(self._endpoint) if self._after_destroy: self._after_destroy() @@ -114,27 +113,29 @@ def all(self) -> List[Vanity]: class VanityMixin(Resource): """Mixin class to add a vanity attribute to a resource.""" - _uid: str = "guid" - """str : the unique identifier field for this resource""" + class HasGuid(TypedDict): + """Has a guid.""" + + guid: str - def __init__(self, /, params: ResourceParameters, **kwargs): + def __init__(self, /, params: ResourceParameters, **kwargs: Unpack[HasGuid]): super().__init__(params, **kwargs) + self._uid = kwargs['guid'] self._vanity: Optional[Vanity] = None + @property + def _endpoint(self): + return self.params.url + f"v1/content/{self._uid}/vanity" + @property def vanity(self) -> Optional[Vanity]: - """Retrieve or lazily load the associated vanity resource.""" + """Get the vanity.""" if self._vanity: return self._vanity try: - v = self.get(self._uid) - if v is None: - raise ValueError(f"Missing value for required field: '{self._uid}'.") - endpoint = self.params.url + f"v1/content/{v}/vanity" - response = self.params.session.get(endpoint) - result = response.json() - self._vanity = Vanity(self.params, after_destroy=self.reset_vanity, **result) + self._vanity = self.find_vanity() + self._vanity._after_destroy = self.reset_vanity return self._vanity except ClientError as e: if e.http_status == 404: @@ -142,7 +143,7 @@ def vanity(self) -> Optional[Vanity]: raise e @vanity.setter - def vanity(self, value: Union[str, dict]) -> None: + def vanity(self, value: Union[str, "CreateVanityRequest"]) -> None: """Set the vanity. Parameters @@ -151,9 +152,9 @@ def vanity(self, value: Union[str, dict]) -> None: The value can be a string or a dictionary. If provided as a string, it represents the vanity path. If provided as a dictionary, it contains key-value pairs with detailed information about the object. """ if isinstance(value, str): - self.set_vanity(path=value) + self.create_vanity(path=value) elif isinstance(value, dict): - self.set_vanity(**value) + self.create_vanity(**value) self.reset_vanity() @vanity.deleter @@ -183,71 +184,33 @@ def reset_vanity(self) -> None: """ self._vanity = None - @overload - def set_vanity(self, *, path: str) -> None: - """Set the vanity. - - Parameters - ---------- - path : str - The vanity path. - - Raises - ------ - ValueError - If the unique identifier field is missing or the value is None. - """ - ... + class CreateVanityRequest(TypedDict, total=False): + """A request schema for creating a vanity. - @overload - def set_vanity(self, *, path: str, force: bool) -> None: - """Set the vanity. - - Parameters + Attributes ---------- path : str - The vanity path. + The path for the vanity. force : bool - If `True`, overwrite the ownership of this vanity to this resource, default `False` - - Raises - ------ - ValueError - If the unique identifier field is missing or the value is None. + Whether to force the creation of the vanity. """ - ... - - @overload - def set_vanity(self, **attributes) -> None: - """Set the vanity. - Parameters - ---------- - **attributes : dict, optional - Arbitrary attributes. All attributes are passed as the request body to POST 'v1/content/:guid/vanity' - - Raises - ------ - ValueError - If the unique identifier field is missing or the value is None. - """ - ... + path: Required[str] + force: NotRequired[bool] - def set_vanity(self, **attributes) -> None: - """Set the vanity. + def create_vanity(self, **kwargs: Unpack[CreateVanityRequest]) -> None: + """Create a vanity. Parameters ---------- - **attributes : dict, optional - Arbitrary attributes. All attributes are passed as the request body to POST 'v1/content/:guid/vanity' - - Raises - ------ - ValueError - If the unique identifier field is missing or the value is None. + path : str, required + The path for the vanity. + force : bool, not required + Whether to force the creation of the vanity, default False """ - v = self.get(self._uid) - if v is None: - raise ValueError(f"Missing value for required field: '{self._uid}'.") - endpoint = self.params.url + f"v1/content/{v}/vanity" - self.params.session.put(endpoint, json=attributes) + self.params.session.put(self._endpoint, json=kwargs) + + def find_vanity(self): + response = self.params.session.get(self._endpoint) + result = response.json() + return Vanity(self.params, **result) diff --git a/tests/posit/connect/test_vanities.py b/tests/posit/connect/test_vanities.py index 8fb0df85..73a8506e 100644 --- a/tests/posit/connect/test_vanities.py +++ b/tests/posit/connect/test_vanities.py @@ -1,6 +1,5 @@ from unittest.mock import Mock -import pytest import requests import responses from responses.matchers import json_params_matcher @@ -27,16 +26,6 @@ def test_destroy_sends_delete_request(self): assert mock_delete.call_count == 1 - def test_destroy_without_content_guid_raises_value_error(self): - vanity = Vanity(params=Mock()) - with pytest.raises(ValueError): - vanity.destroy() - - def test_destroy_with_none_content_guid_raises_value_error(self): - vanity = Vanity(params=Mock(), content_guid=None) - with pytest.raises(ValueError): - vanity.destroy() - @responses.activate def test_destroy_calls_after_destroy_callback(self): content_guid = "8ce6eaca-60af-4c2f-93a0-f5f3cddf5ee5" From 3f94ebdd6824dc891d4b7b39ad7724a0033f1c8d Mon Sep 17 00:00:00 2001 From: tdstein Date: Thu, 10 Oct 2024 22:39:06 -0400 Subject: [PATCH 08/12] cleanup --- .../tests/posit/connect/test_vanities.py | 2 -- src/posit/connect/vanities.py | 35 ++++++++++++------- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/integration/tests/posit/connect/test_vanities.py b/integration/tests/posit/connect/test_vanities.py index b6dcc927..790e19ea 100644 --- a/integration/tests/posit/connect/test_vanities.py +++ b/integration/tests/posit/connect/test_vanities.py @@ -2,7 +2,6 @@ class TestVanities: - @classmethod def setup_class(cls): cls.client = connect.Client() @@ -52,7 +51,6 @@ def test_property(self): # Cleanup content.delete() - def test_destroy(self): content = self.client.content.create(name="example") diff --git a/src/posit/connect/vanities.py b/src/posit/connect/vanities.py index 3db144da..46098d9e 100644 --- a/src/posit/connect/vanities.py +++ b/src/posit/connect/vanities.py @@ -5,8 +5,6 @@ from .errors import ClientError from .resources import Resource, ResourceParameters, Resources -AfterDestroyCallback = Callable[[], None] - class Vanity(Resource): """A vanity resource. @@ -44,17 +42,22 @@ class Vanity(Resource): - `/content` """ - _fuid: str = "content_guid" - """str : the foreign unique identifier field that points to the owner of this vanity, by default 'content_guid'""" + AfterDestroyCallback = Callable[[], None] + + class VanityAttributes(TypedDict): + """Vanity attributes.""" + + path: str + content_guid: Required[str] + created_time: str def __init__( self, /, params: ResourceParameters, *, - content_guid: str, after_destroy: Optional[AfterDestroyCallback] = None, - **kwargs, + **kwargs: Unpack[VanityAttributes], ): """Initialize a Vanity. @@ -64,9 +67,13 @@ def __init__( after_destroy : AfterDestroyCallback, optional Called after the Vanity is successfully destroyed, by default None """ - super().__init__(params, content_guid=content_guid, **kwargs) - self._endpoint = self.params.url + f"v1/content/{content_guid}/vanity" + super().__init__(params, **kwargs) self._after_destroy = after_destroy + self._content_guid = kwargs["content_guid"] + + @property + def _endpoint(self): + return self.params.url + f"v1/content/{self._content_guid}/vanity" def destroy(self) -> None: """Destroy the vanity. @@ -120,12 +127,12 @@ class HasGuid(TypedDict): def __init__(self, /, params: ResourceParameters, **kwargs: Unpack[HasGuid]): super().__init__(params, **kwargs) - self._uid = kwargs['guid'] + self._content_guid = kwargs["guid"] self._vanity: Optional[Vanity] = None @property def _endpoint(self): - return self.params.url + f"v1/content/{self._uid}/vanity" + return self.params.url + f"v1/content/{self._content_guid}/vanity" @property def vanity(self) -> Optional[Vanity]: @@ -148,8 +155,12 @@ def vanity(self, value: Union[str, "CreateVanityRequest"]) -> None: Parameters ---------- - value : str or dict - The value can be a string or a dictionary. If provided as a string, it represents the vanity path. If provided as a dictionary, it contains key-value pairs with detailed information about the object. + value : str or CreateVanityRequest + The value can be a str or a CreateVanityRequest. If provided as a string, it is the vanity path. + + See Also + -------- + create_vanity """ if isinstance(value, str): self.create_vanity(path=value) From abb26ba26e413cbc0393c579e4a15286216616b3 Mon Sep 17 00:00:00 2001 From: tdstein Date: Fri, 11 Oct 2024 11:45:17 -0400 Subject: [PATCH 09/12] always use the path for content.vanity attribute --- .../tests/posit/connect/test_vanities.py | 5 +- src/posit/connect/vanities.py | 69 ++++++++++--------- tests/posit/connect/test_vanities.py | 32 +++------ 3 files changed, 49 insertions(+), 57 deletions(-) diff --git a/integration/tests/posit/connect/test_vanities.py b/integration/tests/posit/connect/test_vanities.py index 790e19ea..e72c725a 100644 --- a/integration/tests/posit/connect/test_vanities.py +++ b/integration/tests/posit/connect/test_vanities.py @@ -41,8 +41,7 @@ def test_property(self): # Get vanity = content.vanity - assert vanity - assert vanity["path"] == "/example/" + assert vanity == "/example/" # Delete del content.vanity @@ -61,7 +60,7 @@ def test_destroy(self): content.vanity = "example" # Get - vanity = content.vanity + vanity = content.find_vanity() assert vanity assert vanity["path"] == "/example/" diff --git a/src/posit/connect/vanities.py b/src/posit/connect/vanities.py index 46098d9e..ffd6976b 100644 --- a/src/posit/connect/vanities.py +++ b/src/posit/connect/vanities.py @@ -1,4 +1,4 @@ -from typing import Callable, List, Optional, TypedDict, Union +from typing import Callable, List, Optional, TypedDict from typing_extensions import NotRequired, Required, Unpack @@ -47,9 +47,9 @@ class Vanity(Resource): class VanityAttributes(TypedDict): """Vanity attributes.""" - path: str + path: Required[str] content_guid: Required[str] - created_time: str + created_time: Required[str] def __init__( self, @@ -123,7 +123,7 @@ class VanityMixin(Resource): class HasGuid(TypedDict): """Has a guid.""" - guid: str + guid: Required[str] def __init__(self, /, params: ResourceParameters, **kwargs: Unpack[HasGuid]): super().__init__(params, **kwargs) @@ -135,38 +135,38 @@ def _endpoint(self): return self.params.url + f"v1/content/{self._content_guid}/vanity" @property - def vanity(self) -> Optional[Vanity]: + def vanity(self) -> Optional[str]: """Get the vanity.""" if self._vanity: - return self._vanity + return self._vanity["path"] try: self._vanity = self.find_vanity() - self._vanity._after_destroy = self.reset_vanity - return self._vanity + return self._vanity["path"] except ClientError as e: if e.http_status == 404: return None raise e @vanity.setter - def vanity(self, value: Union[str, "CreateVanityRequest"]) -> None: + def vanity(self, value: str) -> None: """Set the vanity. Parameters ---------- - value : str or CreateVanityRequest - The value can be a str or a CreateVanityRequest. If provided as a string, it is the vanity path. + value : str + The vanity path. + + Note + ---- + This action requires owner or administrator privileges. See Also -------- create_vanity """ - if isinstance(value, str): - self.create_vanity(path=value) - elif isinstance(value, dict): - self.create_vanity(**value) - self.reset_vanity() + self._vanity = self.create_vanity(path=value) + self._vanity._after_destroy = self.reset_vanity @vanity.deleter def vanity(self) -> None: @@ -178,7 +178,7 @@ def vanity(self) -> None: Note ---- - This action requires administrator privileges. + This action requires owner or administrator privileges. See Also -------- @@ -196,20 +196,15 @@ def reset_vanity(self) -> None: self._vanity = None class CreateVanityRequest(TypedDict, total=False): - """A request schema for creating a vanity. - - Attributes - ---------- - path : str - The path for the vanity. - force : bool - Whether to force the creation of the vanity. - """ + """A request schema for creating a vanity.""" path: Required[str] + """The vanity path (.e.g, 'my-dashboard')""" + force: NotRequired[bool] + """Whether to force creation of the vanity""" - def create_vanity(self, **kwargs: Unpack[CreateVanityRequest]) -> None: + def create_vanity(self, **kwargs: Unpack[CreateVanityRequest]) -> Vanity: """Create a vanity. Parameters @@ -217,11 +212,23 @@ def create_vanity(self, **kwargs: Unpack[CreateVanityRequest]) -> None: path : str, required The path for the vanity. force : bool, not required - Whether to force the creation of the vanity, default False + Whether to force the creation of the vanity. When True, any other vanity with the same path will be deleted. + + Warnings + -------- + If setting force=True, the destroy operation performed on the other vanity is irreversible. """ - self.params.session.put(self._endpoint, json=kwargs) + response = self.params.session.put(self._endpoint, json=kwargs) + result = response.json() + return Vanity(self.params, **result) - def find_vanity(self): + def find_vanity(self) -> Vanity: + """Find the vanity. + + Returns + ------- + Vanity + """ response = self.params.session.get(self._endpoint) result = response.json() - return Vanity(self.params, **result) + return Vanity(self.params, after_destroy=self.reset_vanity, **result) diff --git a/tests/posit/connect/test_vanities.py b/tests/posit/connect/test_vanities.py index 73a8506e..29a6f01f 100644 --- a/tests/posit/connect/test_vanities.py +++ b/tests/posit/connect/test_vanities.py @@ -20,7 +20,7 @@ def test_destroy_sends_delete_request(self): session = requests.Session() url = Url(base_url) params = ResourceParameters(session, url) - vanity = Vanity(params, content_guid=content_guid) + vanity = Vanity(params, content_guid=content_guid, path=Mock(), created_time=Mock()) vanity.destroy() @@ -67,15 +67,14 @@ def test_vanity_getter_returns_vanity(self): guid = "8ce6eaca-60af-4c2f-93a0-f5f3cddf5ee5" base_url = "http://connect.example/__api__" endpoint = f"{base_url}/v1/content/{guid}/vanity" - vanity_data = {"content_guid": guid} - mock_get = responses.get(endpoint, json=vanity_data) + mock_get = responses.get(endpoint, json={"content_guid": guid, "path": "my-dashboard"}) session = requests.Session() url = Url(base_url) params = ResourceParameters(session, url) content = VanityMixin(params, guid=guid) - assert content.vanity == vanity_data + assert content.vanity == "my-dashboard" assert mock_get.call_count == 1 @responses.activate @@ -84,33 +83,20 @@ def test_vanity_setter_with_string(self): base_url = "http://connect.example/__api__" endpoint = f"{base_url}/v1/content/{guid}/vanity" path = "example" - mock_put = responses.put(endpoint, match=[json_params_matcher({"path": path})]) + mock_put = responses.put( + endpoint, + json={"content_guid": guid, "path": path}, + match=[json_params_matcher({"path": path})], + ) session = requests.Session() url = Url(base_url) params = ResourceParameters(session, url) content = VanityMixin(params, guid=guid) content.vanity = path + assert content.vanity == path assert mock_put.call_count == 1 - assert content._vanity is None - - @responses.activate - def test_vanity_setter_with_dict(self): - guid = "8ce6eaca-60af-4c2f-93a0-f5f3cddf5ee5" - base_url = "http://connect.example/__api__" - endpoint = f"{base_url}/v1/content/{guid}/vanity" - attrs = {"path": "example", "locked": True} - mock_put = responses.put(endpoint, match=[json_params_matcher(attrs)]) - - session = requests.Session() - url = Url(base_url) - params = ResourceParameters(session, url) - content = VanityMixin(params, guid=guid) - content.vanity = attrs - - assert content._vanity is None - assert mock_put.call_count == 1 @responses.activate def test_vanity_deleter(self): From 01322837447c6ff12794f9711a3f030cd58a181b Mon Sep 17 00:00:00 2001 From: tdstein Date: Fri, 11 Oct 2024 11:51:41 -0400 Subject: [PATCH 10/12] fix: ensure the vanity is known before deleting it --- src/posit/connect/vanities.py | 1 + tests/posit/connect/test_vanities.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/posit/connect/vanities.py b/src/posit/connect/vanities.py index ffd6976b..487622a9 100644 --- a/src/posit/connect/vanities.py +++ b/src/posit/connect/vanities.py @@ -184,6 +184,7 @@ def vanity(self) -> None: -------- reset_vanity """ + self.vanity if self._vanity: self._vanity.destroy() self.reset_vanity() diff --git a/tests/posit/connect/test_vanities.py b/tests/posit/connect/test_vanities.py index 29a6f01f..e77bfc54 100644 --- a/tests/posit/connect/test_vanities.py +++ b/tests/posit/connect/test_vanities.py @@ -109,7 +109,7 @@ def test_vanity_deleter(self): url = Url(base_url) params = ResourceParameters(session, url) content = VanityMixin(params, guid=guid) - content._vanity = Vanity(params, content_guid=guid) + content._vanity = Vanity(params, path=Mock(), content_guid=guid, created_time=Mock()) del content.vanity assert content._vanity is None From 0165a8de9fe6b1994ff5d1df1cf45ddb2408800e Mon Sep 17 00:00:00 2001 From: tdstein Date: Fri, 11 Oct 2024 12:05:39 -0400 Subject: [PATCH 11/12] do not associate find_vanity with the _vanity --- integration/tests/posit/connect/test_vanities.py | 1 + src/posit/connect/vanities.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/integration/tests/posit/connect/test_vanities.py b/integration/tests/posit/connect/test_vanities.py index e72c725a..b79d43cc 100644 --- a/integration/tests/posit/connect/test_vanities.py +++ b/integration/tests/posit/connect/test_vanities.py @@ -66,6 +66,7 @@ def test_destroy(self): # Delete vanity.destroy() + content.reset_vanity() assert content.vanity is None # Cleanup diff --git a/src/posit/connect/vanities.py b/src/posit/connect/vanities.py index 487622a9..fcacde25 100644 --- a/src/posit/connect/vanities.py +++ b/src/posit/connect/vanities.py @@ -142,6 +142,7 @@ def vanity(self) -> Optional[str]: try: self._vanity = self.find_vanity() + self._vanity._after_destroy = self.reset_vanity return self._vanity["path"] except ClientError as e: if e.http_status == 404: @@ -232,4 +233,4 @@ def find_vanity(self) -> Vanity: """ response = self.params.session.get(self._endpoint) result = response.json() - return Vanity(self.params, after_destroy=self.reset_vanity, **result) + return Vanity(self.params, **result) From de9b2906ad5a3a683850141e79e0868c9ee62eae Mon Sep 17 00:00:00 2001 From: Taylor Steinberg Date: Fri, 11 Oct 2024 15:08:17 -0400 Subject: [PATCH 12/12] Update src/posit/connect/vanities.py --- src/posit/connect/vanities.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/posit/connect/vanities.py b/src/posit/connect/vanities.py index fcacde25..a13d0282 100644 --- a/src/posit/connect/vanities.py +++ b/src/posit/connect/vanities.py @@ -125,7 +125,7 @@ class HasGuid(TypedDict): guid: Required[str] - def __init__(self, /, params: ResourceParameters, **kwargs: Unpack[HasGuid]): + def __init__(self, params: ResourceParameters, **kwargs: Unpack[HasGuid]): super().__init__(params, **kwargs) self._content_guid = kwargs["guid"] self._vanity: Optional[Vanity] = None