Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .coveragerc
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# This file contains the configuration settings for the coverage report generated.

[report]
# exclude '...' (ellipsis literal). This option uses regex, so an escaped literal is required.
exclude_also =
\.\.\.
exclude_lines =
if TYPE_CHECKING:

fail_under = 80
38 changes: 38 additions & 0 deletions integration/tests/posit/connect/test_environments.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import pytest
from packaging import version

from posit import connect

from . import CONNECT_VERSION


@pytest.mark.skipif(
CONNECT_VERSION < version.parse("2023.05.0"),
reason="Environments API unavailable",
)
class TestEnvironments:
@classmethod
def setup_class(cls):
cls.client = connect.Client()
cls.environment = cls.client.environments.create(
title="title", name="name", cluster_name="Kubernetes"
)

@classmethod
def teardown_class(cls):
cls.environment.destroy()
assert len(cls.client.environments) == 0

def test_find(self):
uid = self.environment["guid"]
environment = self.client.environments.find(uid)
assert environment == self.environment

def test_find_by(self):
environment = self.client.environments.find_by(name="name")
assert environment == self.environment

def test_update(self):
assert self.environment["title"] == "title"
self.environment.update(title="new-title")
assert self.environment["title"] == "new-title"
15 changes: 11 additions & 4 deletions src/posit/connect/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,10 @@

from __future__ import annotations

from typing import overload
from typing import TYPE_CHECKING, overload

from requests import Response, Session

from posit.connect.tags import Tags

from . import hooks, me
from .auth import Auth
from .config import Config
Expand All @@ -17,11 +15,15 @@
from .metrics import Metrics
from .oauth import OAuth
from .packages import Packages
from .resources import ResourceParameters
from .resources import ResourceParameters, _ResourceSequence
from .tags import Tags
from .tasks import Tasks
from .users import User, Users
from .vanities import Vanities

if TYPE_CHECKING:
from .environments import Environments


class Client(ContextManager):
"""
Expand Down Expand Up @@ -303,6 +305,11 @@ def packages(self) -> Packages:
def vanities(self) -> Vanities:
return Vanities(self.resource_params)

@property
@requires(version="2023.05.0")
def environments(self) -> Environments:
return _ResourceSequence(self._ctx, "v1/environments")
Comment on lines +308 to +311
Copy link
Collaborator Author

@tdstein tdstein Dec 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is where the implicit type-checking occurs. The method signature returns the Environments protocol while the implementation returns _ResourceSequence.


def __del__(self):
"""Close the session when the Client instance is deleted."""
if hasattr(self, "session") and self.session is not None:
Expand Down
211 changes: 211 additions & 0 deletions src/posit/connect/environments.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
from __future__ import annotations

from abc import abstractmethod
from collections.abc import Mapping, Sized
from typing import (
Any,
List,
Literal,
Protocol,
TypedDict,
runtime_checkable,
)

MatchingType = Literal["any", "exact", "none"]
"""Directions for how environments are considered for selection.

- any: The image may be selected by Connect if not defined in the bundle manifest.
- exact: The image must be defined in the bundle manifest
- none: Never use this environment
"""


class Installation(TypedDict):
"""Interpreter installation in an execution environment."""

path: str
"""The absolute path to the interpreter's executable."""

version: str
"""The semantic version of the interpreter."""


class Installations(TypedDict):
"""Interpreter installations in an execution environment."""

installations: List[Installation]
"""Interpreter installations in an execution environment."""


class Environment(Mapping[str, Any]):
@abstractmethod
def destroy(self) -> None:
"""Destroy the environment.

Warnings
--------
This operation is irreversible.

Note
----
This action requires administrator privileges.
"""

@abstractmethod
def update(
self,
*,
title: str,
description: str | None = ...,
matching: MatchingType | None = ...,
supervisor: str | None = ...,
python: Installations | None = ...,
quarto: Installations | None = ...,
r: Installations | None = ...,
tensorflow: Installations | None = ...,
) -> None:
"""Update the environment.

Parameters
----------
title : str
A human-readable title.
description : str | None, optional, not required
A human-readable description.
matching : MatchingType, optional, not required
Directions for how the environment is considered for selection
supervisor : str | None, optional, not required
Path to the supervisor script.
python : Installations, optional, not required
The Python installations available in this environment
quarto : Installations, optional, not required
The Quarto installations available in this environment
r : Installations, optional, not required
The R installations available in this environment
tensorflow : Installations, optional, not required
The Tensorflow installations available in this environment

Note
----
This action requires administrator privileges.
"""


