Skip to content

Commit

Permalink
feat: add vanities (#310)
Browse files Browse the repository at this point in the history
Adds vanities support.

A new mixin pattern is introduced, which adds the `vanity` attributes to
the `ContentItem` class. This abstraction is still a bit leaky since the
endpoint is hardcoded to 'v1/content' . But I find this design helpful
since it collocates everything regarding vanities into a single file. In
addition, the Content class has started to get a little unwieldy due to
its size.

The use of TypedDict, Unpack, Required, and NotRequired from
`typing_extensions` is also introduced. Thanks to
https://blog.changs.co.uk/typeddicts-are-better-than-you-think.html, I
discovered this pattern provides a much more succinct way to define
pass-through arguments. It also greatly simplifies the verbosity of
overload methods. That isn't shown here, but I have a test on another
branch.

Resolves #175
  • Loading branch information
tdstein authored Oct 16, 2024
1 parent d69dac1 commit 9e4f173
Show file tree
Hide file tree
Showing 8 changed files with 436 additions and 7 deletions.
3 changes: 0 additions & 3 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,4 @@
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true,
"cSpell.words": [
"mypy"
]
}
1 change: 1 addition & 0 deletions docs/_quarto.yml
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ quartodoc:
- connect.permissions
- connect.tasks
- connect.users
- connect.vanities
- title: Posit Connect Metrics
package: posit
contents:
Expand Down
73 changes: 73 additions & 0 deletions integration/tests/posit/connect/test_vanities.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
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 == "/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.find_vanity()
assert vanity
assert vanity["path"] == "/example/"

# Delete
vanity.destroy()
content.reset_vanity()
assert content.vanity is None

# Cleanup
content.delete()
5 changes: 5 additions & 0 deletions src/posit/connect/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from .resources import ResourceParameters
from .tasks import Tasks
from .users import User, Users
from .vanities import Vanities


class Client(ContextManager):
Expand Down Expand Up @@ -271,6 +272,10 @@ def oauth(self) -> OAuth:
"""
return OAuth(self.resource_params, self.cfg.api_key)

@property
def vanities(self) -> Vanities:
return Vanities(self.resource_params)

def __del__(self):
"""Close the session when the Client instance is deleted."""
if hasattr(self, "session") and self.session is not None:
Expand Down
6 changes: 3 additions & 3 deletions src/posit/connect/content.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 .vanities import VanityMixin
from .variants import Variants


Expand All @@ -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):
Expand Down
3 changes: 2 additions & 1 deletion src/posit/connect/context.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import functools
from typing import Optional, Protocol

import requests
from packaging.version import Version


Expand All @@ -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

Expand Down
236 changes: 236 additions & 0 deletions src/posit/connect/vanities.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
from typing import Callable, List, Optional, TypedDict

from typing_extensions import NotRequired, Required, Unpack

from .errors import ClientError
from .resources import Resource, ResourceParameters, Resources


class Vanity(Resource):
"""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`
"""

AfterDestroyCallback = Callable[[], None]

class VanityAttributes(TypedDict):
"""Vanity attributes."""

path: Required[str]
content_guid: Required[str]
created_time: Required[str]

def __init__(
self,
/,
params: ResourceParameters,
*,
after_destroy: Optional[AfterDestroyCallback] = None,
**kwargs: Unpack[VanityAttributes],
):
"""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
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.
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.
"""
self.params.session.delete(self._endpoint)

if self._after_destroy:
self._after_destroy()


class Vanities(Resources):
"""Manages a collection of vanities."""

def all(self) -> List[Vanity]:
"""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()
return [Vanity(self.params, **result) for result in results]


class VanityMixin(Resource):
"""Mixin class to add a vanity attribute to a resource."""

class HasGuid(TypedDict):
"""Has a guid."""

guid: Required[str]

def __init__(self, params: ResourceParameters, **kwargs: Unpack[HasGuid]):
super().__init__(params, **kwargs)
self._content_guid = kwargs["guid"]
self._vanity: Optional[Vanity] = None

@property
def _endpoint(self):
return self.params.url + f"v1/content/{self._content_guid}/vanity"

@property
def vanity(self) -> Optional[str]:
"""Get the vanity."""
if self._vanity:
return self._vanity["path"]

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:
return None
raise e

@vanity.setter
def vanity(self, value: str) -> None:
"""Set the vanity.
Parameters
----------
value : str
The vanity path.
Note
----
This action requires owner or administrator privileges.
See Also
--------
create_vanity
"""
self._vanity = self.create_vanity(path=value)
self._vanity._after_destroy = self.reset_vanity

@vanity.deleter
def vanity(self) -> None:
"""Destroy the vanity.
Warnings
--------
This operation is irreversible.
Note
----
This action requires owner or administrator privileges.
See Also
--------
reset_vanity
"""
self.vanity
if self._vanity:
self._vanity.destroy()
self.reset_vanity()

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

class CreateVanityRequest(TypedDict, total=False):
"""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]) -> Vanity:
"""Create a vanity.
Parameters
----------
path : str, required
The path for the vanity.
force : bool, not required
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.
"""
response = self.params.session.put(self._endpoint, json=kwargs)
result = response.json()
return Vanity(self.params, **result)

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)
Loading

0 comments on commit 9e4f173

Please sign in to comment.