Skip to content

Commit 9e4f173

Browse files
authored
feat: add vanities (#310)
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
1 parent d69dac1 commit 9e4f173

File tree

8 files changed

+436
-7
lines changed

8 files changed

+436
-7
lines changed

.vscode/settings.json

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,4 @@
44
],
55
"python.testing.unittestEnabled": false,
66
"python.testing.pytestEnabled": true,
7-
"cSpell.words": [
8-
"mypy"
9-
]
107
}

docs/_quarto.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ quartodoc:
104104
- connect.permissions
105105
- connect.tasks
106106
- connect.users
107+
- connect.vanities
107108
- title: Posit Connect Metrics
108109
package: posit
109110
contents:
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
from posit import connect
2+
3+
4+
class TestVanities:
5+
@classmethod
6+
def setup_class(cls):
7+
cls.client = connect.Client()
8+
9+
@classmethod
10+
def teardown_class(cls):
11+
assert cls.client.content.count() == 0
12+
13+
def test_all(self):
14+
content = self.client.content.create(name="example")
15+
16+
# None by default
17+
vanities = self.client.vanities.all()
18+
assert len(vanities) == 0
19+
20+
# Set
21+
content.vanity = "example"
22+
23+
# Get
24+
vanities = self.client.vanities.all()
25+
assert len(vanities) == 1
26+
27+
# Cleanup
28+
content.delete()
29+
30+
vanities = self.client.vanities.all()
31+
assert len(vanities) == 0
32+
33+
def test_property(self):
34+
content = self.client.content.create(name="example")
35+
36+
# None by default
37+
assert content.vanity is None
38+
39+
# Set
40+
content.vanity = "example"
41+
42+
# Get
43+
vanity = content.vanity
44+
assert vanity == "/example/"
45+
46+
# Delete
47+
del content.vanity
48+
assert content.vanity is None
49+
50+
# Cleanup
51+
content.delete()
52+
53+
def test_destroy(self):
54+
content = self.client.content.create(name="example")
55+
56+
# None by default
57+
assert content.vanity is None
58+
59+
# Set
60+
content.vanity = "example"
61+
62+
# Get
63+
vanity = content.find_vanity()
64+
assert vanity
65+
assert vanity["path"] == "/example/"
66+
67+
# Delete
68+
vanity.destroy()
69+
content.reset_vanity()
70+
assert content.vanity is None
71+
72+
# Cleanup
73+
content.delete()

src/posit/connect/client.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from .resources import ResourceParameters
1818
from .tasks import Tasks
1919
from .users import User, Users
20+
from .vanities import Vanities
2021

2122