@runtime_checkable
class Environments(Sized, Protocol):
def create(
self,
*,
title: str,
name: str,
cluster_name: str | Literal["Kubernetes"],
matching: MatchingType = "any",
description: str | None = ...,
supervisor: str | None = ...,
python: Installations | None = ...,
quarto: Installations | None = ...,
r: Installations | None = ...,
tensorflow: Installations | None = ...,
) -> Environment:
"""Create an environment.

Parameters
----------
title : str
A human-readable title.
name : str
The container image name used for execution in this environment.
cluster_name : str | Literal["Kubernetes"]
The cluster identifier for this environment. Defaults to "Kubernetes" when Off-Host-Execution is enabled.
description : str, optional
A human-readable description.
matching : MatchingType
Directions for how the environment is considered for selection, by default is "any".
supervisor : str, optional
Path to the supervisor script
python : Installations, optional
The Python installations available in this environment
quarto : Installations, optional
The Quarto installations available in this environment
r : Installations, optional
The R installations available in this environment
tensorflow : Installations, optional
The Tensorflow installations available in this environment

Returns
-------
Environment

Note
----
This action requires administrator privileges.
"""
...

def find(self, guid: str, /) -> Environment: ...

def find_by(
self,
*,
id: str = ..., # noqa: A002
guid: str = ...,
created_time: str = ...,
updated_time: str = ...,
title: str = ...,
name: str = ...,
description: str | None = ...,
cluster_name: str | Literal["Kubernetes"] = ...,
environment_type: str | Literal["Kubernetes"] = ...,
matching: MatchingType = ...,
supervisor: str | None = ...,
python: Installations | None = ...,
quarto: Installations | None = ...,
r: Installations | None = ...,
tensorflow: Installations | None = ...,
) -> Environment | None:
"""Find the first record matching the specified conditions.

There is no implied ordering, so if order matters, you should specify it yourself.

Parameters
----------
id : str
The numerical identifier.
guid : str
The unique identifier.
created_time : str
The timestamp (RFC3339) when the environment was created.
updated_time : str
The timestamp (RFC3339) when the environment was updated.
title : str
A human-readable title.
name : str
The container image name used for execution in this environment.
description : str, optional
A human-readable description.
cluster_name : str | Literal["Kubernetes"]
The cluster identifier for this environment. Defaults to "Kubernetes" when Off-Host-Execution is enabled.
environment_type : str | Literal["Kubernetes"]
The cluster environment type. Defaults to "Kubernetes" when Off-Host-Execution is enabled.
matching : MatchingType
Directions for how the environment is considered for selection.
supervisor : str, optional
Path to the supervisor script
python : Installations, optional
The Python installations available in this environment
quarto : Installations, optional
The Quarto installations available in this environment
r : Installations, optional
The R installations available in this environment
tensorflow : Installations, optional
The Tensorflow installations available in this environment

Returns
-------
Environment | None

Note
----
This action requires administrator or publisher privileges.
"""
...
80 changes: 80 additions & 0 deletions src/posit/connect/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,3 +234,83 @@ def find_by(self, **conditions: Any) -> T | None:
"""
collection = self.fetch(**conditions)
return next((v for v in collection if v.items() >= conditions.items()), None)


class _Resource(dict):
def __init__(self, ctx: Context, path: str, **attributes):
self._ctx = ctx
self._path = path
super().__init__(**attributes)

def destroy(self) -> None:
self._ctx.client.delete(self._path)

def update(self, **attributes): # type: ignore[reportIncompatibleMethodOverride]
response = self._ctx.client.put(self._path, json=attributes)
result = response.json()
super().update(**result)


class _ResourceSequence(Sequence):
def __init__(self, ctx: Context, path: str, *, uid: str = "guid"):
self._ctx = ctx
self._path = path
self._uid = uid

def __getitem__(self, index):
return self.fetch()[index]

def __len__(self) -> int:
return len(self.fetch())

def __iter__(self):
return iter(self.fetch())

def __str__(self) -> str:
return str(self.fetch())

def __repr__(self) -> str:
return repr(self.fetch())

def create(self, **attributes: Any) -> Any:
response = self._ctx.client.post(self._path, json=attributes)
result = response.json()
uid = result[self._uid]
path = posixpath.join(self._path, uid)
return _Resource(self._ctx, path, **result)

def fetch(self, **conditions) -> List[Any]:
response = self._ctx.client.get(self._path, params=conditions)
results = response.json()
resources = []
for result in results:
uid = result[self._uid]
path = posixpath.join(self._path, uid)
resource = _Resource(self._ctx, path, **result)
resources.append(resource)

return resources

def find(self, *args: str) -> Any:
path = posixpath.join(self._path, *args)
response = self._ctx.client.get(path)
result = response.json()
return _Resource(self._ctx, path, **result)

def find_by(self, **conditions) -> Any | None:
"""
Find the first record matching the specified conditions.

There is no implied ordering, so if order matters, you should specify it yourself.

Parameters
----------
**conditions : Any

Returns
-------
Optional[T]
The first record matching the conditions, or `None` if no match is found.
"""
collection = self.fetch(**conditions)
return next((v for v in collection if v.items() >= conditions.items()), None)
Loading
Loading