Skip to content

Commit 7efac29

Browse files
authored
feat: add environments (#355)
Adds environment support. Adds environment support using base implementations at runtime and implicit type-checking for static typing. Closes #307
1 parent c6be01f commit 7efac29

File tree

8 files changed

+482
-5
lines changed

8 files changed

+482
-5
lines changed

.coveragerc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
# This file contains the configuration settings for the coverage report generated.
22

33
[report]
4-
# exclude '...' (ellipsis literal). This option uses regex, so an escaped literal is required.
54
exclude_also =
65
\.\.\.
6+
exclude_lines =
7+
if TYPE_CHECKING:
78

89
fail_under = 80
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import pytest
2+
from packaging import version
3+
4+
from posit import connect
5+
6+
from . import CONNECT_VERSION
7+
8+
9+
@pytest.mark.skipif(
10+
CONNECT_VERSION < version.parse("2023.05.0"),
11+
reason="Environments API unavailable",
12+
)
13+
class TestEnvironments:
14+
@classmethod
15+
def setup_class(cls):
16+
cls.client = connect.Client()
17+
cls.environment = cls.client.environments.create(
18+
title="title", name="name", cluster_name="Kubernetes"
19+
)
20+
21+
@classmethod
22+
def teardown_class(cls):
23+
cls.environment.destroy()
24+
assert len(cls.client.environments) == 0
25+
26+
def test_find(self):
27+
uid = self.environment["guid"]
28+
environment = self.client.environments.find(uid)
29+
assert environment == self.environment
30+
31+
def test_find_by(self):
32+
environment = self.client.environments.find_by(name="name")
33+
assert environment == self.environment
34+
35+
def test_update(self):
36+
assert self.environment["title"] == "title"
37+
self.environment.update(title="new-title")
38+
assert self.environment["title"] == "new-title"

src/posit/connect/client.py

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

33
from __future__ import annotations
44

5-
from typing import overload
5+
from typing import TYPE_CHECKING, overload
66

77
from requests import Response, Session
88

9-
from posit.connect.tags import Tags
10-
119
from . import hooks, me
1210
from .auth import Auth
1311
from .config import Config
@@ -17,11 +15,15 @@
1715
from .metrics import Metrics
1816
from .oauth import OAuth
1917
from .packages import Packages
20-
from .resources import ResourceParameters
18+
from .resources import ResourceParameters, _ResourceSequence
19+
from .tags import Tags
2120
from .tasks import Tasks
2221
from .users import User, Users
2322
from .vanities import Vanities
2423

24+
if TYPE_CHECKING:
25+
from .environments import Environments
26+
2527

2628
class Client(ContextManager):
2729
"""
@@ -303,6 +305,11 @@ def packages(self) -> Packages:
303305
def vanities(self) -> Vanities:
304306
return Vanities(self.resource_params)
305307

308+
@property
309+
@requires(version="2023.05.0")
310+
def environments(self) -> Environments:
311+
return _ResourceSequence(self._ctx, "v1/environments")
312+
306313
def __del__(self):
307314
"""Close the session when the Client instance is deleted."""
308315
if hasattr(self, "session") and self.session is not None:

src/posit/connect/environments.py

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
from __future__ import annotations
2+
3+
from abc import abstractmethod
4+
from collections.abc import Mapping, Sized
5+
from typing import (
6+
Any,
7+
List,
8+
Literal,
9+
Protocol,
10+
TypedDict,
11+
runtime_checkable,
12+
)
13+
14+
MatchingType = Literal["any", "exact", "none"]
15+
"""Directions for how environments are considered for selection.
16+
17+
- any: The image may be selected by Connect if not defined in the bundle manifest.
18+
- exact: The image must be defined in the bundle manifest
19+
- none: Never use this environment
20+
"""
21+
22+
23+
class Installation(TypedDict):
24+
"""Interpreter installation in an execution environment."""
25+
26+
path: str
27+
"""The absolute path to the interpreter's executable."""
28+
29+
version: str
30+
"""The semantic version of the interpreter."""
31+
32+
33+
class Installations(TypedDict):
34+
"""Interpreter installations in an execution environment."""
35+
36+
installations: List[Installation]
37+
"""Interpreter installations in an execution environment."""
38+
39+
40+
class Environment(Mapping[str, Any]):
41+
@abstractmethod
42+
def destroy(self) -> None:
43+
"""Destroy the environment.
44+
45+
Warnings
46+
--------
47+
This operation is irreversible.
48+
49+
Note
50+
----
51+
This action requires administrator privileges.
52+
"""
53+
54+
@abstractmethod
55+
def update(
56+
self,
57+
*,
58+
title: str,
59+
description: str | None = ...,
60+
matching: MatchingType | None = ...,
61+
supervisor: str | None = ...,
62+
python: Installations | None = ...,
63+
quarto: Installations | None = ...,
64+
r: Installations | None = ...,
65+
tensorflow: Installations | None = ...,
66+
) -> None:
67+
"""Update the environment.
68+
69+
Parameters
70+
----------
71+
title : str
72+
A human-readable title.
73+
description : str | None, optional, not required
74+
A human-readable description.
75+
matching : MatchingType, optional, not required
76+
Directions for how the environment is considered for selection
77+
supervisor : str | None, optional, not required
78+
Path to the supervisor script.
79+
python : Installations, optional, not required
80+
The Python installations available in this environment
81+
quarto : Installations, optional, not required
82+
The Quarto installations available in this environment
83+
r : Installations, optional, not required
84+
The R installations available in this environment
85+
tensorflow : Installations, optional, not required
86+
The Tensorflow installations available in this environment
87+
88+
Note
89+
----
90+
This action requires administrator privileges.
91+
"""
92+
93+
94+
@runtime_checkable
95+
class Environments(Sized, Protocol):
96+
def create(
97+
self,
98+
*,
99+
title: str,
100+
name: str,
101+
cluster_name: str | Literal["Kubernetes"],
102+
matching: MatchingType = "any",
103+
description: str | None = ...,
104+
supervisor: str | None = ...,
105+
python: Installations | None = ...,
106+
quarto: Installations | None = ...,
107+
r: Installations | None = ...,
108+
tensorflow: Installations | None = ...,
109+
) -> Environment:
110+
"""Create an environment.
111+
112+
Parameters
113+
----------
114+
title : str
115+
A human-readable title.
116+
name : str
117+
The container image name used for execution in this environment.
118+
cluster_name : str | Literal["Kubernetes"]
119+
The cluster identifier for this environment. Defaults to "Kubernetes" when Off-Host-Execution is enabled.
120+
description : str, optional
121+
A human-readable description.
122+
matching : MatchingType
123+
Directions for how the environment is considered for selection, by default is "any".
124+
supervisor : str, optional
125+
Path to the supervisor script
126+
python : Installations, optional
127+
The Python installations available in this environment
128+
quarto : Installations, optional
129+
The Quarto installations available in this environment
130+
r : Installations, optional
131+
The R installations available in this environment
132+
tensorflow : Installations, optional
133+
The Tensorflow installations available in this environment
134+
135+
Returns
136+
-------
137+
Environment
138+
139+
Note
140+
----
141+
This action requires administrator privileges.
142+
"""
143+
...
144+
145+
def find(self, guid: str, /) -> Environment: ...
146+
147+
def find_by(
148+
self,
149+
*,
150+
id: str = ..., # noqa: A002
151+
guid: str = ...,
152+
created_time: str = ...,
153+
updated_time: str = ...,
154+
title: str = ...,
155+
name: str = ...,
156+
description: str | None = ...,
157+
cluster_name: str | Literal["Kubernetes"] = ...,
158+
environment_type: str | Literal["Kubernetes"] = ...,
159+
matching: MatchingType = ...,
160+
supervisor: str | None = ...,
161+
python: Installations | None = ...,
162+
quarto: Installations | None = ...,
163+
r: Installations | None = ...,
164+
tensorflow: Installations | None = ...,
165+
) -> Environment | None:
166+
"""Find the first record matching the specified conditions.
167+
168+
There is no implied ordering, so if order matters, you should specify it yourself.
169+
170+
Parameters
171+
----------
172+
id : str
173+
The numerical identifier.
174+
guid : str
175+
The unique identifier.
176+
created_time : str
177+
The timestamp (RFC3339) when the environment was created.
178+
updated_time : str
179+
The timestamp (RFC3339) when the environment was updated.
180+
title : str
181+
A human-readable title.
182+
name : str
183+
The container image name used for execution in this environment.
184+
description : str, optional
185+
A human-readable description.
186+
cluster_name : str | Literal["Kubernetes"]
187+
The cluster identifier for this environment. Defaults to "Kubernetes" when Off-Host-Execution is enabled.
188+
environment_type : str | Literal["Kubernetes"]
189+
The cluster environment type. Defaults to "Kubernetes" when Off-Host-Execution is enabled.
190+
matching : MatchingType
191+
Directions for how the environment is considered for selection.
192+
supervisor : str, optional
193+
Path to the supervisor script
194+
python : Installations, optional
195+
The Python installations available in this environment
196+
quarto : Installations, optional
197+
The Quarto installations available in this environment
198+
r : Installations, optional
199+
The R installations available in this environment
200+
tensorflow : Installations, optional
201+
The Tensorflow installations available in this environment
202+
203+
Returns
204+
-------
205+
Environment | None
206+
207+
Note
208+
----
209+
This action requires administrator or publisher privileges.
210+
"""
211+
...

src/posit/connect/resources.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,3 +234,83 @@ def find_by(self, **conditions: Any) -> T | None:
234234
"""
235235
collection = self.fetch(**conditions)
236236
return next((v for v in collection if v.items() >= conditions.items()), None)
237+
238+
239+
class _Resource(dict):
240+
def __init__(self, ctx: Context, path: str, **attributes):
241+
self._ctx = ctx
242+
self._path = path
243+
super().__init__(**attributes)
244+
245+
def destroy(self) -> None:
246+
self._ctx.client.delete(self._path)
247+
248+
def update(self, **attributes): # type: ignore[reportIncompatibleMethodOverride]
249+
response = self._ctx.client.put(self._path, json=attributes)
250+
result = response.json()
251+
super().update(**result)
252+
253+
254+
class _ResourceSequence(Sequence):
255+
def __init__(self, ctx: Context, path: str, *, uid: str = "guid"):
256+
self._ctx = ctx
257+
self._path = path
258+
self._uid = uid
259+
260+
def __getitem__(self, index):
261+
return self.fetch()[index]
262+
263+
def __len__(self) -> int:
264+
return len(self.fetch())
265+
266+
def __iter__(self):
267+
return iter(self.fetch())
268+
269+
def __str__(self) -> str:
270+
return str(self.fetch())
271+
272+
def __repr__(self) -> str:
273+
return repr(self.fetch())
274+
275+
def create(self, **attributes: Any) -> Any:
276+
response = self._ctx.client.post(self._path, json=attributes)
277+
result = response.json()
278+
uid = result[self._uid]
279+
path = posixpath.join(self._path, uid)
280+
return _Resource(self._ctx, path, **result)
281+
282+
def fetch(self, **conditions) -> List[Any]:
283+
response = self._ctx.client.get(self._path, params=conditions)
284+
results = response.json()
285+
resources = []
286+
for result in results:
287+
uid = result[self._uid]
288+
path = posixpath.join(self._path, uid)
289+
resource = _Resource(self._ctx, path, **result)
290+
resources.append(resource)
291+
292+
return resources
293+
294+
def find(self, *args: str) -> Any:
295+
path = posixpath.join(self._path, *args)
296+
response = self._ctx.client.get(path)
297+
result = response.json()
298+
return _Resource(self._ctx, path, **result)
299+
300+
def find_by(self, **conditions) -> Any | None:
301+
"""
302+
Find the first record matching the specified conditions.
303+
304+
There is no implied ordering, so if order matters, you should specify it yourself.
305+
306+
Parameters
307+
----------
308+
**conditions : Any
309+
310+
Returns
311+
-------
312+
Optional[T]
313+
The first record matching the conditions, or `None` if no match is found.
314+
"""
315+
collection = self.fetch(**conditions)
316+
return next((v for v in collection if v.items() >= conditions.items()), None)

0 commit comments

Comments
 (0)