Skip to content

Commit 7330ff8

Browse files
refactor: enhance content retrieval methods and add matching utilities
1 parent dfbd8a0 commit 7330ff8

File tree

7 files changed

+192
-78
lines changed

7 files changed

+192
-78
lines changed

src/posit/connect/content.py

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -984,33 +984,28 @@ def find_one(self, **conditions) -> Optional[ContentItem]:
984984
items = self.find(**conditions)
985985
return next(iter(items), None)
986986

987-
def get(self, guid: str) -> ContentItem:
987+
def get(self, guid: Optional[str] = None) -> ContentItem:
988988
"""Get a content item.
989989
990+
If `guid` is None, attempts to get the content item for the current context using the
991+
CONNECT_CONTENT_GUID environment variable, which is automatically set when running on Connect.
992+
990993
Parameters
991994
----------
992-
guid : str
995+
guid : str, optional
993996
The unique identifier of the content item.
994997
995998
Returns
996999
-------
9971000
ContentItem
9981001
"""
1002+
if guid is None:
1003+
guid = os.getenv("CONNECT_CONTENT_GUID")
1004+
if not guid:
1005+
raise RuntimeError("CONNECT_CONTENT_GUID environment variable is not set.")
1006+
9991007
# Always request all available optional fields for the content item
10001008
params = {"include": "owner,tags,vanity_url"}
10011009

10021010
response = self._ctx.client.get(f"v1/content/{guid}", params=params)
10031011
return ContentItem(self._ctx, **response.json())
1004-
1005-
@property
1006-
def current(self) -> ContentItem:
1007-
"""Get the content item for the current context.
1008-
1009-
Returns
1010-
-------
1011-
ContentItem
1012-
"""
1013-
guid = os.getenv("CONNECT_CONTENT_GUID")
1014-
if not guid:
1015-
raise RuntimeError("CONNECT_CONTENT_GUID environment variable is not set.")
1016-
return self.get(guid)

src/posit/connect/oauth/associations.py

Lines changed: 22 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22

33
from __future__ import annotations
44

5-
import re
5+
from functools import partial
66

77
from typing_extensions import TYPE_CHECKING, List, Optional
88

99
# from ..context import requires
10-
from ..resources import BaseResource, Resources
10+
from ..resources import BaseResource, Resources, _matches_exact, _matches_pattern
1111