2223
class Client(ContextManager):
@@ -271,6 +272,10 @@ def oauth(self) -> OAuth:
271272
"""
272273
return OAuth(self.resource_params, self.cfg.api_key)
273274

275+
@property
276+
def vanities(self) -> Vanities:
277+
return Vanities(self.resource_params)
278+
274279
def __del__(self):
275280
"""Close the session when the Client instance is deleted."""
276281
if hasattr(self, "session") and self.session is not None:

src/posit/connect/content.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@
77
from posixpath import dirname
88
from typing import Any, List, Literal, Optional, overload
99

10-
from posit.connect.oauth.associations import ContentItemAssociations
11-
1210
from . import tasks
1311
from .bundles import Bundles
1412
from .env import EnvVars
13+
from .oauth.associations import ContentItemAssociations
1514
from .permissions import Permissions
1615
from .resources import Resource, ResourceParameters, Resources
1716
from .tasks import Task
17+
from .vanities import VanityMixin
1818
from .variants import Variants
1919

2020

@@ -32,7 +32,7 @@ class ContentItemOwner(Resource):
3232
pass
3333

3434

35-
class ContentItem(Resource):
35+
class ContentItem(VanityMixin, Resource):
3636
def __getitem__(self, key: Any) -> Any:
3737
v = super().__getitem__(key)
3838
if key == "owner" and isinstance(v, dict):

src/posit/connect/context.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import functools
22
from typing import Optional, Protocol
33

4+
import requests
45
from packaging.version import Version
56

67

@@ -21,7 +22,7 @@ def wrapper(instance: ContextManager, *args, **kwargs):
2122

2223

2324
class Context(dict):
24-
def __init__(self, session, url):
25+
def __init__(self, session: requests.Session, url: str):
2526
self.session = session
2627
self.url = url
2728

src/posit/connect/vanities.py

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
from typing import Callable, List, Optional, TypedDict
2+
3+
from typing_extensions import NotRequired, Required, Unpack
4+
5+
from .errors import ClientError
6+
from .resources import Resource, ResourceParameters, Resources
7+
8+
9+
class Vanity(Resource):
10+
"""A vanity resource.
11+
12+
Vanities maintain custom URL paths assigned to content.
13+
14+
Warnings
15+
--------
16+
Vanity paths may only contain alphanumeric characters, hyphens, underscores, and slashes.
17+
18+
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.
19+
20+
The following vanities are reserved by Connect:
21+
- `/__`
22+
- `/favicon.ico`
23+
- `/connect`
24+
- `/apps`
25+
- `/users`
26+
- `/groups`
27+
- `/setpassword`
28+
- `/user-completion`
29+
- `/confirm`
30+
- `/recent`
31+
- `/reports`
32+
- `/plots`
33+
- `/unpublished`
34+
- `/settings`
35+
- `/metrics`
36+
- `/tokens`
37+
- `/help`
38+
- `/login`
39+
- `/welcome`
40+
- `/register`
41+
- `/resetpassword`
42+
- `/content`
43+
"""
44+
45+
AfterDestroyCallback = Callable[[], None]
46+
47+
class VanityAttributes(TypedDict):
48+
"""Vanity attributes."""
49+
50+
path: Required[str]
51+
content_guid: Required[str]
52+
created_time: Required[str]
53+
54+
def __init__(
55+
self,
56+
/,
57+
params: ResourceParameters,
58+
*,
59+
after_destroy: Optional[AfterDestroyCallback] = None,
60+
**kwargs: Unpack[VanityAttributes],
61+
):
62+
"""Initialize a Vanity.
63+
64+
Parameters
65+
----------
66+
params : ResourceParameters
67+
after_destroy : AfterDestroyCallback, optional
68+
Called after the Vanity is successfully destroyed, by default None
69+
"""
70+
super().__init__(params, **kwargs)
71+
self._after_destroy = after_destroy
72+
self._content_guid = kwargs["content_guid"]
73+
74+
@property
75+
def _endpoint(self):
76+
return self.params.url + f"v1/content/{self._content_guid}/vanity"
77+
78+
def destroy(self) -> None:
79+
"""Destroy the vanity.
80+
81+
Raises
82+
------
83+
ValueError
84+
If the foreign unique identifier is missing or its value is `None`.
85+
86+
Warnings
87+
--------
88+
This operation is irreversible.
89+
90+
Note
91+
----
92+
This action requires administrator privileges.
93+
"""
94+
self.params.session.delete(self._endpoint)
95+
96+
if self._after_destroy:
97+
self._after_destroy()
98+
99+
100+
class Vanities(Resources):
101+
"""Manages a collection of vanities."""
102+
103+
def all(self) -> List[Vanity]:
104+
"""Retrieve all vanities.
105+
106+
Returns
107+
-------
108+
List[Vanity]
109+
110+
Notes
111+
-----
112+
This action requires administrator privileges.
113+
"""
114+
endpoint = self.params.url + "v1/vanities"
115+
response = self.params.session.get(endpoint)
116+
results = response.json()
117+
return [Vanity(self.params, **result) for result in results]
118+
119+
120+
class VanityMixin(Resource):
121+
"""Mixin class to add a vanity attribute to a resource."""
122+
123+
class HasGuid(TypedDict):
124+
"""Has a guid."""
125+
126+
guid: Required[str]
127+
128+
def __init__(self, params: ResourceParameters, **kwargs: Unpack[HasGuid]):
129+
super().__init__(params, **kwargs)
130+
self._content_guid = kwargs["guid"]
131+
self._vanity: Optional[Vanity] = None
132+
133+
@property
134+
def _endpoint(self):
135+
return self.params.url + f"v1/content/{self._content_guid}/vanity"
136+
137+
@property
138+
def vanity(self) -> Optional[str]:
139+
"""Get the vanity."""
140+
if self._vanity:
141+
return self._vanity["path"]
142+
143+
try:
144+
self._vanity = self.find_vanity()
145+
self._vanity._after_destroy = self.reset_vanity
146+
return self._vanity["path"]
147+
except ClientError as e:
148+
if e.http_status == 404:
149+
return None
150+
raise e
151+
152+
@vanity.setter
153+
def vanity(self, value: str) -> None:
154+
"""Set the vanity.
155+
156+
Parameters
157+
----------
158+
value : str
159+
The vanity path.
160+
161+
Note
162+
----
163+
This action requires owner or administrator privileges.
164+
165+
See Also
166+
--------
167+
create_vanity
168+
"""
169+
self._vanity = self.create_vanity(path=value)
170+
self._vanity._after_destroy = self.reset_vanity
171+
172+
@vanity.deleter
173+
def vanity(self) -> None:
174+
"""Destroy the vanity.
175+
176+
Warnings
177+
--------
178+
This operation is irreversible.
179+
180+
Note
181+
----
182+
This action requires owner or administrator privileges.
183+
184+
See Also
185+
--------
186+
reset_vanity
187+
"""
188+
self.vanity
189+
if self._vanity:
190+
self._vanity.destroy()
191+
self.reset_vanity()
192+
193+
def reset_vanity(self) -> None:
194+
"""Unload the cached vanity.
195+
196+
Forces the next access, if any, to query the vanity from the Connect server.
197+
"""
198+
self._vanity = None
199+
200+
class CreateVanityRequest(TypedDict, total=False):
201+
"""A request schema for creating a vanity."""
202+
203+
path: Required[str]
204+
"""The vanity path (.e.g, 'my-dashboard')"""
205+
206+
force: NotRequired[bool]
207+
"""Whether to force creation of the vanity"""
208+
209+
def create_vanity(self, **kwargs: Unpack[CreateVanityRequest]) -> Vanity:
210+
"""Create a vanity.
211+
212+
Parameters
213+
----------
214+
path : str, required
215+
The path for the vanity.
216+
force : bool, not required
217+
Whether to force the creation of the vanity. When True, any other vanity with the same path will be deleted.
218+
219+
Warnings
220+
--------
221+
If setting force=True, the destroy operation performed on the other vanity is irreversible.
222+
"""
223+
response = self.params.session.put(self._endpoint, json=kwargs)
224+
result = response.json()
225+
return Vanity(self.params, **result)
226+
227+
def find_vanity(self) -> Vanity:
228+
"""Find the vanity.
229+
230+
Returns
231+
-------
232+
Vanity
233+
"""
234+
response = self.params.session.get(self._endpoint)
235+
result = response.json()
236+
return Vanity(self.params, **result)

0 commit comments

Comments
 (0)