-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
Showing
8 changed files
with
436 additions
and
7 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
Oops, something went wrong.