1212
if TYPE_CHECKING:
1313
from ..context import Context
@@ -99,33 +99,27 @@ def find_by(
9999
Association | None
100100
The first matching association, or None if no match is found.
101101
"""
102+
filters = []
103+
if integration_type is not None:
104+
filters.append(
105+
partial(_matches_exact, key="oauth_integration_template", value=integration_type)
106+
)
107+
if auth_type is not None:
108+
filters.append(
109+
partial(_matches_exact, key="oauth_integration_auth_type", value=auth_type)
110+
)
111+
if name is not None:
112+
filters.append(partial(_matches_pattern, key="oauth_integration_name", pattern=name))
113+
if description is not None:
114+
filters.append(
115+
partial(_matches_pattern, key="oauth_integration_description", pattern=description)
116+
)
117+
if guid is not None:
118+
filters.append(partial(_matches_exact, key="oauth_integration_guid", value=guid))
119+
102120
for integration in self.find():
103-
if (
104-
integration_type is not None
105-
and integration.get("oauth_integration_template") != integration_type
106-
):
107-
continue
108-
109-
if (
110-
auth_type is not None
111-
and integration.get("oauth_integration_auth_type") != auth_type
112-
):
113-
continue
114-
115-
if name is not None:
116-
integration_name = integration.get("oauth_integration_name", "")
117-
if not re.search(name, integration_name):
118-
continue
119-
120-
if description is not None:
121-
integration_description = integration.get("oauth_integration_description", "")
122-
if not re.search(description, integration_description):
123-
continue
124-
125-
if guid is not None and integration.get("oauth_integration_guid") != guid:
126-
continue
127-
128-
return integration
121+
if all(f(integration) for f in filters):
122+
return integration
129123

130124
return None
131125

src/posit/connect/oauth/integrations.py

Lines changed: 24 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,17 @@
22

33
from __future__ import annotations
44

5-
import re
5+
from functools import partial
66

77
from typing_extensions import TYPE_CHECKING, List, Optional, overload
88

9-
from ..resources import BaseResource, Resources
9+
from ..resources import (
10+
BaseResource,
11+
Resources,
12+
_contains_dict_key_values,
13+
_matches_exact,
14+
_matches_pattern,
15+
)
1016
from .associations import IntegrationAssociations
1117

1218
if TYPE_CHECKING:
@@ -161,32 +167,23 @@ def find_by(
161167
Integration | None
162168
The first matching integration, or None if no match is found.
163169
"""
164-
for integration in self.find():
165-
if integration_type is not None and integration.get("template") != integration_type:
166-
continue
167-
168-
if auth_type is not None and integration.get("auth_type") != auth_type:
169-
continue
170-
171-
if name is not None:
172-
integration_name = integration.get("name", "")
173-
if not re.search(name, integration_name):
174-
continue
175-
176-
if description is not None:
177-
integration_description = integration.get("description", "")
178-
if not re.search(description, integration_description):
179-
continue
170+
filters = []
171+
if integration_type is not None:
172+
filters.append(partial(_matches_exact, key="template", value=integration_type))
173+
if auth_type is not None:
174+
filters.append(partial(_matches_exact, key="auth_type", value=auth_type))
175+
if name is not None:
176+
filters.append(partial(_matches_pattern, key="name", pattern=name))
177+
if description is not None:
178+
filters.append(partial(_matches_pattern, key="description", pattern=description))
179+
if guid is not None:
180+
filters.append(partial(_matches_exact, key="guid", value=guid))
181+
if config is not None:
182+
filters.append(partial(_contains_dict_key_values, key="config", value=config))
180183

181-
if guid is not None and integration.get("guid") != guid:
182-
continue
183-
184-
if config is not None:
185-
integration_config = integration.get("config", {})
186-
if not all(integration_config.get(k) == v for k, v in config.items()):
187-
continue
188-
189-
return integration
184+
for integration in self.find():
185+
if all(f(integration) for f in filters):
186+
return integration
190187

191188
return None
192189

src/posit/connect/oauth/oauth.py

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,17 +56,31 @@ def sessions(self):
5656

5757
def get_credentials(
5858
self,
59-
user_session_token: Optional[str] = None,
59+
user_session_token: str,
6060
requested_token_type: Optional[str | types.OAuthTokenType] = None,
6161
audience: Optional[str] = None,
6262
) -> Credentials:
63-
"""Perform an oauth credential exchange with a user-session-token."""
63+
"""Perform an oauth credential exchange with a user-session-token.
64+
65+
Parameters
66+
----------
67+
user_session_token : str
68+
The user session token to use for the exchange.
69+
requested_token_type : str or OAuthTokenType, optional
70+
The type of token being requested. This can be one of the predefined types in `OAuthTokenType` or a custom string.
71+
audience : str, optional
72+
The intended audience for the token. This must be a valid integration GUID.
73+
74+
Returns
75+
-------
76+
Credentials
77+
The credentials obtained from the exchange.
78+
"""
6479
# craft a credential exchange request
6580
data = {}
6681
data["grant_type"] = types.GRANT_TYPE
6782
data["subject_token_type"] = types.OAuthTokenType.USER_SESSION_TOKEN
68-
if user_session_token:
69-
data["subject_token"] = user_session_token
83+
data["subject_token"] = user_session_token
7084
if requested_token_type:
7185
data["requested_token_type"] = requested_token_type
7286
if audience:
@@ -81,7 +95,23 @@ def get_content_credentials(
8195
requested_token_type: Optional[str | types.OAuthTokenType] = None,
8296
audience: Optional[str] = None,
8397
) -> Credentials:
84-
"""Perform an oauth credential exchange with a content-session-token."""
98+
"""Perform an oauth credential exchange with a content-session-token.
99+
100+
Parameters
101+
----------
102+
content_session_token : str, optional
103+
The content session token to use for the exchange. If not provided, the function will attempt to read the token from the environment variable 'CONNECT_CONTENT_SESSION_TOKEN'.
104+
requested_token_type : str or OAuthTokenType, optional
105+
The type of token being requested. This can be one of the predefined types in `OAuthTokenType` or a custom string.
106+
audience : str, optional
107+
The intended audience for the token. This must be a valid integration GUID.
108+
109+
Returns
110+
-------
111+
Credentials
112+
The credentials obtained from the exchange.
113+
114+
"""
85115
# craft a credential exchange request
86116
data = {}
87117
data["grant_type"] = types.GRANT_TYPE

src/posit/connect/resources.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import posixpath
4+
import re
45
import warnings
56
from abc import ABC
67

@@ -192,3 +193,24 @@ def fetch(self, **conditions):
192193
resource = _Resource(self._ctx, path, **result)
193194
resources.append(resource)
194195
yield from resources
196+
197+
198+
def _matches_exact(item: BaseResource, key: str, value: str):
199+
item_value = item.get(key)
200+
if item_value is None or not isinstance(item_value, str):
201+
return False
202+
return item_value == value
203+
204+
205+
def _matches_pattern(item: BaseResource, key: str, pattern: str):
206+
item_value = item.get(key)
207+
if item_value is None or not isinstance(item_value, str):
208+
return False
209+
return re.search(pattern, item_value) is not None
210+
211+
212+
def _contains_dict_key_values(item: BaseResource, key: str, value: dict):
213+
item_value = item.get(key)
214+
if item_value is None or not isinstance(item_value, dict):
215+
return False
216+
return all(item_value.get(k) == v for k, v in value.items())

tests/posit/connect/test_content.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -388,7 +388,7 @@ def test_with_env_var(self, monkeypatch):
388388
match=[matchers.query_param_matcher({"include": "owner,tags,vanity_url"})],
389389
)
390390
c = Client("https://connect.example", "12345")
391-
content_item = c.content.current
391+
content_item = c.content.get()
392392
assert content_item["guid"] == guid
393393

