Skip to content

Commit 44b26c7

Browse files
committed
commit before computer blows up
1 parent 19a3249 commit 44b26c7

File tree

6 files changed

+234
-158
lines changed

6 files changed

+234
-158
lines changed
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
from posit import connect
2+
from posit.connect.content import ContentItem, ContentItemRepository
3+
4+
5+
class TestContentItemRepository:
6+
@classmethod
7+
def setup_class(cls):
8+
cls.client = connect.Client()
9+
10+
@classmethod
11+
def teardown_class(cls):
12+
assert cls.client.content.count() == 0
13+
14+
@property
15+
def content_name(self):
16+
return "example"
17+
18+
def create_content(self) -> ContentItem:
19+
return self.client.content.create(name=self.content_name)
20+
21+
@property
22+
def repo_repository(self):
23+
return "posit-dev/posit-sdk-py"
24+
25+
@property
26+
def repo_branch(self):
27+
return "1dacc4dd"
28+
29+
@property
30+
def repo_directory(self):
31+
return "integration/resources/connect/bundles/example-quarto-minimal"
32+
33+
@property
34+
def repo_polling(self):
35+
return False
36+
37+
@property
38+
def default_repository(self):
39+
return {
40+
"repository": self.repo_repository,
41+
"branch": self.repo_branch,
42+
"directory": self.repo_directory,
43+
"polling": self.repo_polling,
44+
}
45+
46+
def test_create_get_update_delete(self):
47+
content = self.create_content()
48+
49+
# None by default
50+
print("!!!!HERE!!!!")
51+
print(content)
52+
print(content.repository)
53+
54+
assert content.repository is None
55+
56+
# Create
57+
new_repo = content.create_repository(**self.default_repository)
58+
59+
# Get
60+
content_repo = content.repository
61+
assert content_repo is not None
62+
63+
def assert_repo(r: ContentItemRepository):
64+
assert isinstance(content_repo, ContentItemRepository)
65+
assert r["repository"] == self.repo_repository
66+
assert r["branch"] == self.repo_branch
67+
assert r["directory"] == self.repo_directory
68+
assert r["polling"] is self.repo_polling
69+
70+
assert_repo(new_repo)
71+
assert_repo(content_repo)
72+
73+
# Update
74+
ex_branch = "main"
75+
updated_repo = content_repo.update(branch=ex_branch)
76+
assert updated_repo["branch"] == ex_branch
77+
78+
assert updated_repo["repository"] == self.repo_repository
79+
assert updated_repo["directory"] == self.repo_directory
80+
assert updated_repo["polling"] is self.repo_polling
81+
82+
# Delete
83+
content.repository.delete()
84+
assert content.repository is None
85+
86+
# Cleanup
87+
content.delete()

src/posit/connect/_api.py

Lines changed: 40 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -2,42 +2,39 @@
22

33
import posixpath
44
from abc import ABC, abstractmethod
5-
from typing import TYPE_CHECKING, Any, Generic, Protocol, TypeVar, cast
5+
from collections.abc import Mapping
6+
from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast
67

8+
from ._api_call import ApiCallMixin, get_api
79
from ._json import Jsonifiable, JsonifiableDict, ResponseAttrs
810

911
if TYPE_CHECKING:
1012
from .context import Context
1113

14+
1215
# Design Notes:
1316
# * Perform API calls on property retrieval
1417
# * Dictionary endpoints: Retrieve all attributes during init unless provided
1518
# * List endpoints: Do not retrieve until `.fetch()` is called directly. Avoids cache invalidation issues.
1619
# * Only expose methods needed for `ReadOnlyDict`.
1720
# * Ex: When inheriting from `dict`, we'd need to shut down `update`, `pop`, etc.
1821
# * Use `ApiContextProtocol` to ensure that the class has the necessary attributes for API calls.
19-
# * Inherit from `EndpointMixin` to add all helper methods for API calls.
22+
# * Inherit from `ApiCallMixin` to add all helper methods for API calls.
2023
# * Classes should write the `path` only once within its init method.
2124
# * Through regular interactions, the path should only be written once.
2225

2326

24-
class ApiContextProtocol(Protocol):
25-
_ctx: Context
26-
_path: str
27-
28-
2927
# TODO-future?; Add type hints for the ReadOnlyDict class
3028
# ArgsT = TypeVar("ArgsT", bound="ResponseAttrs")
3129

3230

3331
class ReadOnlyDict(
3432
# Generic[ArgsT],
33+
Mapping,
3534
):
3635
# _attrs: ArgsT
3736
_attrs: ResponseAttrs
3837
"""Resource attributes passed."""
39-
_attrs_locked: bool
40-
"""Semaphore for locking the resource attributes. Deters setting new attributes after initialization."""
4138

4239
def __init__(self, attrs: ResponseAttrs) -> None:
4340
"""
@@ -48,8 +45,10 @@ def __init__(self, attrs: ResponseAttrs) -> None:
4845
attrs : dict
4946
Resource attributes passed
5047
"""
48+
print("here!", attrs)
49+
super().__init__()
50+
print("mapping attrs", attrs)
5151
self._attrs = attrs
52-
self._attrs_locked = True
5352

