Skip to content

Commit 918bd5f

Browse files
authored
feat: Add User.groups and Group.members (#341)
1 parent fed8325 commit 918bd5f

File tree

23 files changed

+1013
-217
lines changed

23 files changed

+1013
-217
lines changed

integration/tests/posit/connect/test_content_item_permissions.py

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
5+
import pytest
6+
17
from posit import connect
2-
from posit.connect.content import ContentItem
8+
9+
if TYPE_CHECKING:
10+
from posit.connect.content import ContentItem
11+
from posit.connect.permissions import Permission
312

413

514
class TestContentPermissions:
@@ -48,7 +57,7 @@ def test_permissions_add_destroy(self):
4857
role="owner",
4958
)
5059

51-
def assert_permissions_match_guids(permissions, objs_with_guid):
60+
def assert_permissions_match_guids(permissions: list[Permission], objs_with_guid):
5261
for permission, obj_with_guid in zip(permissions, objs_with_guid):
5362
assert permission["principal_guid"] == obj_with_guid["guid"]
5463

@@ -59,11 +68,9 @@ def assert_permissions_match_guids(permissions, objs_with_guid):
5968
)
6069

6170
# Remove permissions (and from some that isn't an owner)
62-
destroyed_permissions = self.content.permissions.destroy(self.user_aron, self.user_bill)
63-
assert_permissions_match_guids(
64-
destroyed_permissions,
65-
[self.user_aron],
66-
)
71+
self.content.permissions.destroy(self.user_aron)
72+
with pytest.raises(ValueError):
73+
self.content.permissions.destroy(self.user_bill)
6774

6875
# Prove they have been removed
6976
assert_permissions_match_guids(
@@ -72,11 +79,7 @@ def assert_permissions_match_guids(permissions, objs_with_guid):
7279
)
7380

7481
# Remove the last permission
75-
destroyed_permissions = self.content.permissions.destroy(self.group_friends)
76-
assert_permissions_match_guids(
77-
destroyed_permissions,
78-
[self.group_friends],
79-
)
82+
self.content.permissions.destroy(self.group_friends)
8083

8184
# Prove they have been removed
8285
assert self.content.permissions.find() == []

integration/tests/posit/connect/test_users.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,48 @@ def test_get(self):
5151
assert self.client.users.get(self.bill["guid"]) == self.bill
5252
assert self.client.users.get(self.cole["guid"]) == self.cole
5353

54+
# Also tests Groups.members
55+
def test_user_group_interactions(self):
56+
try:
57+
test_group = self.client.groups.create(name="UnitFriends")
58+
59+
# `Group.members.count()`
60+
assert test_group.members.count() == 0
61+
62+
# `Group.members.add()`
63+
test_group.members.add(self.bill)
64+
# `User.groups.add()`
65+
assert test_group.members.count() == 1
66+
self.cole.groups.add(test_group)
67+
assert test_group.members.count() == 2
68+
69+
# `Group.members.find()`
70+
group_users = test_group.members.find()
71+
assert len(group_users) == 2
72+
assert group_users[0]["guid"] == self.bill["guid"]
73+
assert group_users[1]["guid"] == self.cole["guid"]
74+
75+
# `User.group.find()`
76+
bill_groups = self.bill.groups.find()
77+
assert len(bill_groups) == 1
78+
assert bill_groups[0]["guid"] == test_group["guid"]
79+
80+
# `Group.members.delete()`
81+
test_group.members.delete(self.bill)
82+
assert test_group.members.count() == 1
83+
84+
# `User.groups.delete()`
85+
self.cole.groups.delete(test_group)
86+
assert test_group.members.count() == 0
87+
88+
finally:
89+
groups = self.client.groups.find(prefix="UnitFriends")
90+
if len(groups) > 0:
91+
test_group = groups[0]
92+
test_group.delete()
93+
94+
assert len(self.client.groups.find(prefix="UnitFriends")) == 0
95+
5496

5597
class TestUserContent:
5698
"""Checks behavior of the content attribute."""