394394
def test_without_env_var(self, monkeypatch):
@@ -397,7 +397,7 @@ def test_without_env_var(self, monkeypatch):
397397
with pytest.raises(
398398
RuntimeError, match="CONNECT_CONTENT_GUID environment variable is not set."
399399
):
400-
_ = c.content.current
400+
_ = c.content.get()
401401

402402

403403
class TestContentsCount:

tests/posit/connect/test_resources.py

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@
44

55
from typing_extensions import Optional
66

7-
from posit.connect.resources import BaseResource
7+
from posit.connect.resources import (
8+
BaseResource,
9+
_contains_dict_key_values,
10+
_matches_exact,
11+
_matches_pattern,
12+
)
813

914
config = Mock()
1015
session = Mock()
@@ -62,3 +67,74 @@ def test_foo(self):
6267
d = {k: v}
6368
r = FakeResource(mock.Mock(), **d)
6469
assert r.foo == v
70+
71+
72+
class TestContainsDictKeyValues:
73+
def test_empty_value_dict(self):
74+
r = FakeResource(mock.Mock(), foo={"a": 1, "b": 2})
75+
assert _contains_dict_key_values(r, "foo", {}) is True
76+
77+
def test_matching_single_key_value(self):
78+
r = FakeResource(mock.Mock(), foo={"a": 1, "b": 2})
79+
assert _contains_dict_key_values(r, "foo", {"a": 1}) is True
80+
81+
def test_matching_multiple_key_values(self):
82+
r = FakeResource(mock.Mock(), foo={"a": 1, "b": 2, "c": 3})
83+
assert _contains_dict_key_values(r, "foo", {"a": 1, "b": 2}) is True
84+
85+
def test_non_matching_key_value(self):
86+
r = FakeResource(mock.Mock(), foo={"a": 1, "b": 2})
87+
assert _contains_dict_key_values(r, "foo", {"a": 2}) is False
88+
89+
def test_missing_key_in_item(self):
90+
r = FakeResource(mock.Mock(), foo={"a": 1})
91+
assert _contains_dict_key_values(r, "foo", {"b": 2}) is False
92+
93+
def test_missing_field_in_resource(self):
94+
r = FakeResource(mock.Mock())
95+
assert _contains_dict_key_values(r, "nonexistent", {"a": 1}) is False
96+
97+
def test_non_dict_field_value(self):
98+
r = FakeResource(mock.Mock(), foo="not_a_dict")
99+
assert _contains_dict_key_values(r, "foo", {"a": 1}) is False
100+
101+
def test_nested_dict_values(self):
102+
r = FakeResource(mock.Mock(), foo={"nested": {"x": 10}, "simple": "value"})
103+
assert _contains_dict_key_values(r, "foo", {"nested": {"x": 10}}) is True
104+
assert _contains_dict_key_values(r, "foo", {"nested": {"x": 20}}) is False
105+
106+
107+
class TestMatchesPattern:
108+
def test_pattern_matches(self):
109+
r = FakeResource(mock.Mock(), name="test-app-123")
110+
assert _matches_pattern(r, "name", r"test-.*-\d+") is True
111+
112+
def test_pattern_does_not_match(self):
113+
r = FakeResource(mock.Mock(), name="production-app")
114+
assert _matches_pattern(r, "name", r"test-.*-\d+") is False
115+
116+
def test_missing_field_returns_false(self):
117+
r = FakeResource(mock.Mock())
118+
assert _matches_pattern(r, "nonexistent", ".*") is False
119+
120+
def test_empty_pattern_matches_any_string(self):
121+
r = FakeResource(mock.Mock(), description="any text here")
122+
assert _matches_pattern(r, "description", "") is True
123+
124+
def test_partial_match_returns_true(self):
125+
r = FakeResource(mock.Mock(), title="My Great App")
126+
assert _matches_pattern(r, "title", "Great") is True
127+
128+
129+
class TestMatchesExact:
130+
def test_exact_match_returns_true(self):
131+
r = FakeResource(mock.Mock(), name="test-app")
132+
assert _matches_exact(r, "name", "test-app") is True
133+
134+
def test_no_match_returns_false(self):
135+
r = FakeResource(mock.Mock(), name="test-app")
136+
assert _matches_exact(r, "name", "other-app") is False
137+
138+
def test_missing_field_returns_false(self):
139+
r = FakeResource(mock.Mock())
140+
assert _matches_exact(r, "nonexistent", "value") is False

0 commit comments

Comments
 (0)