5453
def get(self, key: str, default: Any = None) -> Any:
5554
return self._attrs.get(key, default)
@@ -58,53 +57,37 @@ def __getitem__(self, key: str) -> Any:
5857
return self._attrs[key]
5958

6059
def __setitem__(self, key: str, value: Any) -> None:
61-
if self._attrs_locked:
62-
raise AttributeError(
63-
"Resource attributes are locked. "
64-
"To retrieve updated values, please retrieve the parent object again."
65-
)
66-
self._attrs[key] = value
67-
68-
def _set_attrs(self, **kwargs: Any) -> None:
69-
# Unlock
70-
self._attrs_locked = False
71-
# Set
72-
for key, value in kwargs.items():
73-
self._attrs[key] = value
74-
# Lock
75-
self._attrs_locked = True
60+
raise AttributeError(
61+
"Resource attributes are locked. "
62+
"To retrieve updated values, please retrieve the parent object again."
63+
)
7664

7765
def __len__(self) -> int:
7866
return self._attrs.__len__()
7967

68+
def __iter__(self):
69+
return self._attrs.__iter__()
8070

81-
class EndpointMixin(ApiContextProtocol):
82-
_ctx: Context
83-
"""The context object containing the session and URL for API interactions."""
84-
_path: str
85-
"""The HTTP path component for the resource endpoint."""
71+
def __contains__(self, key: object) -> bool:
72+
return self._attrs.__contains__(key)
8673

87-
def _endpoint(self, extra_endpoint: str = "") -> str:
88-
return self._ctx.url + self._path + extra_endpoint
74+
def __repr__(self) -> str:
75+
return repr(self._attrs)
8976

90-
def _get_api(self, *, extra_endpoint: str = "") -> Jsonifiable:
91-
response = self._ctx.session.get(self._endpoint(extra_endpoint))
92-
return response.json()
77+
def __str__(self) -> str:
78+
return str(self._attrs)
9379

94-
def _delete_api(self, *, extra_endpoint: str = "") -> Jsonifiable:
95-
response = self._ctx.session.get(self._endpoint(extra_endpoint))
96-
return response.json()
80+
def keys(self):
81+
return self._attrs.keys()
9782

98-
def _patch_api(self, json: Jsonifiable | None, *, extra_endpoint: str = "") -> Jsonifiable:
99-
response = self._ctx.session.patch(self._endpoint(extra_endpoint), json=json)
100-
return response.json()
83+
def values(self):
84+
return self._attrs.values()
10185

102-
def _put_api(self, json: Jsonifiable | None, *, extra_endpoint: str = "") -> Jsonifiable:
103-
response = self._ctx.session.put(self._endpoint(extra_endpoint), json=json)
104-
return response.json()
86+
def items(self):
87+
return self._attrs.items()
10588

10689

107-
class ApiDictEndpoint(EndpointMixin, ReadOnlyDict):
90+
class ApiDictEndpoint(ApiCallMixin, ReadOnlyDict):
10891
def _get_api(self, *, extra_endpoint: str = "") -> JsonifiableDict | None:
10992
super()._get_api(extra_endpoint=extra_endpoint)
11093