src/posit/connect/client.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ def __init__(self, *args, **kwargs) -> None:
156156
session.hooks["response"].append(hooks.handle_errors)
157157
self.session = session
158158
self.resource_params = ResourceParameters(session, self.cfg.url)
159-
self._ctx = Context(self.session, self.cfg.url)
159+
self._ctx = Context(self)
160160

161161
@property
162162
def version(self) -> str | None:
@@ -180,7 +180,7 @@ def me(self) -> User:
180180
User
181181
The currently authenticated user.
182182
"""
183-
return me.get(self.resource_params)
183+
return me.get(self._ctx)
184184

185185
@property
186186
def groups(self) -> Groups:
@@ -191,7 +191,7 @@ def groups(self) -> Groups:
191191
Groups
192192
The groups resource interface.
193193
"""
194-
return Groups(self.resource_params)
194+
return Groups(self._ctx)
195195

196196
@property
197197
def tasks(self) -> Tasks:
@@ -215,7 +215,7 @@ def users(self) -> Users:
215215
Users
216216
The users resource instance.
217217
"""
218-
return Users(self.resource_params)
218+
return Users(self._ctx)
219219

220220
@property
221221
def content(self) -> Content:
@@ -227,7 +227,7 @@ def content(self) -> Content:
227227
Content
228228
The content resource instance.
229229
"""
230-
return Content(self.resource_params)
230+
return Content(self._ctx)
231231

232232
@property
233233
def metrics(self) -> Metrics:

src/posit/connect/content.py

Lines changed: 30 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
from . import tasks
2121
from ._api import ApiDictEndpoint, JsonifiableDict
2222
from .bundles import Bundles
23-
from .context import Context
2423
from .env import EnvVars
2524
from .errors import ClientError
2625
from .jobs import JobsMixin
@@ -32,6 +31,7 @@
3231
from .variants import Variants
3332

3433
if TYPE_CHECKING:
34+
from .context import Context
3535
from .tasks import Task
3636

3737

@@ -221,30 +221,32 @@ class _AttrsCreate(_AttrsBase):
221221
@overload
222222
def __init__(
223223
self,
224+
ctx: Context,
224225
/,
225-
params: ResourceParameters,
226+
*,
226227
guid: str,
227228
) -> None: ...
228229

229230
@overload
230231
def __init__(
231232
self,
233+
ctx: Context,
232234
/,
233-
params: ResourceParameters,
235+
*,
234236
guid: str,
235237
**kwargs: Unpack[ContentItem._Attrs],
236238
) -> None: ...
237239

238240
def __init__(
239241
self,
242+
ctx: Context,
240243
/,
241-
params: ResourceParameters,
244+
*,
242245
guid: str,
243246
**kwargs: Unpack[ContentItem._AttrsNotRequired],
244247
) -> None:
245248
_assert_guid(guid)
246249

247-
ctx = Context(params.session, params.url)
248250
path = f"v1/content/{guid}"
249251
super().__init__(ctx, path, guid=guid, **kwargs)
250252

