Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add vanities #310

Merged
merged 12 commits into from
Oct 16, 2024
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
Comment on lines +67 to +68
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is interesting to me; what are its use cases?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's currently used on L145 below (self._vanity._after_destroy = self.reset_vanity), which says, "after the vanity is destroyed, call reset_vanity". It's a way to control the state of VanityMixin without having to interact with it directly from 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.

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]
Comment on lines +123 to +126
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you explain how this works? Is the internal class definition here is mostly for type-related stuff? (I see it used in the __init__ method below)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, it is strictly for the type checker. It makes this class easier to work with in unit tests.


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