-
Notifications
You must be signed in to change notification settings - Fork 4
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
feat: add vanities #310
Changes from all commits
5d3f2b2
9f8443c
ea13944
7f37087
e689752
ab69342
91aa99b
3f94ebd
abb26ba
0132283
0165a8d
de9b290
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,7 +4,4 @@ | |
], | ||
"python.testing.unittestEnabled": false, | ||
"python.testing.pytestEnabled": true, | ||
"cSpell.words": [ | ||
"mypy" | ||
] | ||
} |
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() |
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] | ||
Comment on lines
+123
to
+126
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.