@@ -123,21 +106,26 @@ def __init__(self, *, ctx: Context, path: str, attrs: ResponseAttrs | None = Non
123106
attrs : dict
124107
Resource attributes passed
125108
"""
126-
super().__init__(attrs or {})
109+
# If no attributes are provided, fetch the API and set the attributes from the response
110+
print("dict attrs", attrs)
111+
if attrs is None:
112+
init_attrs: Jsonifiable = get_api(ctx, path)
113+
attrs = cast(ResponseAttrs, init_attrs)
114+
print("dict init attrs", attrs)
115+
116+
print("pre init")
117+
print("super", super())
118+
super().__init__(attrs)
119+
print("post init")
127120
self._ctx = ctx
128121
self._path = path
129122

130-
# If attrs is None, Fetch the API and set the attributes
131-
if attrs is None:
132-
init_attrs = self._get_api() or {}
133-
self._set_attrs(**init_attrs)
134-
135123

136124
T = TypeVar("T", bound="ReadOnlyDict")
137125
"""A type variable that is bound to the `Active` class"""
138126

139127

140-
class ApiListEndpoint(EndpointMixin, Generic[T], ABC, object):
128+
class ApiListEndpoint(ApiCallMixin, Generic[T], ABC, object):
141129
"""A tuple for any HTTP GET endpoint that returns a collection."""
142130

143131
_data: tuple[T, ...]

src/posit/connect/_api_call.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING, Protocol
4+
5+
if TYPE_CHECKING:
6+
from ._json import Jsonifiable
7+
from .context import Context
8+
9+
10+
class ApiContextProtocol(Protocol):
11+
_ctx: Context
12+
_path: str
13+
14+
15+
def endpoint(ctx: Context, path: str, *, extra_endpoint: str = "") -> str:
16+
return ctx.url + path + extra_endpoint
17+
18+
19+
def get_api(ctx: Context, path: str, *, extra_endpoint: str = "") -> Jsonifiable:
20+
response = ctx.session.get(endpoint(ctx, path, extra_endpoint=extra_endpoint))
21+
return response.json()
22+
23+
24+
class ApiCallMixin(ApiContextProtocol):
25+
_ctx: Context
26+
"""The context object containing the session and URL for API interactions."""
27+
_path: str
28+
"""The HTTP path component for the resource endpoint."""
29+
30+
def _endpoint(self, extra_endpoint: str = "") -> str:
31+
return endpoint(self._ctx, self._path, extra_endpoint=extra_endpoint)
32+
33+
def _get_api(self, *, extra_endpoint: str = "") -> Jsonifiable:
34+
response = self._ctx.session.get(self._endpoint(extra_endpoint))
35+
return response.json()
36+
37+
def _delete_api(self, *, extra_endpoint: str = "") -> Jsonifiable:
38+
response = self._ctx.session.get(self._endpoint(extra_endpoint))
39+
return response.json()
40+
41+
def _patch_api(self, json: Jsonifiable | None, *, extra_endpoint: str = "") -> Jsonifiable:
42+
response = self._ctx.session.patch(self._endpoint(extra_endpoint), json=json)
43+
return response.json()
44+
45+
def _put_api(self, json: Jsonifiable | None, *, extra_endpoint: str = "") -> Jsonifiable:
46+
response = self._ctx.session.put(self._endpoint(extra_endpoint), json=json)
47+
return response.json()

src/posit/connect/content.py

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from .bundles import Bundles
2525
from .context import Context
2626
from .env import EnvVars
27+
from .errors import ClientError
2728
from .jobs import JobsMixin
2829
from .oauth.associations import ContentItemAssociations
2930
from .permissions import Permissions
@@ -68,7 +69,7 @@ class _Attrs(TypedDict, total=False):
6869
def __init__(
6970
self,
7071
ctx: Context,
71-
*,
72+
/,
7273
content_guid: str,
7374
# By default, the attrs will be retrieved from the API.
7475
**attrs: Unpack[_Attrs],
@@ -88,6 +89,7 @@ def __init__(
8889
_assert_content_guid(content_guid)
8990

9091
init_attrs = {"content_guid": content_guid, **attrs} if len(attrs) > 0 else None
92+
print("init_attrs", init_attrs)
9193
super().__init__(
9294
ctx=ctx,
9395
path=f"v1/content/{content_guid}/repository",
@@ -108,7 +110,7 @@ def update(
108110
self,
109111
# *,
110112
**attrs: Unpack[_Attrs],
111-
) -> None:
113+
) -> ContentItemRepository:
112114
"""Update the content's repository.
113115
114116
Parameters
@@ -130,7 +132,12 @@ def update(
130132
--------
131133
* https://docs.posit.co/connect/api/#patch-/v1/content/-guid-/repository
132134
"""
133-
self._patch_api(cast(JsonifiableDict, dict(attrs)))
135+
result = self._patch_api(cast(JsonifiableDict, dict(attrs)))
136+
return ContentItemRepository(
137+
self._ctx,
138+
content_guid=self["content_guid"],
139+
**result, # pyright: ignore[reportCallIssue]
140+
)
134141

135142

136143
class ContentItemOAuth(Resource):
@@ -169,10 +176,10 @@ def oauth(self) -> ContentItemOAuth:
169176

170177
@property
171178
def repository(self) -> ContentItemRepository | None:
172-
return ContentItemRepository(
173-
self._ctx,
174-
content_guid=self["guid"],
175-
)
179+
try:
180+
return ContentItemRepository(self._ctx, content_guid=self["guid"])
181+
except ClientError:
182+
return None
176183

177184
def create_repository(
178185
self,
@@ -197,9 +204,12 @@ def create_repository(
197204
-------
198205
ContentItemRepository
199206
"""
200-
response = ContentItemRepository(self._ctx, content_guid=self["guid"])._put_api(
201-
cast(JsonifiableDict, attrs)
202-
)
207+
response = ContentItemRepository(
208+
self._ctx,
209+
content_guid=self["guid"],
210+
# Add a placeholder `attr` to avoid a `GET` request.
211+
_init=True, # pyright: ignore[reportCallIssue]
212+
)._put_api(cast(JsonifiableDict, attrs))
203213

204214
return ContentItemRepository(
205215
self._ctx,
@@ -297,6 +307,7 @@ def restart(self) -> None:
297307
def update(
298308
self,
299309
*,
310+
# TODO-barret; Reformat arguments to be similar to `ContentItemRepository._Attrs`
300311
# Required argument
301312
name: Optional[str] = None,
302313
# Content Metadata
@@ -417,6 +428,7 @@ def update(
417428
**attributes,
418429
}
419430
url = self.params.url + f"v1/content/{self['guid']}"
431+
# TODO-barret; Remove `drop_none`
420432
response = self.params.session.patch(url, json=drop_none(args))
421433
super().update(**response.json())
422434

0 commit comments

Comments
 (0)