@@ -291,8 +293,8 @@ def create_repository(
291293
def delete(self) -> None:
292294
"""Delete the content item."""
293295
path = f"v1/content/{self['guid']}"
294-
url = self.params.url + path
295-
self.params.session.delete(url)
296+
url = self._ctx.url + path
297+
self._ctx.session.delete(url)
296298

297299
def deploy(self) -> tasks.Task:
298300
"""Deploy the content.
@@ -311,8 +313,8 @@ def deploy(self) -> tasks.Task:
311313
None
312314
"""
313315
path = f"v1/content/{self['guid']}/deploy"
314-
url = self.params.url + path
315-
response = self.params.session.post(url, json={"bundle_id": None})
316+
url = self._ctx.url + path
317+
response = self._ctx.session.post(url, json={"bundle_id": None})
316318
result = response.json()
317319
ts = tasks.Tasks(self.params)
318320
return ts.get(result["task_id"])
@@ -367,8 +369,8 @@ def restart(self) -> None:
367369
self.environment_variables.create(key, unix_epoch_in_seconds)
368370
self.environment_variables.delete(key)
369371
# GET via the base Connect URL to force create a new worker thread.
370-
url = posixpath.join(dirname(self.params.url), f"content/{self['guid']}")
371-
self.params.session.get(url)
372+
url = posixpath.join(dirname(self._ctx.url), f"content/{self['guid']}")
373+
self._ctx.session.get(url)
372374
return None
373375
else:
374376
raise ValueError(
@@ -438,8 +440,8 @@ def update(
438440
-------
439441
None
440442
"""
441-
url = self.params.url + f"v1/content/{self['guid']}"
442-
response = self.params.session.patch(url, json=attrs)
443+
url = self._ctx.url + f"v1/content/{self['guid']}"
444+
response = self._ctx.session.patch(url, json=attrs)
443445
super().update(**response.json())
444446

445447
# Relationships
@@ -464,7 +466,9 @@ def owner(self) -> dict:
464466
# If it's not included, we can retrieve the information by `owner_guid`
465467
from .users import Users
466468

467-
self["owner"] = Users(self.params).get(self["owner_guid"])
469+
self["owner"] = Users(
470+
self._ctx,
471+
).get(self["owner_guid"])
468472
return self["owner"]
469473

470474
@property
@@ -512,12 +516,13 @@ class Content(Resources):
512516

513517
def __init__(
514518
self,
515-
params: ResourceParameters,
519+
ctx: Context,
516520
*,
517521
owner_guid: str | None = None,
518522
) -> None:
519-
super().__init__(params)
523+
super().__init__(ctx.client.resource_params)
520524
self.owner_guid = owner_guid
525+
self._ctx = ctx
521526

522527
def count(self) -> int:
523528
"""Count the number of content items.
@@ -592,9 +597,9 @@ def create(
592597
ContentItem
593598
"""
594599
path = "v1/content"
595-
url = self.params.url + path
596-
response = self.params.session.post(url, json=attrs)
597-
return ContentItem(self.params, **response.json())
600+
url = self._ctx.url + path
601+
response = self._ctx.session.post(url, json=attrs)
602+
return ContentItem(self._ctx, **response.json())
598603

599604
@overload
600605
def find(
@@ -680,11 +685,11 @@ def find(self, include: Optional[str | list[Any]] = None, **conditions) -> List[
680685
conditions["owner_guid"] = self.owner_guid
681686

682687
path = "v1/content"
683-
url = self.params.url + path
684-
response = self.params.session.get(url, params=conditions)
688+
url = self._ctx.url + path
689+
response = self._ctx.session.get(url, params=conditions)
685690
return [
686691
ContentItem(
687-
self.params,
692+
self._ctx,
688693
**result,
689694
)
690695
for result in response.json()
@@ -853,6 +858,6 @@ def get(self, guid: str) -> ContentItem:
853858
ContentItem
854859
"""
855860
path = f"v1/content/{guid}"
856-
url = self.params.url + path
857-
response = self.params.session.get(url)
858-
return ContentItem(self.params, **response.json())
861+
url = self._ctx.url + path
862+
response = self._ctx.session.get(url)
863+
return ContentItem(self._ctx, **response.json())

src/posit/connect/context.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
from __future__ import annotations
22

33
import functools
4+
import weakref
45
from typing import TYPE_CHECKING, Protocol
56

67
from packaging.version import Version
78

89
if TYPE_CHECKING:
910
import requests
1011

12+
from .client import Client
1113
from .urls import Url
1214

1315

@@ -28,9 +30,12 @@ def wrapper(instance: ContextManager, *args, **kwargs):
2830

2931

3032
class Context:
31-
def __init__(self, session: requests.Session, url: Url):
32-
self.session = session
33-
self.url = url
33+
def __init__(self, client: Client):
34+
self.session: requests.Session = client.session
35+
self.url: Url = client.cfg.url
36+
# Since this is a child object of the client, we use a weak reference to avoid circular
37+
# references (which would prevent garbage collection)
38+
self.client: Client = weakref.proxy(client)
3439

3540
@property
3641
def version(self) -> str | None:

0 commit comments

Comments
 (0)